diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index ab02c247..19b3f818 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -104,6 +104,46 @@ function computeDueDate(scheduledSendAt, netDays = 30) { return new Date(scheduledSendAt.getTime() + ensureInt(netDays) * 24 * 60 * 60 * 1000); } +/** + * Resolve the net-days a new invoice's due date should be anchored to. + * Single source of truth so the editor (split picker), legacy callers, + * and quote→invoice conversion all land on the same number. Priority: + * + * 1. `payload.netDays` — explicit caller override (installment spawn + * passes the snapshot's net_days here). + * 2. Split picker (migration 124): payment_net_days_templates.net_days + * via `payload.paymentNetDaysTemplateId`. This is what the bill + * editor actually sends; the old code only read the legacy FK and + * so silently ignored Net 60 / 90 selections. + * 3. Legacy single FK: payment_term_templates.net_days via + * `payload.paymentTermTemplateId`. + * 4. The `crm_payment_default_net_days` setting (admin-configured). + * 5. 30 — historical hard default. + */ +async function resolveNetDays(payload, trx = db) { + if (payload && payload.netDays != null && payload.netDays !== '') { + const n = ensureInt(payload.netDays); + if (n) return n; + } + if (payload && payload.paymentNetDaysTemplateId) { + const probe = await trx('payment_net_days_templates') + .where({ id: payload.paymentNetDaysTemplateId }) + .select('net_days') + .first(); + if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30; + } + if (payload && payload.paymentTermTemplateId) { + const probe = await trx('payment_term_templates') + .where({ id: payload.paymentTermTemplateId }) + .select('net_days') + .first(); + if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30; + } + const setting = ensureInt(await getAppSetting('crm_payment_default_net_days')); + if (setting) return setting; + return 30; +} + /** * Resolve the deal_uuid for a new invoice row (migration 140). Priority: * @@ -642,18 +682,13 @@ async function createInvoice(payload, adminId, trx = db) { // used `invoiceNumber` here. const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10); const scheduledSendAt = payload.scheduledSendAt ? new Date(payload.scheduledSendAt) : null; - // Resolve the selected payment-term template's net_days BEFORE - // computing the due date so Net 60 / 90 templates actually push - // the due date out. Falls back to 30 when no template is set - // (matches the historical default). - let resolvedNetDays = 30; - if (payload.paymentTermTemplateId) { - const probe = await trx('payment_term_templates') - .where({ id: payload.paymentTermTemplateId }) - .select('net_days') - .first(); - if (probe && probe.net_days != null) resolvedNetDays = ensureInt(probe.net_days) || 30; - } + // Resolve net_days BEFORE computing the due date so Net 60 / 90 + // selections actually push the due date out. resolveNetDays honors + // the split picker FK the editor sends, the legacy single FK, and + // the crm_payment_default_net_days setting (see helper). The clock + // starts on the SEND date when the invoice is scheduled, otherwise + // the issue date — so a future send pushes the due date out too. + const resolvedNetDays = await resolveNetDays(payload, trx); const dueDate = payload.dueDate || computeDueDate(scheduledSendAt || new Date(issueDate), resolvedNetDays) .toISOString().slice(0, 10); @@ -927,10 +962,13 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre } // netDays drives the due-date offset on every scheduled invoice - // created here. Defaults to 30 when the caller doesn't pass one; - // callers in quoteService now pass the converting quote's - // payment-term net_days so Net 60 / 90 templates flow through. - const resolvedNetDays = ensureInt(netDays) || 30; + // created here. Callers in quoteService pass the converting quote's + // payment-term net_days so Net 60 / 90 templates flow through; when + // absent we fall back to the crm_payment_default_net_days setting + // (then 30) rather than silently using 30, matching createInvoice. + const resolvedNetDays = ensureInt(netDays) + || ensureInt(await getAppSetting('crm_payment_default_net_days')) + || 30; const total = installments.length; const acceptanceTime = new Date(); const invoiceIds = []; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 415999bf..a6062a2c 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3512,6 +3512,8 @@ "total": "Gesamt", "sourceQuote": "Vom Angebot", "issueDate": "Ausgestellt am", + "dueDateOverrideOn": "Manuelles Fälligkeitsdatum — Häkchen entfernen, um es automatisch aus Versanddatum + Zahlungsziel zu berechnen", + "dueDateOverrideOff": "Automatisch aus Versanddatum + Zahlungsziel — ankreuzen, um es manuell zu setzen", "scheduledSendAt": "Geplanter Versand (optional)", "installment": "Rate", "paid": "Bezahlt", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 306f7e5f..45da5bbe 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3557,6 +3557,8 @@ "field": { "issueDate": "Issued", "dueDate": "Due", + "dueDateOverrideOn": "Manual due date — untick to auto-set from send date + payment term", + "dueDateOverrideOff": "Auto from send date + payment term — tick to set manually", "scheduledSendAt": "Scheduled send", "installment": "Installment", "total": "Total", diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx index 38edc81e..8426117d 100644 --- a/frontend/src/pages/admin/bills/BillEditorPage.tsx +++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx @@ -48,6 +48,12 @@ export const BillEditorPage: React.FC = () => { const [currency, setCurrency] = useState('CHF'); const [issueDate, setIssueDate] = useState(new Date().toISOString().slice(0, 10)); const [dueDate, setDueDate] = useState(''); + // Due date is normally view-only: it auto-tracks (send date else issue + // date) + the selected Net-days template, so the payment clock starts + // on the day the invoice actually goes out. Flipping this lets the + // admin type a different date by hand; we keep it pinned so the auto + // effect below stops clobbering their value. + const [dueDateOverridden, setDueDateOverridden] = useState(false); const [scheduledSendAt, setScheduledSendAt] = useState(''); // null = inherit profile default at render time. 'none' / 'swiss' / // 'epc' = explicit per-invoice override. (Existing invoices that @@ -124,6 +130,10 @@ export const BillEditorPage: React.FC = () => { setCurrency(inv.currency); setIssueDate(inv.issueDate); setDueDate(inv.dueDate); + // The invoice already carries a due date — preserve it rather than + // letting the auto effect recompute and surprise the admin. They + // can untick "Override" to re-enable auto-tracking. + setDueDateOverridden(true); setScheduledSendAt(inv.scheduledSendAt ? inv.scheduledSendAt.slice(0, 16) : ''); // Preserve null when the saved invoice has no explicit format — // it inherits the profile default at render time. @@ -317,6 +327,25 @@ export const BillEditorPage: React.FC = () => { setPaymentTimingTemplateId((prev) => prev ?? defaultTiming.id); }, [isEdit, netDaysTemplates, timingTemplates, appSettings]); + // Auto-track the due date off (scheduled send date else issue date) + + // the selected Net-days template, mirroring the backend's + // computeDueDate. The clock starts the day the invoice goes out, so + // scheduling a future send pushes the due date out with it. Skipped + // once the admin overrides the field by hand. Date math is in UTC to + // match the backend (which parses the YYYY-MM-DD base as UTC midnight). + useEffect(() => { + if (dueDateOverridden) return; + const base = (scheduledSendAt ? scheduledSendAt.slice(0, 10) : issueDate) || ''; + if (!/^\d{4}-\d{2}-\d{2}$/.test(base)) return; + const tpl = netDaysTemplates?.templates?.find((t) => t.id === paymentNetDaysTemplateId); + const netDays = tpl?.netDays != null + ? Number(tpl.netDays) + : Number(appSettings?.crm_payment_default_net_days) || 30; + const d = new Date(`${base}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + netDays); + setDueDate(d.toISOString().slice(0, 10)); + }, [dueDateOverridden, scheduledSendAt, issueDate, paymentNetDaysTemplateId, netDaysTemplates, appSettings]); + const buildPayload = (): InvoiceCreatePayload => ({ customerAccountId: customerId || 0, currency, @@ -501,7 +530,25 @@ export const BillEditorPage: React.FC = () => {