From 1b14e49d86b3b53d5777779e849b7fb459b79d25 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 15:30:32 +0530 Subject: [PATCH] feat(hq): recurring invoice generation with at-most-once auto-send Co-Authored-By: Claude Fable 5 --- apps/hq/src/scheduler.ts | 89 ++++++++++++++++++++++-- apps/hq/test/scheduler-recurring.test.ts | 80 +++++++++++++++++++++ 2 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 apps/hq/test/scheduler-recurring.test.ts diff --git a/apps/hq/src/scheduler.ts b/apps/hq/src/scheduler.ts index 853ec81..5cac507 100644 --- a/apps/hq/src/scheduler.ts +++ b/apps/hq/src/scheduler.ts @@ -1,14 +1,19 @@ +import { writeAudit } from './audit' import type { DB } from './db' +import { createDraft, issueDocument } from './repos-documents' +import { getClientModule } from './repos-modules' import { outstandingPaise } from './repos-payments' +import { CADENCE_KIND, getRecurringPlan } from './repos-recurring' import { getNumberSetting, upsertReminder } from './repos-reminders' -import type { SendReminderDeps } from './send-reminder' +import { sendReminder, type SendReminderDeps } from './send-reminder' /** * Daily scan — pure w.r.t. the clock: `today` (YYYY-MM-DD) is injected, so tests * are deterministic. Every reminder is created through upsertReminder (INSERT OR * IGNORE on the idempotency key), which makes re-runs and catch-up after downtime - * safe with no extra bookkeeping. Recurring generation + auto-send is added in the - * next task; this pass covers the four detection rules (all land in the manual queue). + * safe with no extra bookkeeping. The four detection rules all land in the manual + * queue; recurring generation is transactional (claim + generate + advance next_run + * all-or-nothing) with the auto-send strictly after commit — at most once per period. */ export type ScanDeps = SendReminderDeps @@ -26,10 +31,74 @@ export function addDaysIso(dateIso: string, days: number): string { return new Date(Date.UTC(y!, m! - 1, d! + days)).toISOString().slice(0, 10) } +/** 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) + const ms = cadence === 'yearly' ? Date.UTC(y! + 1, m! - 1, d!) : Date.UTC(y!, m!, d!) + return new Date(ms).toISOString().slice(0, 10) +} + function bump(created: Record, kind: string): void { created[kind] = (created[kind] ?? 0) + 1 } +type ClaimResult = 'done' | { created: boolean; auto: boolean; reminderId: string } + +/** One period for one plan, atomically. Callers wrap in db.transaction. */ +function claimAndGenerate(db: DB, planId: string, today: string, now: string): ClaimResult { + const plan = getRecurringPlan(db, planId) + if (plan === null || !plan.active || plan.nextRun > today) return 'done' + const duePeriod = plan.nextRun + const up = upsertReminder(db, { + ruleKind: 'recurring_generated', subjectId: plan.id, duePeriod, + clientId: plan.clientId, policyApplied: plan.policy, now, + }) + const nextRun = addCadence(plan.nextRun, plan.cadence) + if (!up.created) { + // Defensive: the period's reminder already exists but next_run wasn't advanced. + // Only reachable if generation were ever non-transactional; advance, never regenerate. + db.prepare(`UPDATE recurring_plan SET next_run=? WHERE id=?`).run(nextRun, planId) + return { created: false, auto: false, reminderId: up.id } + } + if (plan.clientModuleId === null) throw new Error(`recurring_plan ${plan.id}: client_module required to generate`) + const cm = getClientModule(db, plan.clientModuleId) + if (cm === null) throw new Error(`recurring_plan ${plan.id}: client_module not found`) + const kind = CADENCE_KIND[plan.cadence] + const draft = createDraft(db, 'system', { + docType: 'INVOICE', clientId: plan.clientId, + lines: [{ + moduleId: cm.moduleId, qty: 1, kind, edition: cm.edition, + ...(plan.amountPaise !== null ? { unitPricePaise: plan.amountPaise } : {}), + }], + }) + const inv = issueDocument(db, 'system', draft.id) + db.prepare(`UPDATE reminder SET doc_id=? WHERE id=?`).run(inv.id, up.id) + db.prepare(`UPDATE recurring_plan SET next_run=? WHERE id=?`).run(nextRun, planId) + writeAudit(db, 'system', 'generate', 'recurring_plan', plan.id, { nextRun: plan.nextRun }, { nextRun, invoiceId: inv.id }) + return { created: true, auto: plan.policy === 'auto', reminderId: up.id } +} + +/** Generate every due period for every active plan; collect auto reminders to send. */ +function generateRecurring(db: DB, today: string, now: string, created: Record): string[] { + const plans = db.prepare( + `SELECT id FROM recurring_plan WHERE active=1 AND next_run <= ?`, + ).all(today) as { id: string }[] + const autoQueue: string[] = [] + for (const { id } of plans) { + let guard = 0 + for (;;) { + if (guard++ > 240) break // safety cap: ~20 years of monthly against a corrupt next_run + const claim = db.transaction(() => claimAndGenerate(db, id, today, now))() + if (claim === 'done') break + if (claim.created) { + bump(created, 'recurring_generated') + if (claim.auto) autoQueue.push(claim.reminderId) + } + } + } + return autoQueue +} + export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promise { const now = deps.now?.() ?? new Date().toISOString() const created: Record = {} @@ -86,5 +155,17 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi } } - return { today, created, autoSent: 0, autoFailed: 0 } + // --- recurring_generated (transactional generation, then async auto-send) --- + const autoQueue = generateRecurring(db, today, now, created) + let autoSent = 0 + let autoFailed = 0 + for (const reminderId of autoQueue) { + // Send outside the generation transaction: a failed send never rolls back the + // (already-issued) invoice or the advanced schedule — it just parks the reminder. + const out = await sendReminder(db, deps, reminderId, 'system') + if (out.ok) autoSent += 1 + else autoFailed += 1 + } + + return { today, created, autoSent, autoFailed } } diff --git a/apps/hq/test/scheduler-recurring.test.ts b/apps/hq/test/scheduler-recurring.test.ts new file mode 100644 index 0000000..be1d6f7 --- /dev/null +++ b/apps/hq/test/scheduler-recurring.test.ts @@ -0,0 +1,80 @@ +// 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': 'Tecnostac' }), now: () => '2026-07-10T00:00:00Z' } +} + +function world() { + const db = openDb(':memory:'); seedIfEmpty(db) + saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY)) + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] }) + const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', allowedKinds: ['monthly'] }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-01-01' }) + const cm = 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 } = world() + const plan = 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 = listDocuments(db, { clientId: c.id, type: 'INVOICE' }) + expect(invoices).toHaveLength(1) + expect(invoices[0]!.docNo).toMatch(/^INV\//) + expect(getRecurringPlan(db, plan.id)!.nextRun).toBe('2026-08-10') // advanced exactly one month + const rem = 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 } = world() + const plan = 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(listDocuments(db, { clientId: c.id, type: 'INVOICE' })).toHaveLength(1) // invoice still issued + expect(getRecurringPlan(db, plan.id)!.nextRun).toBe('2026-08-10') // schedule advanced on generation + const rem = 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(listDocuments(db, { clientId: c.id, type: 'INVOICE' })).toHaveLength(1) + }) + it('manual plan: generates the invoice but queues (no send)', async () => { + const { db, c, cm } = world() + 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(getReminder(db, 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 } = world() + const plan = 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(listDocuments(db, { clientId: c.id, type: 'INVOICE' })).toHaveLength(3) + expect(getRecurringPlan(db, plan.id)!.nextRun).toBe('2026-08-10') + const periods = listReminders(db, { ruleKind: 'recurring_generated' }).map((r) => r.duePeriod).sort() + expect(periods).toEqual(['2026-05-10', '2026-06-10', '2026-07-10']) + }) +})