feat(hq): recurring invoice generation with at-most-once auto-send

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 2d3b1a52a9
commit 1b14e49d86

@ -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<string, number>, 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, number>): 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<ScanResult> {
const now = deps.now?.() ?? new Date().toISOString()
const created: Record<string, number> = {}
@ -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 }
}

@ -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'])
})
})
Loading…
Cancel
Save