// apps/hq/test/scheduler-recurring.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 } from '../src/repos-modules' import { createRecurringPlan, getRecurringPlan } from '../src/repos-recurring' import { saveAccount } from '../src/repos-email' import { encrypt } from '../src/crypto' import { listDocuments } from '../src/repos-documents' import { listReminders, getReminder } from '../src/repos-reminders' import { runDailyScan, type ScanDeps } from '../src/scheduler' const KEY = '11'.repeat(32) const okFetch = (async (url: string) => new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'g1' }), { status: 200 })) as typeof fetch const deadFetch = (async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })) as typeof fetch function deps(f: typeof fetch): ScanDeps { return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'SiMS' }), now: () => '2026-07-10T00:00:00Z' } } async function world() { const db = openDb(':memory:'); await seedIfEmpty(db) await saveAccount(db, 'us@sims.com', encrypt('rt', KEY)) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] }) const m = await createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', allowedKinds: ['monthly'] }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-01-01' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' }) return { db, c, m, cm } } describe('runDailyScan — recurring generation', () => { it('auto plan: generates+issues an invoice, advances next_run one cadence, sends, marks sent', async () => { const { db, c, cm } = await world() const plan = await createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'auto' }) const res = await runDailyScan(db, deps(okFetch), '2026-07-10') expect(res.created['recurring_generated']).toBe(1) expect(res.autoSent).toBe(1) const invoices = (await listDocuments(db, { clientId: c.id, type: 'INVOICE' })).documents expect(invoices).toHaveLength(1) expect(invoices[0]!.docNo).toMatch(/^INV\//) expect((await getRecurringPlan(db, plan.id))!.nextRun).toBe('2026-08-10') // advanced exactly one month const rem = (await listReminders(db, { ruleKind: 'recurring_generated' }))[0]! expect(rem.status).toBe('sent') expect(rem.docId).toBe(invoices[0]!.id) }) it('failed send: invoice exists and next_run advanced, reminder is failed (queued for manual), no regeneration on re-run', async () => { const { db, c, cm } = await world() const plan = await createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'auto' }) const res = await runDailyScan(db, deps(deadFetch), '2026-07-10') expect(res.created['recurring_generated']).toBe(1) expect(res.autoFailed).toBe(1) expect((await listDocuments(db, { clientId: c.id, type: 'INVOICE' })).documents).toHaveLength(1) // invoice still issued expect((await getRecurringPlan(db, plan.id))!.nextRun).toBe('2026-08-10') // schedule advanced on generation const rem = (await listReminders(db, { ruleKind: 'recurring_generated' }))[0]! expect(rem.status).toBe('failed') // waits in the manual queue // Re-run: next_run is now in the future → no second invoice, no duplicate reminder. const again = await runDailyScan(db, deps(deadFetch), '2026-07-10') expect(again.created['recurring_generated'] ?? 0).toBe(0) expect((await listDocuments(db, { clientId: c.id, type: 'INVOICE' })).documents).toHaveLength(1) }) it('manual plan: generates the invoice but queues (no send)', async () => { const { db, c, cm } = await world() await createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'manual' }) const res = await runDailyScan(db, deps(okFetch), '2026-07-10') expect(res.autoSent).toBe(0) expect((await getReminder(db, (await listReminders(db, { ruleKind: 'recurring_generated' }))[0]!.id))!.status).toBe('queued') }) it('catch-up: a plan three months in arrears bills every missed month exactly once', async () => { const { db, c, cm } = await world() const plan = await createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-05-10', policy: 'manual' }) const res = await runDailyScan(db, deps(okFetch), '2026-07-10') expect(res.created['recurring_generated']).toBe(3) // May, Jun, Jul expect((await listDocuments(db, { clientId: c.id, type: 'INVOICE' })).documents).toHaveLength(3) expect((await getRecurringPlan(db, plan.id))!.nextRun).toBe('2026-08-10') const periods = (await listReminders(db, { ruleKind: 'recurring_generated' })).map((r) => r.duePeriod).sort() expect(periods).toEqual(['2026-05-10', '2026-06-10', '2026-07-10']) }) })