From 2d3b1a52a91ae1f61d3a408e6db16f22c8a3d897 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 15:26:17 +0530 Subject: [PATCH] feat(hq): daily scan detection rules with idempotent reminders Co-Authored-By: Claude Fable 5 --- apps/hq/src/repos-payments.ts | 6 ++ apps/hq/src/scheduler.ts | 90 +++++++++++++++++++++++++++++ apps/hq/test/scheduler-scan.test.ts | 60 +++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 apps/hq/src/scheduler.ts create mode 100644 apps/hq/test/scheduler-scan.test.ts diff --git a/apps/hq/src/repos-payments.ts b/apps/hq/src/repos-payments.ts index f3ae94e..8eaecf4 100644 --- a/apps/hq/src/repos-payments.ts +++ b/apps/hq/src/repos-payments.ts @@ -73,6 +73,12 @@ function outstandingOf(db: DB, doc: Doc): number { return doc.payablePaise - allocatedTo(db, doc.id) - creditedTo(db, doc.id) } +/** Exported outstanding read for the scheduler and dashboard. */ +export function outstandingPaise(db: DB, docId: string): number { + const doc = getDocument(db, docId) + return doc === null ? 0 : outstandingOf(db, doc) +} + /** Status flips: any allocation > 0 → part_paid; outstanding ≤ 0 → paid. */ function refreshSettlementStatus(db: DB, userId: string, docId: string): void { const doc = getDocument(db, docId) diff --git a/apps/hq/src/scheduler.ts b/apps/hq/src/scheduler.ts new file mode 100644 index 0000000..853ec81 --- /dev/null +++ b/apps/hq/src/scheduler.ts @@ -0,0 +1,90 @@ +import type { DB } from './db' +import { outstandingPaise } from './repos-payments' +import { getNumberSetting, upsertReminder } from './repos-reminders' +import 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). + */ + +export type ScanDeps = SendReminderDeps + +export interface ScanResult { + today: string + created: Record + autoSent: number + autoFailed: number +} + +/** ISO date arithmetic on YYYY-MM-DD (UTC), lexical-compare safe. */ +export function addDaysIso(dateIso: string, days: number): string { + const [y, m, d] = dateIso.split('-').map(Number) + return new Date(Date.UTC(y!, m! - 1, d! + days)).toISOString().slice(0, 10) +} + +function bump(created: Record, kind: string): void { + created[kind] = (created[kind] ?? 0) + 1 +} + +export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promise { + const now = deps.now?.() ?? new Date().toISOString() + const created: Record = {} + + // --- invoice_overdue --- + const overdueDays = getNumberSetting(db, 'reminders.overdue_days', 7) + const overdueCutoff = addDaysIso(today, -overdueDays) + const period = today.slice(0, 7) + const overdue = db.prepare( + `SELECT id, client_id FROM document + WHERE doc_type='INVOICE' AND doc_no IS NOT NULL + AND status NOT IN ('paid','cancelled','lost') AND doc_date <= ?`, + ).all(overdueCutoff) as { id: string; client_id: string }[] + for (const inv of overdue) { + if (outstandingPaise(db, inv.id) <= 0) continue + if (upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: period, clientId: inv.client_id, docId: inv.id, now }).created) { + bump(created, 'invoice_overdue') + } + } + + // --- renewal_due --- + const renewalDays = getNumberSetting(db, 'reminders.renewal_days', 15) + const renewalHorizon = addDaysIso(today, renewalDays) + const renewals = db.prepare( + `SELECT id, client_id, next_renewal FROM client_module + WHERE active=1 AND next_renewal IS NOT NULL AND next_renewal >= ? AND next_renewal <= ?`, + ).all(today, renewalHorizon) as { id: string; client_id: string; next_renewal: string }[] + for (const cm of renewals) { + if (upsertReminder(db, { ruleKind: 'renewal_due', subjectId: cm.id, duePeriod: cm.next_renewal, clientId: cm.client_id, now }).created) { + bump(created, 'renewal_due') + } + } + + // --- amc_expiring (per-contract window) --- + const amcs = db.prepare( + `SELECT id, client_id, period_to, renewal_reminder_days FROM amc_contract WHERE active=1`, + ).all() as { id: string; client_id: string; period_to: string; renewal_reminder_days: number }[] + for (const a of amcs) { + const horizon = addDaysIso(today, a.renewal_reminder_days) + if (a.period_to < today || a.period_to > horizon) continue + if (upsertReminder(db, { ruleKind: 'amc_expiring', subjectId: a.id, duePeriod: a.period_to, clientId: a.client_id, now }).created) { + bump(created, 'amc_expiring') + } + } + + // --- follow_up (internal) --- + const followUps = db.prepare( + `SELECT id, client_id, follow_up_on FROM interaction + WHERE follow_up_on IS NOT NULL AND follow_up_on <= ?`, + ).all(today) as { id: string; client_id: string; follow_up_on: string }[] + for (const f of followUps) { + if (upsertReminder(db, { ruleKind: 'follow_up', subjectId: f.id, duePeriod: f.follow_up_on, clientId: f.client_id, now }).created) { + bump(created, 'follow_up') + } + } + + return { today, created, autoSent: 0, autoFailed: 0 } +} diff --git a/apps/hq/test/scheduler-scan.test.ts b/apps/hq/test/scheduler-scan.test.ts new file mode 100644 index 0000000..cd57b9a --- /dev/null +++ b/apps/hq/test/scheduler-scan.test.ts @@ -0,0 +1,60 @@ +// 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 { listReminders } from '../src/repos-reminders' +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', +} + +function world() { + const db = openDb(':memory:'); seedIfEmpty(db) + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) + 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 } = world() + // Overdue invoice: issued 2026-06-01, unpaid, > 7 days before 2026-07-10. + const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) + db.prepare(`UPDATE document SET doc_date='2026-06-01' WHERE id=?`).run(inv.id) + // Renewal within 15 days. + const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' }) + // AMC expiring within 30 days. + createAmc(db, 'u1', { clientId: c.id, coverage: 'Support', periodFrom: '2025-08-01', periodTo: '2026-08-01', amountPaise: 20_000_00 }) + // Follow-up due. + 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(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(listReminders(db, {})).toHaveLength(4) + }) + it('does not raise a reminder for an invoice that is not yet overdue', async () => { + const { db, c, m } = world() + const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) + db.prepare(`UPDATE document SET doc_date='2026-07-08' WHERE id=?`).run(inv.id) // only 2 days old + const res = await runDailyScan(db, deps, '2026-07-10') + expect(res.created['invoice_overdue'] ?? 0).toBe(0) + }) +})