fix(invoices): auto-track due date from send date + payment term
Due date now derives from (scheduled send date else issue date) plus the selected Net-days template, both in the editor and on save. The bill editor renders it read-only with an Override toggle for manual entry; existing invoices preserve their stored due date. Backend adds a single resolveNetDays resolver that honors the split payment-net-days template (previously only the legacy FK was read) and the crm_payment_default_net_days setting, used by createInvoice and the installment-spawn path alike.
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = () => {
|
||||
<h3 className="font-semibold mb-2">{t('bills.section.details', 'Details')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<LocalizedDateInput label={t('bills.field.issueDate', 'Issue date') as string} value={issueDate} onChange={setIssueDate} />
|
||||
<LocalizedDateInput label={t('bills.field.dueDate', 'Due date') as string} value={dueDate} onChange={setDueDate} />
|
||||
<div>
|
||||
<LocalizedDateInput
|
||||
label={t('bills.field.dueDate', 'Due date') as string}
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
disabled={!dueDateOverridden}
|
||||
/>
|
||||
<label className="mt-1.5 flex items-center gap-2 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dueDateOverridden}
|
||||
onChange={(e) => setDueDateOverridden(e.target.checked)}
|
||||
className="rounded border-neutral-300 dark:border-neutral-600"
|
||||
/>
|
||||
{dueDateOverridden
|
||||
? t('bills.field.dueDateOverrideOn', 'Manual due date — untick to auto-set from send date + payment term')
|
||||
: t('bills.field.dueDateOverrideOff', 'Auto from send date + payment term — tick to set manually')}
|
||||
</label>
|
||||
</div>
|
||||
<Input type="datetime-local" label={t('bills.field.scheduledSendAt', 'Scheduled send (optional)') as string}
|
||||
value={scheduledSendAt} onChange={(e) => setScheduledSendAt(e.target.value)} />
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user