diff --git a/apps/hq/src/scheduler.ts b/apps/hq/src/scheduler.ts index 2340fe3..56d7f56 100644 --- a/apps/hq/src/scheduler.ts +++ b/apps/hq/src/scheduler.ts @@ -31,6 +31,15 @@ export function addDaysIso(dateIso: string, days: number): string { return new Date(Date.UTC(y!, m! - 1, d! + days)).toISOString().slice(0, 10) } +/** Whole days from `fromIso` to `toIso` (UTC midnights; negative when from > to). */ +export function daysBetweenIso(fromIso: string, toIso: string): number { + const at = (s: string): number => { + const [y, m, d] = s.split('-').map(Number) + return Date.UTC(y!, m! - 1, d!) + } + return Math.round((at(toIso) - at(fromIso)) / 86_400_000) +} + /** Advance a YYYY-MM-DD anchor by one cadence (UTC; month/day overflow normalizes). */ export function addCadence(dateIso: string, cadence: 'monthly' | 'yearly'): string { const [y, m, d] = dateIso.split('-').map(Number) @@ -103,18 +112,25 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi const now = deps.now?.() ?? new Date().toISOString() const created: Record = {} - // --- invoice_overdue --- - const overdueDays = getNumberSetting(db, 'reminders.overdue_days', 7) - const overdueCutoff = addDaysIso(today, -overdueDays) - const period = today.slice(0, 7) + // --- 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). + // 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 FROM document + `SELECT id, client_id, doc_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 }[] + ).all(overdueCutoff) as { id: string; client_id: string; doc_date: string }[] for (const inv of overdue) { if (outstandingPaise(db, inv.id) <= 0) continue - if (upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: period, clientId: inv.client_id, docId: inv.id, now }).created) { + const age = daysBetweenIso(inv.doc_date, today) + const crossed = invOffsets.filter((d) => age >= d) + if (crossed.length === 0) continue + const highest = crossed[crossed.length - 1]! + if (upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: `d${highest}`, clientId: inv.client_id, docId: inv.id, now }).created) { bump(created, 'invoice_overdue') } } diff --git a/apps/hq/src/send-reminder.ts b/apps/hq/src/send-reminder.ts index 4e315df..48cf4be 100644 --- a/apps/hq/src/send-reminder.ts +++ b/apps/hq/src/send-reminder.ts @@ -43,6 +43,17 @@ export function reminderContext(db: DB, reminder: Reminder, companyName: string, ctx.docNo = doc.docNo ?? undefined 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. + // (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)) + } if (reminder.ruleKind === 'quote_followup') { // `today` is injectable (deps.now via sendReminder) so the send path resolves the // SAME dated row as the scan that enqueued it — wall clock only as a fallback. diff --git a/apps/hq/test/scheduler-scan.test.ts b/apps/hq/test/scheduler-scan.test.ts index cd57b9a..1ab32b1 100644 --- a/apps/hq/test/scheduler-scan.test.ts +++ b/apps/hq/test/scheduler-scan.test.ts @@ -7,7 +7,9 @@ import { assignModule, createModule, setPrice, updateClientModule } from '../src import { createDraft, issueDocument } from '../src/repos-documents' import { createAmc } from '../src/repos-amc' import { createInteraction } from '../src/repos-interactions' -import { listReminders } from '../src/repos-reminders' +import { getReminder, listReminders, 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 = { @@ -58,3 +60,59 @@ describe('runDailyScan — detection rules', () => { expect(res.created['invoice_overdue'] ?? 0).toBe(0) }) }) + +describe('invoice_overdue — dN milestone escalation (Phase 9, spec §8)', () => { + function agedInvoice(docDate: string) { + 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=? WHERE id=?`).run(docDate, inv.id) + return { db, c, inv } + } + const invoiceRows = (db: ReturnType) => + listReminders(db, {}).filter((r) => r.ruleKind === 'invoice_overdue') + + it('fires d7, then d15, then d30 — each exactly once as it is crossed', async () => { + const { db } = agedInvoice('2026-06-01') + await runDailyScan(db, deps, '2026-06-08') // age 7 → d7 + await runDailyScan(db, deps, '2026-06-10') // age 9 → d7 already consumed + await runDailyScan(db, deps, '2026-06-16') // age 15 → d15 + await runDailyScan(db, deps, '2026-07-01') // age 30 → d30 + await runDailyScan(db, deps, '2026-07-05') // age 34 → d30 already consumed + expect(invoiceRows(db).map((r) => r.duePeriod).sort()).toEqual(['d15', 'd30', 'd7']) + }) + + it('catch-up after a scan gap fires ONLY the highest crossed milestone (F7)', async () => { + const { db } = agedInvoice('2026-06-01') + await runDailyScan(db, deps, '2026-07-10') // first scan ever, age 39 + expect(invoiceRows(db).map((r) => r.duePeriod)).toEqual(['d30']) + }) + + it('monthly→dN cutover: an already-reminded aged invoice gains one dN row, never the ladder', async () => { + const { db, c, inv } = agedInvoice('2026-06-01') + // Legacy row from the old monthly-bucket scheme. + upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-06', clientId: c.id, docId: inv.id, now: '2026-06-15T00:00:00Z' }) + await runDailyScan(db, deps, '2026-07-10') // age 39 + const periods = invoiceRows(db).map((r) => r.duePeriod).sort() + expect(periods).toEqual(['2026-06', 'd30']) // one legacy + one highest milestone + }) + + it('a dated reminder_schedule row changes the cadence with no code change', async () => { + const { db } = agedInvoice('2026-07-07') // age 3 on the 10th — below the 7/15/30 default + db.prepare( + `INSERT INTO reminder_schedule (id, rule_kind, effective_from, day_offsets) VALUES (?, ?, ?, ?)`, + ).run('sched-inv-fast', 'invoice_overdue', '2026-07-01', '3') + const res = await runDailyScan(db, deps, '2026-07-10') + expect(res.created['invoice_overdue']).toBe(1) + expect(invoiceRows(db).map((r) => r.duePeriod)).toEqual(['d3']) + }) + + it('the email renders "N day(s) past due" from ctx.daysOverdue', async () => { + const { db, c, inv } = agedInvoice('2026-06-25') // 15 days before the 10th + const { id } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd15', clientId: c.id, docId: inv.id, now: '2026-07-10T00:00:00Z' }) + const { ctx } = reminderContext(db, getReminder(db, id)!, 'Tecnostac', '2026-07-10') + expect(ctx.daysOverdue).toBe(15) + expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/15 day\(s\) past due/) + }) +})