You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
120 lines
6.8 KiB
TypeScript
120 lines
6.8 KiB
TypeScript
// apps/hq/test/scheduler-scan.test.ts
|
|
import { describe, it, expect } from 'vitest'
|
|
import { openDb } from '../src/db'
|
|
import { seedIfEmpty } from '../src/seed'
|
|
import { createClient } from '../src/repos-clients'
|
|
import { assignModule, createModule, setPrice, updateClientModule } from '../src/repos-modules'
|
|
import { createDraft, issueDocument } from '../src/repos-documents'
|
|
import { createAmc } from '../src/repos-amc'
|
|
import { createInteraction } from '../src/repos-interactions'
|
|
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 = {
|
|
gmail: { f: (async () => new Response('{}')) as typeof fetch, clientId: '', clientSecret: '', keyHex: '' },
|
|
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }),
|
|
now: () => '2026-07-10T00:00:00Z',
|
|
}
|
|
|
|
async function world() {
|
|
const db = openDb(':memory:'); await seedIfEmpty(db)
|
|
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
|
|
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
|
|
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
|
|
return { db, c, m }
|
|
}
|
|
|
|
describe('runDailyScan — detection rules', () => {
|
|
it('raises overdue, renewal, amc and follow-up reminders, and is idempotent', async () => {
|
|
const { db, c, m } = await world()
|
|
// Overdue invoice: issued 2026-06-01, unpaid, > 7 days before 2026-07-10.
|
|
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
|
|
await db.run(`UPDATE document SET doc_date='2026-06-01', due_date=NULL WHERE id=?`, inv.id)
|
|
// Renewal within 15 days.
|
|
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
|
await updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' })
|
|
// AMC expiring within 30 days.
|
|
await createAmc(db, 'u1', { clientId: c.id, coverage: 'Support', periodFrom: '2025-08-01', periodTo: '2026-08-01', amountPaise: 20_000_00 })
|
|
// Follow-up due.
|
|
await createInteraction(db, 'u1', { clientId: c.id, typeCode: 'call', onDate: '2026-07-01', followUpOn: '2026-07-09' })
|
|
|
|
const res = await runDailyScan(db, deps, '2026-07-10')
|
|
expect(res.created['invoice_overdue']).toBe(1)
|
|
expect(res.created['renewal_due']).toBe(1)
|
|
expect(res.created['amc_expiring']).toBe(1)
|
|
expect(res.created['follow_up']).toBe(1)
|
|
expect(await listReminders(db, { status: 'queued' })).toHaveLength(4)
|
|
|
|
// Re-run same day → the idempotency key blocks every duplicate.
|
|
const again = await runDailyScan(db, deps, '2026-07-10')
|
|
expect(Object.values(again.created).reduce((a, b) => a + b, 0)).toBe(0)
|
|
expect(await listReminders(db, {})).toHaveLength(4)
|
|
})
|
|
it('does not raise a reminder for an invoice that is not yet overdue', async () => {
|
|
const { db, c, m } = await world()
|
|
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
|
|
await db.run(`UPDATE document SET doc_date='2026-07-08', due_date=NULL WHERE id=?`, inv.id) // only 2 days old
|
|
const res = await runDailyScan(db, deps, '2026-07-10')
|
|
expect(res.created['invoice_overdue'] ?? 0).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('invoice_overdue — dN milestone escalation (Phase 9, spec §8)', () => {
|
|
async function agedInvoice(docDate: string) {
|
|
const { db, c, m } = await world()
|
|
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
|
|
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
|
|
})).id)
|
|
await db.run(`UPDATE document SET doc_date=?, due_date=NULL WHERE id=?`, docDate, inv.id)
|
|
return { db, c, inv }
|
|
}
|
|
const invoiceRows = async (db: ReturnType<typeof openDb>) =>
|
|
(await 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 } = await 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((await 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 } = await agedInvoice('2026-06-01')
|
|
await runDailyScan(db, deps, '2026-07-10') // first scan ever, age 39
|
|
expect((await 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 } = await agedInvoice('2026-06-01')
|
|
// Legacy row from the old monthly-bucket scheme.
|
|
await 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 = (await 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 } = await agedInvoice('2026-07-07') // age 3 on the 10th — below the 7/15/30 default
|
|
await db.run(
|
|
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, day_offsets) VALUES (?, ?, ?, ?)`,
|
|
'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((await 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 } = await agedInvoice('2026-06-25') // 15 days before the 10th
|
|
const { id } = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd15', clientId: c.id, docId: inv.id, now: '2026-07-10T00:00:00Z' })
|
|
const { ctx } = await reminderContext(db, (await getReminder(db, id))!, 'Tecnostac', '2026-07-10')
|
|
expect(ctx.daysOverdue).toBe(15)
|
|
expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/15 day\(s\) past due/)
|
|
})
|
|
})
|