From 06b4522deceb06ed1d90e188a62b82fc7e0e91d6 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:27:10 +0200 Subject: [PATCH] feat(billing): manual cadence + fix admin date inputs ignoring date-format setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (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) --- backend/src/routes/adminCustomers.js | 2 +- backend/src/services/customerHoursService.js | 15 +++-- backend/src/services/invoiceService.js | 40 ++++++++----- .../src/components/admin/HoursSection.tsx | 5 +- frontend/src/i18n/locales/de.json | 6 +- frontend/src/i18n/locales/en.json | 6 +- .../src/pages/admin/CustomerDetailPage.tsx | 58 ++++++++++++------- frontend/src/pages/admin/EventDetailsPage.tsx | 7 +-- .../admin/contracts/ContractEditorPage.tsx | 8 +-- .../pages/admin/quotes/QuoteEditorPage.tsx | 10 ++-- .../src/services/customerAdmin.service.ts | 12 ++-- 11 files changed, 104 insertions(+), 65 deletions(-) diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js index 4b2b8606..026af794 100644 --- a/backend/src/routes/adminCustomers.js +++ b/backend/src/routes/adminCustomers.js @@ -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). diff --git a/backend/src/services/customerHoursService.js b/backend/src/services/customerHoursService.js index e07644e7..d35065fa 100644 --- a/backend/src/services/customerHoursService.js +++ b/backend/src/services/customerHoursService.js @@ -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', ); diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 83467b9d..bc863f0c 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -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] : [] }; } diff --git a/frontend/src/components/admin/HoursSection.tsx b/frontend/src/components/admin/HoursSection.tsx index 13414230..62536a2a 100644 --- a/frontend/src/components/admin/HoursSection.tsx +++ b/frontend/src/components/admin/HoursSection.tsx @@ -17,7 +17,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { Clock } from 'lucide-react'; -import { Button, Card } from '../common'; +import { Button, Card, LocalizedDateInput } from '../common'; import { DecimalInput } from '../common/DecimalInput'; import { parseLocaleDecimal, parseDuration } from '../../utils/parsers'; import { customerAdminService } from '../../services/customerAdmin.service'; @@ -224,8 +224,7 @@ export const HoursSection: React.FC = ({ - setEntryDate(e.target.value)} className="input w-full" /> +
- {form.billingCadence && form.billingCadence !== 'per_event' && ( + {(form.billingCadence === 'monthly' || form.billingCadence === 'quarterly') && (
- setValidUntil(e.target.value)} /> +
@@ -419,7 +419,7 @@ export const ContractEditorPage: React.FC = () => { - setEventDate(e.target.value)} /> +
diff --git a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx index bf8806de..22a68839 100644 --- a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx +++ b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx @@ -16,7 +16,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ArrowLeft, Eye, Send } from 'lucide-react'; -import { Button, Card, Loading, Input } from '../../../components/common'; +import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common'; import { quotesService, type QuoteCreatePayload, @@ -490,8 +490,8 @@ export const QuoteEditorPage: React.FC = () => {
setForm((f) => ({ ...f, eventName: e.target.value }))} /> - setForm((f) => ({ ...f, eventDate: e.target.value }))} /> + setForm((f) => ({ ...f, eventDate: iso }))} /> setForm((f) => ({ ...f, eventTimeStart: e.target.value }))} /> { setForm((f) => ({ ...f, expectedDurationHours: e.target.value }))} /> - setForm((f) => ({ ...f, validUntil: e.target.value }))} /> + setForm((f) => ({ ...f, validUntil: iso }))} />
diff --git a/frontend/src/services/customerAdmin.service.ts b/frontend/src/services/customerAdmin.service.ts index 9ecdbded..08330679 100644 --- a/frontend/src/services/customerAdmin.service.ts +++ b/frontend/src/services/customerAdmin.service.ts @@ -58,7 +58,7 @@ export interface CustomerAccountDetail extends CustomerAccountSummary { * - 'monthly' / 'quarterly': snap every scheduled invoice to * `billingCycleDay` of the next period. */ - billingCadence?: 'per_event' | 'monthly' | 'quarterly'; + billingCadence?: 'per_event' | 'monthly' | 'quarterly' | 'manual'; billingCycleDay?: number; /** Per-customer Skonto opt-out (migration 112). When true, none of * this customer's invoices qualify for an early-payment discount, @@ -360,16 +360,18 @@ export const customerAdminService = { }, }; -/** Open monthly bill accumulator preview (migration 128). One row in +/** Open bill accumulator preview (migration 128). One row in * the invoices table with is_monthly_draft=true that gathers every * invoice line created for this customer during the current period; - * ships on the cadence day or via triggerMonthlyBill. */ + * ships on the cadence day or via triggerMonthlyBill. Manual-cadence + * drafts carry no period (periodStart/End null) and ship only on the + * admin trigger. */ export interface MonthlyDraftPreview { id: number; invoiceNumber: string; currency: string; - periodStart: string; - periodEnd: string; + periodStart: string | null; + periodEnd: string | null; netAmountMinor: number; vatRate: number | null; vatAmountMinor: number;