diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index aa7f6bc..686c78a 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -196,6 +196,8 @@ export const patchClientModule = (id: string, body: Record): Pr apiFetch<{ clientModule: ClientModule }>(`/client-modules/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) .then((r) => r.clientModule) +export const getBillingSettings = (): Promise<{ paymentTermsDays: number }> => + apiFetch<{ paymentTermsDays: number }>('/settings/billing') export const createDocument = (body: Record): Promise => apiFetch<{ document: Doc }>('/documents', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.document) export const getDocumentFull = (id: string): Promise<{ document: Doc; events: DocumentEvent[]; emails: EmailLogRow[]; shares: Share[] }> => diff --git a/apps/hq-web/src/pages/NewDocument.tsx b/apps/hq-web/src/pages/NewDocument.tsx index 3c13687..a911851 100644 --- a/apps/hq-web/src/pages/NewDocument.tsx +++ b/apps/hq-web/src/pages/NewDocument.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom' import { formatINR, fromRupees } from '@sims/domain' import { Badge, Button, Field, Notice, PageHeader, Toolbar } from '@sims/ui' import { - createDocument, getClients, getModules, getPrices, previewDocument, + createDocument, getBillingSettings, getClients, getModules, getPrices, previewDocument, KIND_LABEL, type Client, type DocTotals, type Kind, type ModulePrice, } from '../api' import { LivePreview } from '../components/LivePreview' @@ -66,6 +66,19 @@ export function NewDocument() { const [error, setError] = useState() const [saving, setSaving] = useState(false) + // D18: invoice due date — auto-filled from billing.payment_terms_days, editable. + const [dueDate, setDueDate] = useState('') + const [termsDays, setTermsDays] = useState() + useEffect(() => { + getBillingSettings().then((s) => setTermsDays(s.paymentTermsDays)).catch(() => { /* field stays manual */ }) + }, []) + useEffect(() => { + if (docType === 'INVOICE' && dueDate === '' && termsDays !== undefined) { + const d = new Date(); d.setUTCDate(d.getUTCDate() + termsDays) + setDueDate(d.toISOString().slice(0, 10)) + } + }, [docType, termsDays]) // eslint-disable-line react-hooks/exhaustive-deps -- prefill once, never clobber edits + // -- live preview wiring: server totals + warnings replace all client money math -- const [commitNonce, setCommitNonce] = useState(0) const commit = () => setCommitNonce((n) => n + 1) @@ -112,8 +125,9 @@ export function NewDocument() { contentLines: splitContent(l.contentText), })), ...(terms.trim() !== '' ? { terms: terms.trim() } : {}), + ...(docType === 'INVOICE' && dueDate !== '' ? { dueDate } : {}), }) - const previewBody = useMemo(() => JSON.stringify(buildDraftBody()), [docType, client, lines, terms]) + const previewBody = useMemo(() => JSON.stringify(buildDraftBody()), [docType, client, lines, terms, dueDate]) const save = () => { if (client === undefined) { setError('Pick a client first'); return } @@ -192,6 +206,14 @@ export function NewDocument() { ))} + {docType === 'INVOICE' && ( + + { setDueDate(e.target.value); commit() }} + /> + + )}

Lines

{lines.map((l, i) => { diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index e326632..9de75c5 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -429,7 +429,8 @@ export function apiRouter( const t = prepared.totals const doc: Doc = { id: 'preview', docType: input.docType, docNo: null, fy: fyOf(prepared.docDate), - clientId: client.id, docDate: prepared.docDate, status: 'draft', refDocId: null, + clientId: client.id, docDate: prepared.docDate, dueDate: prepared.dueDate ?? null, + status: 'draft', refDocId: null, taxablePaise: t.taxablePaise, cgstPaise: t.cgstPaise, sgstPaise: t.sgstPaise, igstPaise: t.igstPaise, roundOffPaise: t.roundOffPaise, payablePaise: t.payablePaise, payload: prepared.payload, source: 'preview', createdBy: staffId(res), createdAt: '', diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index 1841788..03246e7 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -53,6 +53,7 @@ CREATE TABLE IF NOT EXISTS document ( id TEXT PRIMARY KEY, doc_type TEXT NOT NULL CHECK (doc_type IN ('QUOTATION','PROFORMA','INVOICE','RECEIPT','CREDIT_NOTE')), doc_no TEXT, fy TEXT NOT NULL, client_id TEXT NOT NULL, doc_date TEXT NOT NULL, + due_date TEXT, -- INVOICE only: stamped at issue from billing.payment_terms_days when absent (D18) status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','sent','accepted','invoiced','part_paid','paid','lost','cancelled')), ref_doc_id TEXT, -- QT→PI→INV chain; CN → the invoice it amends @@ -187,6 +188,10 @@ function migrate(db: DB): void { if (!emailCols.some((c) => c.name === 'bounced')) { db.exec(`ALTER TABLE email_log ADD COLUMN bounced INTEGER NOT NULL DEFAULT 0`) } + const docCols = db.prepare(`PRAGMA table_info(document)`).all() as { name: string }[] + if (!docCols.some((c) => c.name === 'due_date')) { + db.exec(`ALTER TABLE document ADD COLUMN due_date TEXT`) + } const clientCols = db.prepare(`PRAGMA table_info(client)`).all() as { name: string }[] if (!clientCols.some((c) => c.name === 'owner_id')) { db.exec(`ALTER TABLE client ADD COLUMN owner_id TEXT`) diff --git a/apps/hq/src/reminder-templates.ts b/apps/hq/src/reminder-templates.ts index 80cf6b5..7d49fc8 100644 --- a/apps/hq/src/reminder-templates.ts +++ b/apps/hq/src/reminder-templates.ts @@ -36,12 +36,16 @@ export function reminderEmail(kind: ReminderRuleKind, ctx: ReminderContext): Rem switch (kind) { case 'invoice_overdue': { const amt = ctx.amountPaise !== undefined ? formatINR(ctx.amountPaise) : '' + // D18: with a due date the mail says the honest thing ("was due on X, N days + // overdue"); legacy invoices without one keep the past-due-since-issue wording. + const lateness = ctx.dueDate !== undefined + ? ` was due on ${ctx.dueDate}` + (ctx.daysOverdue !== undefined ? ` and is now ${ctx.daysOverdue} day(s) overdue` : '') + : ` is outstanding` + (ctx.daysOverdue !== undefined ? ` and is now ${ctx.daysOverdue} day(s) past due` : '') return { subject: `Payment reminder — Invoice ${ctx.docNo} (${ctx.companyName})`, bodyText: `Dear ${ctx.clientName},\n\n` - + `This is a gentle reminder that Invoice ${ctx.docNo}${amt !== '' ? ` for ${amt}` : ''} is outstanding` - + (ctx.daysOverdue !== undefined ? ` and is now ${ctx.daysOverdue} day(s) past due` : '') + + `This is a gentle reminder that Invoice ${ctx.docNo}${amt !== '' ? ` for ${amt}` : ''}${lateness}` + `. We would be grateful if you could arrange payment at your earliest convenience.\n\n` + `If payment has already been made, kindly ignore this message.\n\n` + signOff(ctx), diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts index ab26454..b186e8b 100644 --- a/apps/hq/src/repos-documents.ts +++ b/apps/hq/src/repos-documents.ts @@ -4,7 +4,7 @@ import { writeAudit } from './audit' import type { DB } from './db' import { getClient } from './repos-clients' import { getModule, priceOn, type Kind } from './repos-modules' -import { dismissQuoteFollowups } from './repos-reminders' +import { dismissQuoteFollowups, getNumberSetting } from './repos-reminders' import { nextDocNo } from './series' /** @@ -36,7 +36,7 @@ export interface DocPayload { export interface Doc { id: string; docType: DocType; docNo: string | null; fy: string; clientId: string - docDate: string; status: DocStatus; refDocId: string | null + docDate: string; dueDate: string | null; status: DocStatus; refDocId: string | null taxablePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number roundOffPaise: number; payablePaise: number payload: DocPayload; source: string; createdBy: string; createdAt: string @@ -44,7 +44,7 @@ export interface Doc { interface DocRow { id: string; doc_type: string; doc_no: string | null; fy: string; client_id: string - doc_date: string; status: string; ref_doc_id: string | null + doc_date: string; due_date: string | null; status: string; ref_doc_id: string | null taxable_paise: number; cgst_paise: number; sgst_paise: number; igst_paise: number round_off_paise: number; payable_paise: number payload: string; source: string; created_by: string; created_at: string @@ -53,7 +53,8 @@ interface DocRow { function toDoc(r: DocRow): Doc { return { id: r.id, docType: r.doc_type as DocType, docNo: r.doc_no, fy: r.fy, - clientId: r.client_id, docDate: r.doc_date, status: r.status as DocStatus, + clientId: r.client_id, docDate: r.doc_date, dueDate: r.due_date, + status: r.status as DocStatus, refDocId: r.ref_doc_id, taxablePaise: r.taxable_paise, cgstPaise: r.cgst_paise, sgstPaise: r.sgst_paise, igstPaise: r.igst_paise, roundOffPaise: r.round_off_paise, payablePaise: r.payable_paise, @@ -117,10 +118,18 @@ export interface DraftInput { clientId: string lines: DraftLineInput[] terms?: string + /** INVOICE only (D18): payment due date; when absent, issue stamps doc_date + terms. */ + dueDate?: string } const todayIso = (): string => new Date().toISOString().slice(0, 10) +/** YYYY-MM-DD + N days (UTC). Local copy — importing scheduler's helper would cycle. */ +const addDays = (dateIso: string, days: number): string => { + const [y, m, d] = dateIso.split('-').map(Number) + return new Date(Date.UTC(y!, m! - 1, d! + days)).toISOString().slice(0, 10) +} + function supplyStateCode(db: DB): string { const row = db.prepare(`SELECT value FROM setting WHERE key='company.state_code'`) .get() as { value: string } | undefined @@ -175,17 +184,17 @@ function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?: /** Insert a draft row + created event + audit. Callers wrap in a transaction. */ function insertDocRow(db: DB, userId: string, a: { docType: DocType; clientId: string; refDocId: string | null; docDate: string - totals: BillTotals; payload: DocPayload + totals: BillTotals; payload: DocPayload; dueDate?: string | null }): Doc { const id = uuidv7() const t = a.totals db.prepare( - `INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, status, ref_doc_id, + `INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, due_date, status, ref_doc_id, taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise, payload, created_by, created_at) - VALUES (?, ?, NULL, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( - id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.refDocId, + id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.dueDate ?? null, a.refDocId, t.taxablePaise, t.cgstPaise, t.sgstPaise, t.igstPaise, t.roundOffPaise, t.payablePaise, JSON.stringify(a.payload), userId, new Date().toISOString(), ) @@ -209,6 +218,7 @@ function zeroTotals(): BillTotals { export interface PreparedDraft { docType: DocType; clientId: string; docDate: string totals: BillTotals; payload: DocPayload; warnings: string[] + dueDate?: string } /** @@ -223,6 +233,11 @@ export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boo if (!['QUOTATION', 'PROFORMA', 'INVOICE'].includes(input.docType)) { throw new Error(`Cannot draft doc type: ${input.docType}`) } + // Due dates are payment terms — they belong to invoices only (D18, spec A2). + if (input.dueDate !== undefined) { + if (input.docType !== 'INVOICE') throw new Error('Due date applies to INVOICE drafts only') + if (!/^\d{4}-\d{2}-\d{2}$/.test(input.dueDate)) throw new Error('dueDate must be YYYY-MM-DD') + } const client = getClient(db, input.clientId) if (client === null && !permissive) throw new Error('Client not found') const today = todayIso() @@ -254,6 +269,7 @@ export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boo return { docType: input.docType, clientId: input.clientId, docDate: today, totals: computed.totals, payload, warnings, + ...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}), } } @@ -262,6 +278,7 @@ export function createDraft(db: DB, userId: string, input: DraftInput): Doc { return db.transaction(() => insertDocRow(db, userId, { docType: prepared.docType, clientId: prepared.clientId, refDocId: null, docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload, + dueDate: prepared.dueDate ?? null, }))() } @@ -276,8 +293,16 @@ export function issueDocument(db: DB, userId: string, docId: string): Doc { if (before.status === 'cancelled') throw new Error('Cannot issue a cancelled document') const docNo = nextDocNo(db, before.docType, before.fy) db.prepare(`UPDATE document SET doc_no=? WHERE id=?`).run(docNo, docId) + // D18: an invoice that reaches issue without a due date gets doc_date + terms + // stamped NOW — the last moment it is still a draft (issued paper never changes). + let dueDate = before.dueDate + if (before.docType === 'INVOICE' && dueDate === null) { + const terms = getNumberSetting(db, 'billing.payment_terms_days', 15) + dueDate = addDays(before.docDate, terms) + db.prepare(`UPDATE document SET due_date=? WHERE id=?`).run(dueDate, docId) + } addEvent(db, docId, 'issued', { docNo }) - writeAudit(db, userId, 'issue', 'document', docId, { docNo: null }, { docNo }) + writeAudit(db, userId, 'issue', 'document', docId, { docNo: null }, { docNo, ...(dueDate !== before.dueDate ? { dueDate } : {}) }) return getDocument(db, docId)! })() } diff --git a/apps/hq/src/scheduler.ts b/apps/hq/src/scheduler.ts index 56d7f56..43f6c95 100644 --- a/apps/hq/src/scheduler.ts +++ b/apps/hq/src/scheduler.ts @@ -112,21 +112,23 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi const now = deps.now?.() ?? new Date().toISOString() const created: Record = {} - // --- invoice_overdue (dN day-past-issue milestones from the dated schedule; spec §8) --- - // A7: there is no due-date column — age anchors on doc_date (the issue date). + // --- invoice_overdue (dN day-past-due milestones from the dated schedule; spec §8, D18) --- + // Age anchors on the due date when the invoice carries one (stamped at issue from + // billing.payment_terms_days), else the issue date — the pre-D18 behavior. // Catch-up guard (F7): only the HIGHEST crossed milestone enqueues per scan, so a // scan gap (or the old monthly→dN cutover) nudges once, never the whole ladder; // under daily scans each milestone still fires exactly once as it is crossed. const invOffsets = resolveSchedule(db, 'invoice_overdue', today).dayOffsets // ascending const overdueCutoff = addDaysIso(today, -invOffsets[0]!) const overdue = db.prepare( - `SELECT id, client_id, doc_date FROM document + `SELECT id, client_id, doc_date, due_date FROM document WHERE doc_type='INVOICE' AND doc_no IS NOT NULL - AND status NOT IN ('paid','cancelled','lost') AND doc_date <= ?`, - ).all(overdueCutoff) as { id: string; client_id: string; doc_date: string }[] + AND status NOT IN ('paid','cancelled','lost') + AND COALESCE(due_date, doc_date) <= ?`, + ).all(overdueCutoff) as { id: string; client_id: string; doc_date: string; due_date: string | null }[] for (const inv of overdue) { if (outstandingPaise(db, inv.id) <= 0) continue - const age = daysBetweenIso(inv.doc_date, today) + const age = daysBetweenIso(inv.due_date ?? inv.doc_date, today) const crossed = invOffsets.filter((d) => age >= d) if (crossed.length === 0) continue const highest = crossed[crossed.length - 1]! diff --git a/apps/hq/src/seed.ts b/apps/hq/src/seed.ts index 576d66d..77c1462 100644 --- a/apps/hq/src/seed.ts +++ b/apps/hq/src/seed.ts @@ -70,6 +70,13 @@ export function seedIfEmpty(db: DB): void { for (const [key, value] of Object.entries(REMINDER_SETTINGS)) seededReminderSettings += insert.run(key, value).changes if (seededReminderSettings > 0) writeAudit(db, 'system', 'seed', 'setting', 'reminders.*', undefined, REMINDER_SETTINGS) + // Billing defaults (D18): payment terms in days — issueDocument stamps + // due_date = doc_date + terms on invoices that don't carry one. + const BILLING_SETTINGS: Record = { 'billing.payment_terms_days': '15' } + let seededBilling = 0 + for (const [key, value] of Object.entries(BILLING_SETTINGS)) seededBilling += insert.run(key, value).changes + if (seededBilling > 0) writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS) + // Dated reminder schedules (rule 3 / D-REMIND): one open-ended row per rule kind, // matching the code-constant fallback. A cadence change is a NEW dated row, not an edit. const scheduleKinds: ScheduleRuleKind[] = ['quote_followup', 'invoice_overdue'] diff --git a/apps/hq/src/send-reminder.ts b/apps/hq/src/send-reminder.ts index 48cf4be..a0ae429 100644 --- a/apps/hq/src/send-reminder.ts +++ b/apps/hq/src/send-reminder.ts @@ -44,15 +44,18 @@ export function reminderContext(db: DB, reminder: Reminder, companyName: string, ctx.amountPaise = doc.payablePaise ctx.period = reminder.duePeriod if (reminder.ruleKind === 'invoice_overdue') { - // A7: anchored on the invoice's issue date (no due-date column) — renders the - // template's "N day(s) past due". `today` injectable like the quote branch below. + // D18: anchored on the due date when the invoice carries one (stamped at issue), + // else the issue date (legacy invoices) — renders "N day(s) past due"/"overdue". + // `today` injectable like the quote branch below. // (Local day math: importing scheduler's helper here would create an import cycle.) const onDate = today ?? new Date().toISOString().slice(0, 10) const atUtc = (s: string): number => { const [y, m, d] = s.split('-').map(Number) return Date.UTC(y!, m! - 1, d!) } - ctx.daysOverdue = Math.max(0, Math.round((atUtc(onDate) - atUtc(doc.docDate)) / 86_400_000)) + const anchor = doc.dueDate ?? doc.docDate + if (doc.dueDate !== null) ctx.dueDate = doc.dueDate + ctx.daysOverdue = Math.max(0, Math.round((atUtc(onDate) - atUtc(anchor)) / 86_400_000)) } if (reminder.ruleKind === 'quote_followup') { // `today` is injectable (deps.now via sendReminder) so the send path resolves the diff --git a/apps/hq/src/templates.ts b/apps/hq/src/templates.ts index 5e7d3aa..f2d48c9 100644 --- a/apps/hq/src/templates.ts +++ b/apps/hq/src/templates.ts @@ -199,6 +199,7 @@ export function documentHtml(doc: Doc, client: Client, company: Record
Document No${doc.docNo !== null ? esc(doc.docNo) : 'DRAFT'}
Date${displayDate(doc.docDate)}
+ ${doc.docType === 'INVOICE' && typeof doc.dueDate === 'string' ? `
Due Date${displayDate(doc.dueDate)}
` : ''}
FY${esc(doc.fy)}
@@ -353,7 +354,7 @@ export function documentHtmlSample(company: Record, opts: { cont } const doc: Doc = { id: 'sample', docType: 'INVOICE', docNo: null, fy: '2026-27', clientId: 'sample', - docDate: new Date().toISOString().slice(0, 10), status: 'draft', refDocId: null, + docDate: new Date().toISOString().slice(0, 10), dueDate: null, status: 'draft', refDocId: null, taxablePaise: 10_000_00, cgstPaise: 900_00, sgstPaise: 900_00, igstPaise: 0, roundOffPaise: 0, payablePaise: 11_800_00, payload: { diff --git a/apps/hq/test/dashboard.test.ts b/apps/hq/test/dashboard.test.ts index 66870a7..9e1809a 100644 --- a/apps/hq/test/dashboard.test.ts +++ b/apps/hq/test/dashboard.test.ts @@ -23,7 +23,7 @@ function seeded() { const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) - db.prepare(`UPDATE document SET doc_date='2026-06-01' WHERE id=?`).run(inv.id) + db.prepare(`UPDATE document SET doc_date='2026-06-01', due_date=NULL WHERE id=?`).run(inv.id) const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' }) createInteraction(db, 'u1', { clientId: c.id, typeCode: 'call', onDate: '2026-07-01', followUpOn: '2026-07-09' }) diff --git a/apps/hq/test/due-dates.test.ts b/apps/hq/test/due-dates.test.ts new file mode 100644 index 0000000..c105506 --- /dev/null +++ b/apps/hq/test/due-dates.test.ts @@ -0,0 +1,122 @@ +// apps/hq/test/due-dates.test.ts — go-live cluster WS-A: invoice due dates (D18) +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, setPrice } from '../src/repos-modules' +import { convertDocument, createDraft, getDocument, issueDocument } from '../src/repos-documents' +import { getReminder, listReminders, setSetting, upsertReminder } from '../src/repos-reminders' +import { reminderContext } from '../src/send-reminder' +import { reminderEmail } from '../src/reminder-templates' +import { runDailyScan, type ScanDeps } from '../src/scheduler' + +const deps: ScanDeps = { + gmail: { f: (async () => new Response('{}')) as typeof fetch, clientId: '', clientSecret: '', keyHex: '' }, + renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }), + now: () => '2026-07-17T00:00:00Z', +} + +function world() { + const db = openDb(':memory:'); seedIfEmpty(db) // seeds billing.payment_terms_days=15 + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) + const invoiceDraft = () => createDraft(db, 'u1', { + docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], + }) + return { db, c, m, invoiceDraft } +} + +describe('due dates (D18)', () => { + it('issue stamps doc_date + terms on an invoice without one (manual issue path)', () => { + const { db, invoiceDraft } = world() + const draft = invoiceDraft() + expect(draft.dueDate).toBeNull() + db.prepare(`UPDATE document SET doc_date='2026-07-01' WHERE id=?`).run(draft.id) + const inv = issueDocument(db, 'u1', draft.id) + expect(inv.dueDate).toBe('2026-07-16') // 2026-07-01 + 15 + }) + + it('a caller-provided due date survives issue untouched', () => { + const { db, c, m } = world() + const draft = createDraft(db, 'u1', { + docType: 'INVOICE', clientId: c.id, dueDate: '2026-09-30', + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], + }) + expect(draft.dueDate).toBe('2026-09-30') + expect(issueDocument(db, 'u1', draft.id).dueDate).toBe('2026-09-30') + }) + + it('the terms setting is honoured when changed (dated behavior lives in the stamp)', () => { + const { db, invoiceDraft } = world() + setSetting(db, 'u1', 'billing.payment_terms_days', '30') + const draft = invoiceDraft() + db.prepare(`UPDATE document SET doc_date='2026-07-01' WHERE id=?`).run(draft.id) + expect(issueDocument(db, 'u1', draft.id).dueDate).toBe('2026-07-31') + }) + + it('quotes and proformas never get a due date — at draft or at issue', () => { + const { db, c, m } = world() + expect(() => createDraft(db, 'u1', { + docType: 'QUOTATION', clientId: c.id, dueDate: '2026-08-01', + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], + })).toThrow(/INVOICE/) + const pi = createDraft(db, 'u1', { + docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], + }) + expect(issueDocument(db, 'u1', pi.id).dueDate).toBeNull() + }) + + it('proforma → invoice conversion gets stamped at issue (convert-and-send path)', () => { + const { db, c, m } = world() + const pi = issueDocument(db, 'u1', createDraft(db, 'u1', { + docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], + }).id) + const invDraft = convertDocument(db, 'u1', pi.id, 'INVOICE') + const inv = issueDocument(db, 'u1', invDraft.id) + expect(inv.dueDate).not.toBeNull() // stamped from terms on the invoice's own doc_date + }) + + it('bad dueDate format is rejected at draft', () => { + const { db, c, m } = world() + expect(() => createDraft(db, 'u1', { + docType: 'INVOICE', clientId: c.id, dueDate: '17-07-2026', + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], + })).toThrow(/YYYY-MM-DD/) + }) + + it('overdue scan anchors on the due date: not yet due ⇒ silent, past due ⇒ milestone from due_date', async () => { + const { db, invoiceDraft } = world() + // Old issue date but due date in the future → NOT overdue. + const a = issueDocument(db, 'u1', invoiceDraft().id) + db.prepare(`UPDATE document SET doc_date='2026-05-01', due_date='2026-08-01' WHERE id=?`).run(a.id) + // Due 16 days ago → d15 milestone (7/15/30 ladder), counted from due_date not doc_date. + const b = issueDocument(db, 'u1', invoiceDraft().id) + db.prepare(`UPDATE document SET doc_date='2026-05-01', due_date='2026-07-01' WHERE id=?`).run(b.id) + await runDailyScan(db, deps, '2026-07-17') + const rows = listReminders(db, {}).filter((r) => r.ruleKind === 'invoice_overdue') + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ subjectId: b.id, duePeriod: 'd15' }) + }) + + it('the reminder email says "was due on X and is now N day(s) overdue"', () => { + const { db, c, invoiceDraft } = world() + const inv = issueDocument(db, 'u1', invoiceDraft().id) + db.prepare(`UPDATE document SET due_date='2026-07-02' WHERE id=?`).run(inv.id) + const { id } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd15', clientId: c.id, docId: inv.id, now: '2026-07-17T00:00:00Z' }) + const { ctx } = reminderContext(db, getReminder(db, id)!, 'Tecnostac', '2026-07-17') + expect(ctx.dueDate).toBe('2026-07-02') + expect(ctx.daysOverdue).toBe(15) + expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/was due on 2026-07-02 and is now 15 day\(s\) overdue/) + }) + + it('legacy invoices without a due date keep the past-due-since-issue wording and doc_date anchor', () => { + const { db, c, invoiceDraft } = world() + const inv = issueDocument(db, 'u1', invoiceDraft().id) + db.prepare(`UPDATE document SET doc_date='2026-07-07', due_date=NULL WHERE id=?`).run(inv.id) + const { id } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd7', clientId: c.id, docId: inv.id, now: '2026-07-17T00:00:00Z' }) + const { ctx } = reminderContext(db, getReminder(db, id)!, 'Tecnostac', '2026-07-17') + expect(ctx.dueDate).toBeUndefined() + expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/is outstanding and is now 10 day\(s\) past due/) + }) +}) diff --git a/apps/hq/test/scheduler-scan.test.ts b/apps/hq/test/scheduler-scan.test.ts index 1ab32b1..eb8da6f 100644 --- a/apps/hq/test/scheduler-scan.test.ts +++ b/apps/hq/test/scheduler-scan.test.ts @@ -31,7 +31,7 @@ describe('runDailyScan — detection rules', () => { const { db, c, m } = world() // Overdue invoice: issued 2026-06-01, unpaid, > 7 days before 2026-07-10. const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) - db.prepare(`UPDATE document SET doc_date='2026-06-01' WHERE id=?`).run(inv.id) + db.prepare(`UPDATE document SET doc_date='2026-06-01', due_date=NULL WHERE id=?`).run(inv.id) // Renewal within 15 days. const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' }) @@ -55,7 +55,7 @@ describe('runDailyScan — detection rules', () => { it('does not raise a reminder for an invoice that is not yet overdue', async () => { const { db, c, m } = world() const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) - db.prepare(`UPDATE document SET doc_date='2026-07-08' WHERE id=?`).run(inv.id) // only 2 days old + db.prepare(`UPDATE document SET doc_date='2026-07-08', due_date=NULL WHERE id=?`).run(inv.id) // only 2 days old const res = await runDailyScan(db, deps, '2026-07-10') expect(res.created['invoice_overdue'] ?? 0).toBe(0) }) @@ -67,7 +67,7 @@ describe('invoice_overdue — dN milestone escalation (Phase 9, spec §8)', () = const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], }).id) - db.prepare(`UPDATE document SET doc_date=? WHERE id=?`).run(docDate, inv.id) + db.prepare(`UPDATE document SET doc_date=?, due_date=NULL WHERE id=?`).run(docDate, inv.id) return { db, c, inv } } const invoiceRows = (db: ReturnType) =>