feat(reminders): invoice dN milestone escalation from the dated schedule (Phase 9, spec §8)

- invoice_overdue scan: monthly bucket → d7/d15/d30 milestones resolved via
  resolveSchedule (dated config, rule 3); each fires once via the idempotency key
- F7 catch-up guard: scan gaps and the monthly→dN cutover enqueue only the
  highest crossed milestone, never the whole ladder
- ctx.daysOverdue populated in reminderContext (A7: anchored on doc_date) so the
  'N day(s) past due' template text renders; injectable today for determinism
- daysBetweenIso helper; 5 tests; suite green

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

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

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

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

Loading…
Cancel
Save