@ -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 : [ { module Id : 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/ )
} )
} )