feat(documents): invoice due dates — terms default + override, honest overdue chase (Phase 1, D18)

- document.due_date additive column; billing.payment_terms_days seeded (15)
- issueDocument stamps doc_date+terms on INVOICE when absent — covers manual
  issue, convert-and-send and recurring generation in one place
- drafts accept dueDate (INVOICE only, validated); composer date field
  auto-filled from GET /settings/billing; invoice PDF prints Due Date
- overdue scan + reminder anchor on COALESCE(due_date, doc_date); email says
  'was due on X and is now N day(s) overdue' when a due date exists
- legacy fixtures pinned to due_date=NULL (sanctioned: they test the doc_date
  anchor); 9 new tests; suite 309/309 across 60 files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent aa05060181
commit bc140fc54c

@ -196,6 +196,8 @@ export const patchClientModule = (id: string, body: Record<string, unknown>): 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<string, unknown>): Promise<Doc> =>
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[] }> =>

@ -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<string | undefined>()
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<number | undefined>()
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() {
))}
</Toolbar>
</Field>
{docType === 'INVOICE' && (
<Field label={`Due date${termsDays !== undefined ? ` (auto: +${termsDays}d)` : ''}`}>
<input
className="wf" type="date" value={dueDate}
onChange={(e) => { setDueDate(e.target.value); commit() }}
/>
</Field>
)}
<h3>Lines</h3>
{lines.map((l, i) => {

@ -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: '',

@ -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, -- QTPIINV 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`)

@ -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),

@ -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)!
})()
}

@ -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<string, number> = {}
// --- 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]!

@ -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<string, string> = { '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']

@ -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

@ -199,6 +199,7 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
<div class="docmeta">
<div class="row"><span class="k">Document No</span><span class="v">${doc.docNo !== null ? esc(doc.docNo) : '<span class="chip-draft">DRAFT</span>'}</span></div>
<div class="row"><span class="k">Date</span><span class="v">${displayDate(doc.docDate)}</span></div>
${doc.docType === 'INVOICE' && typeof doc.dueDate === 'string' ? `<div class="row"><span class="k">Due Date</span><span class="v">${displayDate(doc.dueDate)}</span></div>` : ''}
<div class="row"><span class="k">FY</span><span class="v">${esc(doc.fy)}</span></div>
</div>
</section>
@ -353,7 +354,7 @@ export function documentHtmlSample(company: Record<string, string>, 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: {

@ -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' })

@ -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/)
})
})

@ -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<typeof openDb>) =>

Loading…
Cancel
Save