feat(billing): manual cadence + fix admin date inputs ignoring date-format setting
Manual billing cadence
Adds a "Manual (trigger only)" cadence alongside monthly/quarterly. It reuses
the monthly draft accumulator — invoices and billed hours pile onto one running
draft — but stores NULL monthly_period_start/end so the scheduler's auto-flush
never matches. The draft ships only when an admin clicks "Trigger invoice now".
No migration: billing_cadence is a free-form string column gated by validators.
- adminCustomers.js: allow 'manual' in billing_cadence validator
- invoiceService.js: route manual through accumulator; NULL periods + placeholder
issue/due date in getOrCreateMonthlyDraft
- customerHoursService.js: manual auto-appends hours to running draft;
billUnbilledEntries refuses manual (CADENCE_MISMATCH)
- CustomerDetailPage.tsx: dropdown option, cycle-day hidden for manual,
NULL-period-safe draft preview + trigger button, manual-specific copy
- customerAdmin.service.ts: cadence union + nullable periodStart/periodEnd
- en.json / de.json: manual, triggerConfirmManual, triggerHintManual,
draftPreview.titleManual
Date-format fixes
Replace raw <input type="date"> (browser-locale) with LocalizedDateInput so
these admin surfaces honor the general_date_format setting:
- ContractEditorPage.tsx (issue / valid-until / event dates)
- QuoteEditorPage.tsx (event / valid-until dates)
- EventDetailsPage.tsx (expiry date)
- HoursSection.tsx (entry date)
This commit is contained in:
@@ -395,7 +395,7 @@ router.put('/:id', [
|
||||
// generated invoice to billing_cycle_day of the next period.
|
||||
// Cycle day spans -15..-1 (days before month end) and 1..28
|
||||
// (day of month) per migration 128 + service-layer clamp.
|
||||
body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly']),
|
||||
body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly', 'manual']),
|
||||
body('billing_cycle_day').optional().isInt({ min: -15, max: 28 })
|
||||
.withMessage('billing_cycle_day must be -15..-1 (days before month end) or 1..28 (day of month)'),
|
||||
// Per-customer Skonto opt-out (migration 112).
|
||||
|
||||
@@ -202,8 +202,10 @@ async function createEntry(customerId, payload, adminId) {
|
||||
const inserted = await trx('customer_hour_entries').insert(row).returning('id');
|
||||
const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
// Monthly-mode customers get the auto-append treatment.
|
||||
if (customer.billing_cadence === 'monthly') {
|
||||
// Accumulator-mode customers (monthly + manual) get the auto-append
|
||||
// treatment — the entry lands on the running draft instead of staying
|
||||
// unbilled. Manual differs only in that its draft never auto-flushes.
|
||||
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
|
||||
const fullEntry = { ...row, id: entryId };
|
||||
const rate = resolveEffectiveRate(fullEntry, customer);
|
||||
const lineItem = buildLineItemFromEntry(fullEntry, rate);
|
||||
@@ -387,15 +389,16 @@ async function deleteEntry(entryId, adminId) {
|
||||
/**
|
||||
* Per-event flow: mint a standalone invoice from all unbilled entries
|
||||
* for this customer, one line per entry. Refuses when the customer is
|
||||
* monthly-mode (those entries auto-billed on save, so there should be
|
||||
* no unbilled rows). Returns the new invoice id.
|
||||
* in an accumulator mode (monthly / manual) — those entries auto-billed
|
||||
* onto the running draft on save, so there should be no unbilled rows.
|
||||
* Returns the new invoice id.
|
||||
*/
|
||||
async function billUnbilledEntries(customerId, adminId) {
|
||||
const customer = await db('customer_accounts').where({ id: customerId }).first();
|
||||
if (!customer) throw new AppError('Customer not found', 404);
|
||||
if (customer.billing_cadence === 'monthly') {
|
||||
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
|
||||
throw new AppError(
|
||||
'Monthly-mode customers auto-append entries to the running draft; "Bill these hours" is for per-event customers.',
|
||||
'Accumulator-mode customers (monthly / manual) auto-append entries to the running draft; "Bill these hours" is for per-event customers.',
|
||||
409,
|
||||
'CADENCE_MISMATCH',
|
||||
);
|
||||
|
||||
@@ -293,6 +293,13 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Manual cadence has no billing cycle: the draft accumulates
|
||||
// indefinitely and ships ONLY via the admin "Trigger invoice now"
|
||||
// gesture, so it carries NO period_end. The scheduler's auto-flush
|
||||
// filter is `monthly_period_end <= today`, which a NULL period_end
|
||||
// can never satisfy — keeping manual drafts out of the cron path.
|
||||
const isManual = customer.billing_cadence === 'manual';
|
||||
|
||||
// Resolve period_end: prefer the cadence in the current month, but
|
||||
// if it has already passed, roll to next month so the new draft
|
||||
// gathers items toward the NEXT bill.
|
||||
@@ -302,8 +309,11 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
|
||||
const nextMonth = today.getMonth() + 1;
|
||||
target = computeMonthlyCadenceDate(today.getFullYear(), nextMonth, cycleDay);
|
||||
}
|
||||
const periodStart = new Date(target.getFullYear(), target.getMonth(), 1);
|
||||
const periodEnd = target;
|
||||
const periodStart = isManual ? null : new Date(target.getFullYear(), target.getMonth(), 1);
|
||||
const periodEnd = isManual ? null : target;
|
||||
// Placeholder issue/due date for the empty draft row — recomputed at
|
||||
// issuance time. Manual drafts have no period_end, so fall back to today.
|
||||
const placeholderDate = (periodEnd || today).toISOString().slice(0, 10);
|
||||
|
||||
// Look up any existing open draft for this customer. We deliberately
|
||||
// do NOT filter by monthly_period_end here — only one draft can be
|
||||
@@ -341,8 +351,8 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
|
||||
event_id: null,
|
||||
language,
|
||||
currency,
|
||||
issue_date: periodEnd.toISOString().slice(0, 10),
|
||||
due_date: periodEnd.toISOString().slice(0, 10), // recomputed at issuance time
|
||||
issue_date: placeholderDate,
|
||||
due_date: placeholderDate, // recomputed at issuance time
|
||||
installment_index: 0,
|
||||
installment_total: 1,
|
||||
status: 'scheduled',
|
||||
@@ -355,8 +365,8 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
|
||||
business_bank_account_id: bank?.id || null,
|
||||
qr_format: null,
|
||||
is_monthly_draft: true,
|
||||
monthly_period_start: periodStart.toISOString().slice(0, 10),
|
||||
monthly_period_end: periodEnd.toISOString().slice(0, 10),
|
||||
monthly_period_start: periodStart ? periodStart.toISOString().slice(0, 10) : null,
|
||||
monthly_period_end: periodEnd ? periodEnd.toISOString().slice(0, 10) : null,
|
||||
// Migration 140 — each monthly-draft cycle is its own deal (no
|
||||
// quote/contract chain). Fresh UUID at creation; subsequent line
|
||||
// appends just mutate this same row, so the uuid sticks.
|
||||
@@ -677,15 +687,19 @@ async function createInvoice(payload, adminId, trx = db) {
|
||||
const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first();
|
||||
ensureCustomerCanBill(customer);
|
||||
|
||||
// Monthly-billing intercept (migration 128). For customers in
|
||||
// billing_cadence='monthly' mode every createInvoice call APPENDS
|
||||
// line items onto the running monthly-draft instead of minting a
|
||||
// Accumulator intercept (migration 128). For customers in
|
||||
// billing_cadence='monthly' OR 'manual' mode every createInvoice call
|
||||
// APPENDS line items onto a single running draft instead of minting a
|
||||
// fresh invoice. Admin sees the editor flow exactly as before; the
|
||||
// returned id is the draft's id so the UI can redirect to the
|
||||
// accumulator. `_skipMonthlyRouting` is the escape hatch used by
|
||||
// internal helpers that need to mint a non-draft row (e.g. the
|
||||
// accumulator itself, or future test fixtures).
|
||||
if (customer.billing_cadence === 'monthly' && !payload._skipMonthlyRouting) {
|
||||
// accumulator. The two modes differ only in WHEN the draft ships:
|
||||
// 'monthly' auto-flushes on the cadence day (scheduler), 'manual'
|
||||
// never auto-flushes (no period_end) and ships only via the admin
|
||||
// "Trigger invoice now" gesture. `_skipMonthlyRouting` is the escape
|
||||
// hatch used by internal helpers that need to mint a non-draft row
|
||||
// (e.g. the accumulator itself, or future test fixtures).
|
||||
if ((customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual')
|
||||
&& !payload._skipMonthlyRouting) {
|
||||
const draft = await appendToMonthlyDraft(payload, customer, adminId, trx);
|
||||
return { invoiceIds: draft?.id ? [draft.id] : [] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user