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" />
+
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;