From 1523ebbcceb415182d501f54b46bab050000d6d1 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 14:57:33 +0530 Subject: [PATCH] =?UTF-8?q?docs:=20HQ-2=20implementation=20plan=20(12=20TD?= =?UTF-8?q?D=20tasks=20=E2=80=94=20reminders,=20recurring,=20AMC,=20dashbo?= =?UTF-8?q?ard)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-10-hq2-reminders.md | 1815 +++++++++++++++++ 1 file changed, 1815 insertions(+) diff --git a/docs/superpowers/plans/2026-07-10-hq2-reminders.md b/docs/superpowers/plans/2026-07-10-hq2-reminders.md index a793f3f..a8be41f 100644 --- a/docs/superpowers/plans/2026-07-10-hq2-reminders.md +++ b/docs/superpowers/plans/2026-07-10-hq2-reminders.md @@ -966,3 +966,1818 @@ git commit -m "feat(hq): interaction log with types, follow-ups and routes" -m " ``` --- + +### Task 5: Reminder templates + reminder repo (idempotent upsert, list, dismiss, settings) + +**Files:** +- Create: `apps/hq/src/reminder-templates.ts`, `apps/hq/src/repos-reminders.ts` +- Test: `apps/hq/test/reminders.test.ts` + +**Interfaces:** +- `reminder-templates.ts` produces: + - `ReminderRuleKind = 'invoice_overdue'|'renewal_due'|'amc_expiring'|'follow_up'|'recurring_generated'|'email_bounced'` + - `ReminderContext = { clientName; companyName; docNo?; amountPaise?; dueDate?; daysOverdue?; coverage?; period? }` + - `reminderEmail(kind, ctx): { subject: string; bodyText: string }` — polite Indian business English, amounts via `formatINR`. `follow_up`/`email_bounced` are internal (not client-facing) and return a neutral placeholder; `repos-reminders` refuses to email those kinds. +- `repos-reminders.ts` produces: + - `Reminder = { id; ruleKind; subjectId; duePeriod; clientId; docId: string | null; status: 'queued'|'sent'|'failed'|'dismissed'; policyApplied: 'auto'|'manual'; error: string | null; createdAt; sentAt: string | null }` + - `upsertReminder(db, input: { ruleKind; subjectId; duePeriod; clientId; docId?: string | null; policyApplied?: 'auto'|'manual'; now?: string }): { id: string; created: boolean }` — `INSERT OR IGNORE` on the idempotency key; returns the existing row's id (and `created:false`) when the key already exists. Writes an audit row **only** when a row is actually created. + - `getReminder(db, id): Reminder | null`; `listReminders(db, filter?: { status?; ruleKind?; clientId? }): Reminder[]`; `listQueue(db): Reminder[]` — `status IN ('queued','failed')`, newest first (the manual queue). + - `setReminderStatus(db, userId, id, status, opts?: { error?: string | null; sentAt?: string | null }): Reminder` — audited. + - `dismissReminder(db, userId, id): Reminder` — sets `status='dismissed'` (guard: cannot dismiss an already-`sent` reminder). + - Settings helpers (used across scheduler/bounces): `getSetting(db, key): string | null`; `setSetting(db, userId, key, value): void` (upsert + audit); `getNumberSetting(db, key, fallback): number`. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/reminders.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { reminderEmail } from '../src/reminder-templates' +import { + upsertReminder, listQueue, dismissReminder, setReminderStatus, getReminder, + getNumberSetting, setSetting, +} from '../src/repos-reminders' + +describe('reminder templates', () => { + it('renders an overdue dunning email with the amount and a polite tone', () => { + const mail = reminderEmail('invoice_overdue', { + clientName: 'Malabar Stores', companyName: 'Tecnostac', + docNo: 'INV/26-27-0007', amountPaise: 11_800_00, daysOverdue: 12, + }) + expect(mail.subject).toContain('INV/26-27-0007') + expect(mail.bodyText).toContain('₹11,800.00') + expect(mail.bodyText).toContain('12 day') + }) +}) + +describe('reminder repo', () => { + it('is idempotent on (rule_kind, subject_id, due_period)', () => { + const db = openDb(':memory:') + const a = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: 'inv1', duePeriod: '2026-07', clientId: 'c1', docId: 'inv1', now: '2026-07-10T00:00:00Z' }) + expect(a.created).toBe(true) + const b = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: 'inv1', duePeriod: '2026-07', clientId: 'c1', docId: 'inv1', now: '2026-07-10T06:00:00Z' }) + expect(b.created).toBe(false) + expect(b.id).toBe(a.id) // same row returned, not a duplicate + expect(listQueue(db)).toHaveLength(1) + expect(db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder'`).get()).toMatchObject({ n: 1 }) + }) + it('transitions and dismisses, refusing to dismiss a sent reminder', () => { + const db = openDb(':memory:') + const { id } = upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: 'c1', now: '2026-07-10T00:00:00Z' }) + const dismissed = dismissReminder(db, 'u1', id) + expect(dismissed.status).toBe('dismissed') + const { id: id2 } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: 'inv2', duePeriod: '2026-07', clientId: 'c1', docId: 'inv2', now: '2026-07-10T00:00:00Z' }) + setReminderStatus(db, 'u1', id2, 'sent', { sentAt: '2026-07-10T09:00:00Z' }) + expect(getReminder(db, id2)!.status).toBe('sent') + expect(() => dismissReminder(db, 'u1', id2)).toThrow(/sent/i) + }) + it('reads number settings with a fallback', () => { + const db = openDb(':memory:') + expect(getNumberSetting(db, 'reminders.overdue_days', 7)).toBe(7) + setSetting(db, 'u1', 'reminders.overdue_days', '10') + expect(getNumberSetting(db, 'reminders.overdue_days', 7)).toBe(10) + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/reminders.test.ts` → FAIL. + +- [ ] **Step 3: Implement `apps/hq/src/reminder-templates.ts`** + +```ts +import { formatINR } from '@sims/domain' + +/** Templated reminder emails — one place so WhatsApp can reuse them later (spec §7). */ + +export type ReminderRuleKind = + | 'invoice_overdue' | 'renewal_due' | 'amc_expiring' + | 'follow_up' | 'recurring_generated' | 'email_bounced' + +export interface ReminderContext { + clientName: string; companyName: string + docNo?: string; amountPaise?: number; dueDate?: string + daysOverdue?: number; coverage?: string; period?: string +} + +export interface ReminderMail { subject: string; bodyText: string } + +const signOff = (ctx: ReminderContext): string => `Warm regards,\n${ctx.companyName}` + +export function reminderEmail(kind: ReminderRuleKind, ctx: ReminderContext): ReminderMail { + switch (kind) { + case 'invoice_overdue': { + const amt = ctx.amountPaise !== undefined ? formatINR(ctx.amountPaise) : '' + return { + subject: `Payment reminder — Invoice ${ctx.docNo} (${ctx.companyName})`, + bodyText: + `Dear ${ctx.clientName},\n\n` + + `This is a gentle reminder that Invoice ${ctx.docNo}${amt !== '' ? ` for ${amt}` : ''} is outstanding` + + (ctx.daysOverdue !== undefined ? ` and is now ${ctx.daysOverdue} day(s) past due` : '') + + `. We would be grateful if you could arrange payment at your earliest convenience.\n\n` + + `If payment has already been made, kindly ignore this message.\n\n` + + signOff(ctx), + } + } + case 'recurring_generated': { + const amt = ctx.amountPaise !== undefined ? formatINR(ctx.amountPaise) : '' + const forPeriod = ctx.period !== undefined ? ` for ${ctx.period}` : '' + return { + subject: `Invoice ${ctx.docNo}${forPeriod} (${ctx.companyName})`, + bodyText: + `Dear ${ctx.clientName},\n\n` + + `Please find attached Invoice ${ctx.docNo}${forPeriod}${amt !== '' ? `, amounting to ${amt}` : ''}.\n\n` + + `Kindly arrange payment as per the agreed terms. Thank you for your continued association.\n\n` + + signOff(ctx), + } + } + case 'renewal_due': + return { + subject: `Subscription renewal reminder (${ctx.companyName})`, + bodyText: + `Dear ${ctx.clientName},\n\n` + + `Your subscription is due for renewal${ctx.dueDate !== undefined ? ` on ${ctx.dueDate}` : ' shortly'}. ` + + `Please let us know if you would like us to raise the renewal invoice.\n\n` + + `We value your continued association and look forward to serving you.\n\n` + + signOff(ctx), + } + case 'amc_expiring': + return { + subject: `AMC renewal reminder${ctx.coverage !== undefined && ctx.coverage !== '' ? ` — ${ctx.coverage}` : ''} (${ctx.companyName})`, + bodyText: + `Dear ${ctx.clientName},\n\n` + + `Your Annual Maintenance Contract${ctx.coverage !== undefined && ctx.coverage !== '' ? ` (${ctx.coverage})` : ''} ` + + `is due to expire${ctx.dueDate !== undefined ? ` on ${ctx.dueDate}` : ' soon'}. ` + + `To ensure uninterrupted support, please confirm the renewal at your convenience.\n\n` + + signOff(ctx), + } + case 'follow_up': + case 'email_bounced': + // Internal dashboard items — never emailed to the client. A neutral value + // exists so callers can't crash; repos-reminders refuses to send these kinds. + return { subject: '(internal reminder)', bodyText: '' } + } +} +``` + +Implement `apps/hq/src/repos-reminders.ts`: + +```ts +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' +import type { ReminderRuleKind } from './reminder-templates' + +/** Reminder rows + settings helpers (D12 plain-repo pattern). The UNIQUE key + * (rule_kind, subject_id, due_period) is the whole idempotency story — every + * create goes through INSERT OR IGNORE, so catch-up after downtime is safe. */ + +export type ReminderStatus = 'queued' | 'sent' | 'failed' | 'dismissed' + +export interface Reminder { + id: string; ruleKind: ReminderRuleKind; subjectId: string; duePeriod: string + clientId: string; docId: string | null; status: ReminderStatus + policyApplied: 'auto' | 'manual'; error: string | null; createdAt: string; sentAt: string | null +} + +interface ReminderRow { + id: string; rule_kind: string; subject_id: string; due_period: string; client_id: string + doc_id: string | null; status: string; policy_applied: string; error: string | null + created_at: string; sent_at: string | null +} + +function toReminder(r: ReminderRow): Reminder { + return { + id: r.id, ruleKind: r.rule_kind as ReminderRuleKind, subjectId: r.subject_id, + duePeriod: r.due_period, clientId: r.client_id, docId: r.doc_id, + status: r.status as ReminderStatus, policyApplied: r.policy_applied as 'auto' | 'manual', + error: r.error, createdAt: r.created_at, sentAt: r.sent_at, + } +} + +export function getReminder(db: DB, id: string): Reminder | null { + const row = db.prepare(`SELECT * FROM reminder WHERE id=?`).get(id) as ReminderRow | undefined + return row === undefined ? null : toReminder(row) +} + +export interface UpsertReminderInput { + ruleKind: ReminderRuleKind; subjectId: string; duePeriod: string; clientId: string + docId?: string | null; policyApplied?: 'auto' | 'manual'; now?: string +} + +/** Create the reminder for a (rule, subject, period) exactly once. Returns the + * existing row's id with created:false when the key is already present. */ +export function upsertReminder(db: DB, input: UpsertReminderInput): { id: string; created: boolean } { + const id = uuidv7() + const now = input.now ?? new Date().toISOString() + const res = db.prepare( + // Portability quirk: INSERT OR IGNORE is SQLite/Postgres dialect (standard SQL has MERGE). + `INSERT OR IGNORE INTO reminder + (id, rule_kind, subject_id, due_period, client_id, doc_id, status, policy_applied, error, created_at, sent_at) + VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, NULL, ?, NULL)`, + ).run( + id, input.ruleKind, input.subjectId, input.duePeriod, input.clientId, + input.docId ?? null, input.policyApplied ?? 'manual', now, + ) + if (res.changes === 1) { + writeAudit(db, 'system', 'create', 'reminder', id, undefined, { + ruleKind: input.ruleKind, subjectId: input.subjectId, duePeriod: input.duePeriod, + }) + return { id, created: true } + } + const existing = db.prepare( + `SELECT id FROM reminder WHERE rule_kind=? AND subject_id=? AND due_period=?`, + ).get(input.ruleKind, input.subjectId, input.duePeriod) as { id: string } + return { id: existing.id, created: false } +} + +export interface ReminderFilter { status?: ReminderStatus; ruleKind?: ReminderRuleKind; clientId?: string } + +export function listReminders(db: DB, filter: ReminderFilter = {}): Reminder[] { + let sql = `SELECT * FROM reminder WHERE 1=1` + const args: unknown[] = [] + if (filter.status !== undefined) { sql += ` AND status=?`; args.push(filter.status) } + if (filter.ruleKind !== undefined) { sql += ` AND rule_kind=?`; args.push(filter.ruleKind) } + if (filter.clientId !== undefined) { sql += ` AND client_id=?`; args.push(filter.clientId) } + sql += ` ORDER BY id DESC` + return (db.prepare(sql).all(...args) as ReminderRow[]).map(toReminder) +} + +/** The manual queue: everything awaiting a human — queued items and failed sends. */ +export function listQueue(db: DB): Reminder[] { + return (db.prepare( + `SELECT * FROM reminder WHERE status IN ('queued','failed') ORDER BY id DESC`, + ).all() as ReminderRow[]).map(toReminder) +} + +export function setReminderStatus( + db: DB, userId: string, id: string, status: ReminderStatus, + opts: { error?: string | null; sentAt?: string | null } = {}, +): Reminder { + const before = getReminder(db, id) + if (before === null) throw new Error('Reminder not found') + db.prepare(`UPDATE reminder SET status=?, error=?, sent_at=? WHERE id=?`).run( + status, + opts.error !== undefined ? opts.error : before.error, + opts.sentAt !== undefined ? opts.sentAt : before.sentAt, + id, + ) + const after = getReminder(db, id)! + writeAudit(db, userId, 'update', 'reminder', id, { status: before.status }, { status: after.status, error: after.error }) + return after +} + +export function dismissReminder(db: DB, userId: string, id: string): Reminder { + const before = getReminder(db, id) + if (before === null) throw new Error('Reminder not found') + if (before.status === 'sent') throw new Error('Cannot dismiss a reminder that was already sent') + return setReminderStatus(db, userId, id, 'dismissed') +} + +// ---------- settings helpers (shared by scheduler + bounce poller) ---------- + +export function getSetting(db: DB, key: string): string | null { + const row = db.prepare(`SELECT value FROM setting WHERE key=?`).get(key) as { value: string } | undefined + return row === undefined ? null : row.value +} + +export function setSetting(db: DB, userId: string, key: string, value: string): void { + const before = getSetting(db, key) + db.prepare( + // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE). + `INSERT INTO setting (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, + ).run(key, value) + writeAudit(db, userId, before === null ? 'create' : 'update', 'setting', key, before === null ? undefined : { value: before }, { value }) +} + +export function getNumberSetting(db: DB, key: string, fallback: number): number { + const raw = getSetting(db, key) + if (raw === null) return fallback + const n = Number(raw) + return Number.isFinite(n) ? n : fallback +} +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/reminders.test.ts` → PASS. `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/reminder-templates.ts apps/hq/src/repos-reminders.ts apps/hq/test/reminders.test.ts +git commit -m "feat(hq): reminder templates and idempotent reminder repo" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 6: Reminder sending — `sendReminderEmail` (gmail) + `sendReminder` orchestration + routes + +**Files:** +- Modify: `apps/hq/src/gmail.ts` (add `sendReminderEmail` — attachment-optional, no document coupling) +- Create: `apps/hq/src/send-reminder.ts` (orchestration: build context + optional PDF + send + status update) +- Modify: `apps/hq/src/api.ts` (reminder routes) +- Test: `apps/hq/test/send-reminder.test.ts` + +**Interfaces:** +- `gmail.ts` adds (reusing the module-private `SEND_URL` and the existing `getAccount`/`getAccessToken`/`buildMime`/`decrypt`/`logEmail`/`markAccountDead`/`TokenDeadError`; **no change to `sendDocumentEmail`**): + - `interface ReminderMailInput { to: string; subject: string; bodyText: string; attachment?: { filename: string; data: Buffer }; documentId?: string }` + - `sendReminderEmail(db, deps: GmailDeps, args: ReminderMailInput): Promise` — same token-death handling as `sendDocumentEmail` (`markAccountDead` + `{ ok:false, error:'gmail-token-dead' }`), logs `email_log` (`document_id = args.documentId ?? null`), but never touches document status. Missing account → logs failed, returns `{ ok:false, error:'gmail-not-connected' }`. +- `send-reminder.ts` produces: + - `interface SendReminderDeps { gmail: GmailDeps; renderPdf: (html: string) => Promise; company: () => Record; now?: () => string }` + - `sendReminder(db, deps, reminderId, userId): Promise` — loads the reminder; **refuses** `follow_up`/`email_bounced` (`throw` "not a sendable reminder"); builds `ReminderContext` from the subject (doc for overdue/recurring, client_module for renewal, amc for amc_expiring); if `reminder.docId` present renders that document's PDF (`documentHtml` + `deps.renderPdf`) as the attachment; resolves `to` = the client's first contact email (throws if none); calls `sendReminderEmail`; on `ok` sets reminder `sent` + `sentAt`, on failure sets `failed` + `error`; returns the `SendResult`. +- Routes: `GET /api/reminders?status=` (list; default the queue), `POST /api/reminders/:id/send`, `POST /api/reminders/:id/dismiss` — all `requireAuth`. + +- [ ] **Step 1: Write the failing test** (fetch + renderPdf both injected — offline, Chromium-free) + +```ts +// apps/hq/test/send-reminder.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 { createModule, setPrice } from '../src/repos-modules' +import { createDraft, issueDocument } from '../src/repos-documents' +import { saveAccount } from '../src/repos-email' +import { encrypt } from '../src/crypto' +import { upsertReminder, getReminder } from '../src/repos-reminders' +import { sendReminder, type SendReminderDeps } from '../src/send-reminder' + +const KEY = '11'.repeat(32) +const fakePdf = async () => Buffer.from('%PDF-fake') +const company = () => ({ 'company.name': 'Tecnostac' }) + +function deps(f: typeof fetch): SendReminderDeps { + return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: fakePdf, company, now: () => '2026-07-10T09:00:00Z' } +} + +function invoiceSetup() { + const db = openDb(':memory:'); seedIfEmpty(db) + saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY)) + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@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-04-01' }) + const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) + return { db, c, inv } +} + +describe('sendReminder', () => { + it('sends an overdue reminder with the invoice PDF and marks it sent', async () => { + const { db, c, inv } = invoiceSetup() + const { id } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-07', clientId: c.id, docId: inv.id, now: '2026-07-10T00:00:00Z' }) + const okFetch = (async (url: string) => + new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'gmsg-1' }), { status: 200 })) as typeof fetch + const out = await sendReminder(db, deps(okFetch), id, 'u1') + expect(out).toEqual({ ok: true }) + expect(getReminder(db, id)!.status).toBe('sent') + expect(getReminder(db, id)!.sentAt).toBe('2026-07-10T09:00:00Z') + const log = db.prepare(`SELECT status, document_id FROM email_log ORDER BY id DESC`).get() as { status: string; document_id: string } + expect(log).toMatchObject({ status: 'sent', document_id: inv.id }) + }) + it('leaves the reminder failed (not sent) on token death, and flips the account dead', async () => { + const { db, c, inv } = invoiceSetup() + const { id } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-07', clientId: c.id, docId: inv.id, now: '2026-07-10T00:00:00Z' }) + const deadFetch = (async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })) as typeof fetch + const out = await sendReminder(db, deps(deadFetch), id, 'u1') + expect(out).toEqual({ ok: false, error: 'gmail-token-dead' }) + expect(getReminder(db, id)!.status).toBe('failed') + expect(db.prepare(`SELECT status FROM email_account`).get()).toMatchObject({ status: 'dead' }) + }) + it('refuses to email an internal follow_up reminder', async () => { + const { db, c } = invoiceSetup() + const { id } = upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: c.id, now: '2026-07-10T00:00:00Z' }) + const noFetch = (async () => new Response('{}', { status: 200 })) as typeof fetch + await expect(sendReminder(db, deps(noFetch), id, 'u1')).rejects.toThrow(/sendable/i) + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/send-reminder.test.ts` → FAIL. + +- [ ] **Step 3: Implement.** Append to `apps/hq/src/gmail.ts` (after `sendDocumentEmail`; reuses everything already imported in the file): + +```ts +export interface ReminderMailInput { + to: string; subject: string; bodyText: string + attachment?: { filename: string; data: Buffer } + documentId?: string +} + +/** + * Send a templated reminder — attachment optional, no document-status side effects. + * Mirrors sendDocumentEmail's account/token-death handling and logs every attempt, + * so the dashboard banner and email_log stay consistent across both send paths. + */ +export async function sendReminderEmail( + db: DB, deps: GmailDeps, args: ReminderMailInput, +): Promise { + const logFail = (error: string): SendResult => { + logEmail(db, { + ...(args.documentId !== undefined ? { documentId: args.documentId } : {}), + to: args.to, subject: args.subject, status: 'failed', error, + }) + return { ok: false, error } + } + const account = getAccount(db) + if (account === null) return logFail('gmail-not-connected') + if (account.status === 'dead') return logFail('gmail-token-dead') + + let accessToken: string + try { + const refreshToken = decrypt(account.refreshTokenEnc, deps.keyHex) + accessToken = await getAccessToken(refreshToken, deps.clientId, deps.clientSecret, deps.f) + } catch (err) { + if (err instanceof TokenDeadError) { markAccountDead(db); return logFail('gmail-token-dead') } + return logFail(err instanceof Error ? err.message : String(err)) + } + + const raw = buildMime({ + from: account.address, to: args.to, subject: args.subject, bodyText: args.bodyText, + ...(args.attachment !== undefined + ? { attachment: { filename: args.attachment.filename, contentType: 'application/pdf', data: args.attachment.data } } + : {}), + }) + let gmailMessageId: string | undefined + try { + const res = await deps.f(SEND_URL, { + method: 'POST', + headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' }, + body: JSON.stringify({ raw }), + }) + const json = await res.json().catch(() => ({})) as { id?: string } + if (!res.ok) return logFail(`Gmail send failed: HTTP ${res.status}`) + gmailMessageId = json.id + } catch (err) { + return logFail(err instanceof Error ? err.message : String(err)) + } + + logEmail(db, { + ...(args.documentId !== undefined ? { documentId: args.documentId } : {}), + to: args.to, subject: args.subject, status: 'sent', + ...(gmailMessageId !== undefined ? { gmailMessageId } : {}), + }) + return { ok: true } +} +``` + +Implement `apps/hq/src/send-reminder.ts`: + +```ts +import type { DB } from './db' +import type { GmailDeps, SendResult } from './gmail' +import { sendReminderEmail } from './gmail' +import { getClient } from './repos-clients' +import { getClientModule } from './repos-modules' +import { getDocument } from './repos-documents' +import { getAmc } from './repos-amc' +import { getReminder, setReminderStatus } from './repos-reminders' +import { reminderEmail, type ReminderContext } from './reminder-templates' +import { documentHtml } from './templates' + +/** Turn a queued/failed reminder into an actual email — used by the manual-queue + * Send button and by the scheduler's auto path. Reuses gmail.sendReminderEmail. */ + +export interface SendReminderDeps { + gmail: GmailDeps + renderPdf: (html: string) => Promise + company: () => Record + now?: () => string +} + +export async function sendReminder( + db: DB, deps: SendReminderDeps, reminderId: string, userId: string, +): Promise { + const reminder = getReminder(db, reminderId) + if (reminder === null) throw new Error('Reminder not found') + if (reminder.ruleKind === 'follow_up' || reminder.ruleKind === 'email_bounced') { + throw new Error(`A ${reminder.ruleKind} reminder is an internal item, not a sendable email`) + } + const client = getClient(db, reminder.clientId) + if (client === null) throw new Error('Client not found') + const company = deps.company() + const companyName = company['company.name'] ?? '' + + // Build the per-rule context. + const ctx: ReminderContext = { clientName: client.name, companyName } + let attachment: { filename: string; data: Buffer } | undefined + let documentId: string | undefined + if (reminder.docId !== null) { + const doc = getDocument(db, reminder.docId) + if (doc === null) throw new Error('Reminder document not found') + documentId = doc.id + ctx.docNo = doc.docNo ?? undefined + ctx.amountPaise = doc.payablePaise + ctx.period = reminder.duePeriod + const pdf = await deps.renderPdf(documentHtml(doc, client, company)) + attachment = { filename: `${(doc.docNo ?? 'draft').replaceAll('/', '-')}.pdf`, data: pdf } + } else if (reminder.ruleKind === 'renewal_due') { + const cm = getClientModule(db, reminder.subjectId) + if (cm !== null && cm.nextRenewal !== null) ctx.dueDate = cm.nextRenewal + } else if (reminder.ruleKind === 'amc_expiring') { + const amc = getAmc(db, reminder.subjectId) + if (amc !== null) { ctx.coverage = amc.coverage; ctx.dueDate = amc.periodTo } + } + + const to = client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email + if (to === undefined) throw new Error('No recipient: add a contact email to the client') + + const mail = reminderEmail(reminder.ruleKind, ctx) + const out = await sendReminderEmail(db, deps.gmail, { + to, subject: mail.subject, bodyText: mail.bodyText, + ...(attachment !== undefined ? { attachment } : {}), + ...(documentId !== undefined ? { documentId } : {}), + }) + const now = deps.now?.() ?? new Date().toISOString() + if (out.ok) setReminderStatus(db, userId, reminderId, 'sent', { sentAt: now, error: null }) + else setReminderStatus(db, userId, reminderId, 'failed', { error: out.error }) + return out +} +``` + +Wire routes in `api.ts` (imports + block). `sendReminderDeps()` is built at request time so gmail-connect works without a restart, exactly like the existing `gmail()` helper: + +```ts +import { + dismissReminder, getReminder, listQueue, listReminders, + type ReminderStatus, +} from './repos-reminders' +import { sendReminder, type SendReminderDeps } from './send-reminder' +``` + +```ts + const sendReminderDeps = (): SendReminderDeps => ({ + gmail: gmail(), renderPdf, company: companySettings, + }) + + // ---------- reminders (manual queue) ---------- + r.get('/reminders', requireAuth, (req, res) => { + const status = typeof req.query['status'] === 'string' ? req.query['status'] as ReminderStatus : undefined + res.json({ ok: true, reminders: status !== undefined ? listReminders(db, { status }) : listQueue(db) }) + }) + r.post('/reminders/:id/send', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getReminder(db, id) === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return } + void (async () => { + try { + const out = await sendReminder(db, sendReminderDeps(), id, staffId(res)) + if (!out.ok) { + res.status(out.error === 'gmail-token-dead' ? 409 : 502).json({ ok: false, error: out.error }) + return + } + res.json({ ok: true, reminder: getReminder(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + })() + }) + r.post('/reminders/:id/dismiss', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getReminder(db, id) === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return } + try { + res.json({ ok: true, reminder: dismissReminder(db, staffId(res), id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) +``` + +(`renderPdf` and `documentHtml` are already imported in `api.ts` from HQ-1; reuse those imports.) + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/send-reminder.test.ts` → PASS. Full suite `npm test` green. `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/gmail.ts apps/hq/src/send-reminder.ts apps/hq/src/api.ts apps/hq/test/send-reminder.test.ts +git commit -m "feat(hq): reminder email send reusing gmail, plus manual-queue routes" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 7: Scheduler A — `runDailyScan` detection rules (overdue / renewal / amc / follow-up) + +**Files:** +- Modify: `apps/hq/src/repos-payments.ts` (export `outstandingPaise`) +- Create: `apps/hq/src/scheduler.ts` (`runDailyScan` — pure, injectable `today`; recurring generation is added in Task 8) +- Test: `apps/hq/test/scheduler-scan.test.ts` + +**Interfaces:** +- `repos-payments.ts` adds: `outstandingPaise(db, docId: string): number` — thin exported wrapper over the existing private `outstandingOf(db, doc)` (payable − allocations − non-cancelled credit notes). +- `scheduler.ts` produces: + - `interface ScanDeps extends SendReminderDeps {}` (reuses `SendReminderDeps` from `send-reminder.ts` — `gmail`, `renderPdf`, `company`, `now?`). + - `interface ScanResult { today: string; created: Record; autoSent: number; autoFailed: number }` + - `runDailyScan(db, deps, today: string): Promise` — **pure w.r.t. the clock**: `today` is the business date (`YYYY-MM-DD`); wall-clock stamps come from `deps.now`. In this task it creates queued reminders for four rules (all `policy_applied='manual'` — the dunning/renewal/amc/follow-up rules land in the manual queue by design; the only auto path is recurring, Task 8): + - `invoice_overdue` — issued INVOICEs, `status NOT IN ('paid','cancelled','lost')`, `doc_date <= today − reminders.overdue_days`, `outstandingPaise > 0`. `due_period = today[0:7]` (month bucket → at most one dunning per invoice per month). `subject_id = doc_id = invoice id`. + - `renewal_due` — `client_module` `active=1`, `next_renewal` within `[today, today + reminders.renewal_days]`. `due_period = next_renewal`. `subject_id = client_module id`. + - `amc_expiring` — `amc_contract` `active=1`, `period_to` within `[today, today + renewal_reminder_days]` (per-contract). `due_period = period_to`. `subject_id = amc id`. + - `follow_up` — `interaction.follow_up_on <= today`. `due_period = follow_up_on`. `subject_id = interaction id`. + - Exported date helper `addDaysIso(dateIso: string, days: number): string` (used by tests + Task 8/10). + +- [ ] **Step 1: Write the failing test** + +```ts +// 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) + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/scheduler-scan.test.ts` → FAIL. + +- [ ] **Step 3: Implement.** In `apps/hq/src/repos-payments.ts` add (below `outstandingOf`): + +```ts +/** 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) +} +``` + +Create `apps/hq/src/scheduler.ts`: + +```ts +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 } +} +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/scheduler-scan.test.ts` → PASS. `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/repos-payments.ts apps/hq/src/scheduler.ts apps/hq/test/scheduler-scan.test.ts +git commit -m "feat(hq): daily scan detection rules with idempotent reminders" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 8: Scheduler B — recurring invoice generation + auto-send (the §3 semantics) + +**Files:** +- Modify: `apps/hq/src/scheduler.ts` (add recurring generation + auto-send to `runDailyScan`) +- Test: `apps/hq/test/scheduler-recurring.test.ts` + +**The spec semantics this task nails (read carefully):** +- **Generation is transactional and atomic:** for each due period, one `db.transaction` claims the period (`upsertReminder` → `INSERT OR IGNORE`), generates the DRAFT invoice (`createDraft`) + issues it (`issueDocument`), links the invoice onto the reminder (`reminder.doc_id`), and advances `recurring_plan.next_run` by exactly one cadence — all-or-nothing. A crash rolls the whole period back. +- **`next_run` advances on generation, not on send** (the invoice existing is the spec's condition). The async email send happens *after* commit and only flips the already-committed reminder `queued → sent` / `queued → failed`. **A failed send never regenerates and never re-advances** — the invoice stays issued and the reminder waits in the manual queue with its `error` visible; the UNIQUE key forbids a second generation for that period. +- **Catch-up after downtime:** a `while (next_run <= today)` loop bills every missed period, each keyed on its own `due_period = next_run`, so `INSERT OR IGNORE` makes catch-up exactly-once. A safety cap (240 iterations) guards against a corrupt `next_run`. +- **At-most-once auto-send across crashes:** the auto-send runs once per newly-created reminder. If the process dies mid-send, the reminder is left `queued` (invoice already exists, `next_run` already advanced) → a human sends it from the queue; the scanner never auto-retries. + +**Interfaces:** no new exports — `runDailyScan` now also fills `created['recurring_generated']`, `autoSent`, `autoFailed`. + +- [ ] **Step 1: Write the failing test** + +```ts +// 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']) + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/scheduler-recurring.test.ts` → FAIL (recurring not generated yet). + +- [ ] **Step 3: Implement.** Add imports to `apps/hq/src/scheduler.ts`: + +```ts +import { writeAudit } from './audit' +import { createDraft, issueDocument } from './repos-documents' +import { getClientModule } from './repos-modules' +import { CADENCE_KIND, getRecurringPlan } from './repos-recurring' +import { sendReminder } from './send-reminder' +``` + +Add the cadence helper (next to `addDaysIso`): + +```ts +/** 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) +} +``` + +Add the generation helpers: + +```ts +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 +} +``` + +Finally, extend `runDailyScan` — replace its `return` with the recurring pass + auto-send loop: + +```ts + // --- 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 } +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/scheduler-recurring.test.ts` → PASS (all four cases, including catch-up = 3 and failed-send = still-one-invoice). Full `npm test` green. `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/scheduler.ts apps/hq/test/scheduler-recurring.test.ts +git commit -m "feat(hq): recurring invoice generation with at-most-once auto-send" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 9: Bounce detection — Gmail `readonly` scope + `pollBounces` + +**Files:** +- Modify: `apps/hq/scripts/gmail-connect.ts` (widen `SCOPE` to also request `gmail.readonly`) +- Create: `apps/hq/src/bounces.ts` (`pollBounces` + `markBounce`) +- Test: `apps/hq/test/bounces.test.ts` + +**Interfaces:** +- `bounces.ts` produces: + - `interface BounceDeps { gmail: GmailDeps; now?: () => string }` + - `pollBounces(db, deps): Promise<{ scanned: number; bounced: number }>` — reads mailer-daemon/postmaster DSNs since `reminders.bounce_last_poll` (default: 2 days back), extracts the failed recipient(s), and for each calls `markBounce`. Fetch-based (injectable `deps.gmail.f`), token-death-aware (`markAccountDead` + bail). Advances `reminders.bounce_last_poll` only on a clean pass. + - `markBounce(db, recipient, at): number` — flips the most recent `email_log` row (`to_addr=recipient`, `status='sent'`, `bounced=0`) to `bounced=1`, resolves the client via the linked document, and raises an `email_bounced` reminder (`INSERT OR IGNORE`, `subject_id = due_period = email_log.id` → idempotent). Returns 1 if a row matched, else 0. +- The `email_log.bounced` column already exists (added via the guarded `migrate` in Task 1). The `email_bounced` rule kind is already in the `reminder.rule_kind` CHECK (Task 1). **Note:** the company mailbox is not yet connected in production, so widening the OAuth scope now costs **no** re-consent — the first `gmail-connect` run grants both scopes together. + +- [ ] **Step 1: Write the failing test** (fetch injected; no network) + +```ts +// apps/hq/test/bounces.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { saveAccount, logEmail } from '../src/repos-email' +import { encrypt } from '../src/crypto' +import { listReminders } from '../src/repos-reminders' +import { pollBounces, type BounceDeps } from '../src/bounces' + +const KEY = '11'.repeat(32) + +function bounceFetch(recipient: string): typeof fetch { + return (async (url: string) => { + const u = String(url) + if (u.includes('/token')) return new Response(JSON.stringify({ access_token: 'at' }), { status: 200 }) + if (u.includes('/messages/b1')) { + return new Response(JSON.stringify({ + snippet: `Delivery to ${recipient} failed permanently`, + payload: { headers: [{ name: 'X-Failed-Recipients', value: recipient }] }, + }), { status: 200 }) + } + return new Response(JSON.stringify({ messages: [{ id: 'b1' }] }), { status: 200 }) // list + }) as typeof fetch +} + +describe('pollBounces', () => { + it('flips the sent email_log to bounced and raises an email_bounced reminder, idempotently', async () => { + const db = openDb(':memory:'); seedIfEmpty(db) + saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY)) + logEmail(db, { to: 'ravi@acme.in', subject: 'INV/26-27-0001', status: 'sent', gmailMessageId: 'g1' }) + const deps: BounceDeps = { + gmail: { f: bounceFetch('ravi@acme.in'), clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, + now: () => '2026-07-10T00:00:00Z', + } + const out = await pollBounces(db, deps) + expect(out).toEqual({ scanned: 1, bounced: 1 }) + expect(db.prepare(`SELECT bounced FROM email_log WHERE to_addr='ravi@acme.in'`).get()).toMatchObject({ bounced: 1 }) + expect(listReminders(db, { ruleKind: 'email_bounced' })).toHaveLength(1) + // Re-poll: the row is already bounced=0→1, so nothing new flips and no duplicate reminder. + const again = await pollBounces(db, deps) + expect(again.bounced).toBe(0) + expect(listReminders(db, { ruleKind: 'email_bounced' })).toHaveLength(1) + }) + it('no-ops cleanly when Gmail is not connected', async () => { + const db = openDb(':memory:'); seedIfEmpty(db) + const deps: BounceDeps = { gmail: { f: bounceFetch('x@y.z'), clientId: '', clientSecret: '', keyHex: KEY }, now: () => '2026-07-10T00:00:00Z' } + expect(await pollBounces(db, deps)).toEqual({ scanned: 0, bounced: 0 }) + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/bounces.test.ts` → FAIL. + +- [ ] **Step 3: Implement.** In `apps/hq/scripts/gmail-connect.ts` replace the `SCOPE` constant: + +```ts +// Two scopes: send (HQ-1) and readonly (HQ-2 bounce polling reads mailer-daemon DSNs). +// The company mailbox is not yet connected in production, so requesting both now +// costs no extra consent — the first connect grants them together. +const SCOPE = [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.readonly', +].join(' ') +``` + +(The existing `gmail-connect.test.ts` asserts the scope merely *contains* `gmail.send`, so it still passes.) + +Create `apps/hq/src/bounces.ts`: + +```ts +import { writeAudit } from './audit' +import { decrypt } from './crypto' +import type { DB } from './db' +import { getAccessToken, markAccountDead, TokenDeadError, type GmailDeps } from './gmail' +import { getDocument } from './repos-documents' +import { getAccount } from './repos-email' +import { getSetting, setSetting, upsertReminder } from './repos-reminders' + +/** + * Bounce detection — "sent" ≠ "delivered" (spec §3). Polls the mailbox (Gmail + * readonly) for mailer-daemon/postmaster DSNs since the last poll, matches failed + * recipients against email_log, flips them to bounced and raises a dashboard + * reminder. fetch is injected so tests run offline. Idempotent: a row only flips + * once (bounced=0 guard) and the reminder keys on the email_log id. + */ + +const GMAIL_LIST = 'https://gmail.googleapis.com/gmail/v1/users/me/messages' +const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/g + +export interface BounceDeps { gmail: GmailDeps; now?: () => string } + +interface GmailMessageMeta { snippet?: string; payload?: { headers?: { name: string; value: string }[] } } + +function extractRecipients(msg: GmailMessageMeta): string[] { + const out = new Set() + for (const h of msg.payload?.headers ?? []) { + if (h.name.toLowerCase() === 'x-failed-recipients') { + for (const a of h.value.split(',')) { const t = a.trim(); if (t !== '') out.add(t.toLowerCase()) } + } + } + for (const m of (msg.snippet ?? '').matchAll(EMAIL_RE)) out.add(m[0].toLowerCase()) + return [...out] +} + +/** Flip the most recent matching sent log to bounced + raise an email_bounced reminder. */ +export function markBounce(db: DB, recipient: string, at: string): number { + const row = db.prepare( + `SELECT id, document_id FROM email_log + WHERE lower(to_addr)=lower(?) AND status='sent' AND bounced=0 + ORDER BY id DESC LIMIT 1`, + ).get(recipient) as { id: string; document_id: string | null } | undefined + if (row === undefined) return 0 + db.prepare(`UPDATE email_log SET bounced=1 WHERE id=?`).run(row.id) + const clientId = row.document_id !== null ? (getDocument(db, row.document_id)?.clientId ?? '') : '' + upsertReminder(db, { + ruleKind: 'email_bounced', subjectId: row.id, duePeriod: row.id, clientId, + docId: row.document_id, now: at, + }) + writeAudit(db, 'system', 'update', 'email_log', row.id, { bounced: 0 }, { bounced: 1, recipient }) + return 1 +} + +export async function pollBounces(db: DB, deps: BounceDeps): Promise<{ scanned: number; bounced: number }> { + const now = deps.now?.() ?? new Date().toISOString() + const account = getAccount(db) + if (account === null || account.status === 'dead') return { scanned: 0, bounced: 0 } + let accessToken: string + try { + accessToken = await getAccessToken( + decrypt(account.refreshTokenEnc, deps.gmail.keyHex), deps.gmail.clientId, deps.gmail.clientSecret, deps.gmail.f, + ) + } catch (err) { + if (err instanceof TokenDeadError) markAccountDead(db) + return { scanned: 0, bounced: 0 } + } + const since = getSetting(db, 'reminders.bounce_last_poll') + ?? new Date(Date.parse(now) - 2 * 86_400_000).toISOString() + const q = `from:mailer-daemon OR from:postmaster after:${Math.floor(Date.parse(since) / 1000)}` + const headers = { authorization: `Bearer ${accessToken}` } + let scanned = 0 + let bounced = 0 + try { + const list = await deps.gmail.f(`${GMAIL_LIST}?q=${encodeURIComponent(q)}`, { headers }) + const listJson = await list.json().catch(() => ({})) as { messages?: { id: string }[] } + for (const { id } of listJson.messages ?? []) { + scanned += 1 + const msg = await deps.gmail.f( + `${GMAIL_LIST}/${id}?format=metadata&metadataHeaders=Subject&metadataHeaders=X-Failed-Recipients`, { headers }, + ) + const meta = await msg.json().catch(() => ({})) as GmailMessageMeta + for (const recipient of extractRecipients(meta)) bounced += markBounce(db, recipient, now) + } + } catch { + // Network hiccup: leave bounce_last_poll unchanged so the next pass re-scans this window. + return { scanned, bounced } + } + setSetting(db, 'system', 'reminders.bounce_last_poll', now) + return { scanned, bounced } +} +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/bounces.test.ts` → PASS. `npx vitest run apps/hq/test/gmail-connect.test.ts` still PASS. `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/scripts/gmail-connect.ts apps/hq/src/bounces.ts apps/hq/test/bounces.test.ts +git commit -m "feat(hq): mailbox bounce detection with email_bounced reminders" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 10: Dashboard aggregation + `/api/dashboard` + scheduler wiring in `server.ts` + +**Files:** +- Create: `apps/hq/src/repos-dashboard.ts` +- Modify: `apps/hq/src/api.ts` (`GET /api/dashboard`), `apps/hq/src/server.ts` (`startServer(port, { scheduler })` + `startScheduler`) +- Test: `apps/hq/test/dashboard.test.ts` + +**Interfaces:** +- `repos-dashboard.ts` produces `dashboardView(db, today): DashboardView` where + ```ts + interface DashboardView { + today: string + overdue: { docId; docNo: string | null; clientId; clientName; outstandingPaise; daysOverdue }[] + dueThisWeek: { planId; clientId; clientName; nextRun; amountPaise: number | null }[] + renewalsThisMonth: { clientModuleId; clientId; clientName; nextRenewal }[] + followUpsToday: { id; clientId; clientName; onDate; followUpOn: string; notes }[] + recentPayments: { id; clientId; clientName; receivedOn; amountPaise; mode }[] + queue: (Reminder & { clientName: string; docNo: string | null })[] + totals: { overduePaise: number; queued: number; failed: number } + } + ``` + — overdue = issued unpaid invoices (`outstandingPaise > 0`, `doc_date <= today`); dueThisWeek = active recurring plans with `next_run` in `[today, today+7]`; renewalsThisMonth = active client_modules with `next_renewal` in `[today, end-of-month]`; followUpsToday via `listOpenFollowUps(db, today)`; recentPayments = last 10; queue = `listQueue(db)` enriched with client name + doc number. +- `server.ts` produces: `startServer(port: number, opts?: { scheduler?: boolean }): http.Server` (scheduler **off by default** so tests importing `startServer` never spawn intervals or run scans); `startScheduler(db): NodeJS.Timeout` — runs `runDailyScan` + `pollBounces` on boot and every 6h, `handle.unref()` so it never blocks process exit. The direct-launch block passes `{ scheduler: true }`. +- Route: `GET /api/dashboard` (`requireAuth`). + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/dashboard.test.ts +import { describe, it, expect, afterAll } from 'vitest' +import express from 'express' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createStaff } from '../src/auth' +import { apiRouter } from '../src/api' +import { createClient } from '../src/repos-clients' +import { assignModule, createModule, setPrice, updateClientModule } from '../src/repos-modules' +import { createDraft, issueDocument } from '../src/repos-documents' +import { createInteraction } from '../src/repos-interactions' +import { dashboardView } from '../src/repos-dashboard' +import { runDailyScan, type ScanDeps } from '../src/scheduler' + +const scanDeps: 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 seeded() { + 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' }) + 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) + const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' }) + createInteraction(db, 'u1', { clientId: c.id, typeCode: 'call', onDate: '2026-07-01', followUpOn: '2026-07-09' }) + return { db, c, inv } +} + +describe('dashboardView', () => { + it('aggregates overdue, renewals, follow-ups and the reminder queue', async () => { + const { db, inv } = seeded() + await runDailyScan(db, scanDeps, '2026-07-10') // populates the queue + const view = dashboardView(db, '2026-07-10') + expect(view.overdue.map((o) => o.docId)).toContain(inv.id) + expect(view.overdue[0]!.outstandingPaise).toBe(11_800_00) + expect(view.renewalsThisMonth).toHaveLength(1) + expect(view.followUpsToday).toHaveLength(1) + expect(view.queue.length).toBeGreaterThanOrEqual(3) // overdue + renewal + follow_up + expect(view.totals.overduePaise).toBe(11_800_00) + }) +}) + +describe('GET /api/dashboard + reminder queue routes', () => { + const { db } = seeded() + createStaff(db, { email: 'e2e@test.in', displayName: 'E2E', role: 'owner', password: 'e2e-password' }) + const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) + const server = app.listen(0) + const base = `http://localhost:${(server.address() as { port: number }).port}/api` + let token = '' + const call = async (method: string, path: string, body?: unknown) => { + const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) }) + return { status: res.status, json: await res.json() as any } + } + afterAll(() => server.close()) + it('serves the dashboard, lists the queue and dismisses a reminder', async () => { + token = (await call('POST', '/auth/login', { email: 'e2e@test.in', password: 'e2e-password' })).json.token + await runDailyScan(db, scanDeps, '2026-07-10') + const dash = (await call('GET', '/dashboard')).json + expect(dash.ok).toBe(true) + expect(dash.view.overdue.length).toBeGreaterThanOrEqual(1) + const queue = (await call('GET', '/reminders')).json.reminders + expect(queue.length).toBeGreaterThanOrEqual(1) + const dismissed = (await call('POST', `/reminders/${queue[0].id}/dismiss`)).json + expect(dismissed.reminder.status).toBe('dismissed') + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/dashboard.test.ts` → FAIL. + +- [ ] **Step 3: Implement `apps/hq/src/repos-dashboard.ts`** + +```ts +import type { DB } from './db' +import { outstandingPaise } from './repos-payments' +import { listOpenFollowUps } from './repos-interactions' +import { listQueue, type Reminder } from './repos-reminders' +import { addDaysIso } from './scheduler' + +/** Read-only money view for the dashboard home (spec §3 "today's money"). */ + +export interface DashboardView { + today: string + overdue: { docId: string; docNo: string | null; clientId: string; clientName: string; outstandingPaise: number; daysOverdue: number }[] + dueThisWeek: { planId: string; clientId: string; clientName: string; nextRun: string; amountPaise: number | null }[] + renewalsThisMonth: { clientModuleId: string; clientId: string; clientName: string; nextRenewal: string }[] + followUpsToday: { id: string; clientId: string; clientName: string; onDate: string; followUpOn: string; notes: string }[] + recentPayments: { id: string; clientId: string; clientName: string; receivedOn: string; amountPaise: number; mode: string }[] + queue: (Reminder & { clientName: string; docNo: string | null })[] + totals: { overduePaise: number; queued: number; failed: number } +} + +function daysBetween(fromIso: string, toIso: string): number { + return Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 86_400_000) +} + +function endOfMonth(today: string): string { + const [y, m] = today.split('-').map(Number) + return new Date(Date.UTC(y!, m!, 0)).toISOString().slice(0, 10) // day 0 of next month = last day of this +} + +export function dashboardView(db: DB, today: string): DashboardView { + const overdueRows = db.prepare( + `SELECT d.id, d.doc_no, d.client_id, d.doc_date, c.name AS client_name + FROM document d JOIN client c ON c.id = d.client_id + WHERE d.doc_type='INVOICE' AND d.doc_no IS NOT NULL + AND d.status NOT IN ('paid','cancelled','lost') AND d.doc_date <= ? + ORDER BY d.doc_date`, + ).all(today) as { id: string; doc_no: string | null; client_id: string; doc_date: string; client_name: string }[] + const overdue: DashboardView['overdue'] = [] + let overduePaise = 0 + for (const r of overdueRows) { + const outstanding = outstandingPaise(db, r.id) + if (outstanding <= 0) continue + overduePaise += outstanding + overdue.push({ + docId: r.id, docNo: r.doc_no, clientId: r.client_id, clientName: r.client_name, + outstandingPaise: outstanding, daysOverdue: Math.max(0, daysBetween(r.doc_date, today)), + }) + } + + const weekEnd = addDaysIso(today, 7) + const dueThisWeek = db.prepare( + `SELECT rp.id AS plan_id, rp.client_id, rp.next_run, rp.amount_paise, c.name AS client_name + FROM recurring_plan rp JOIN client c ON c.id = rp.client_id + WHERE rp.active=1 AND rp.next_run >= ? AND rp.next_run <= ? ORDER BY rp.next_run`, + ).all(today, weekEnd).map((r) => { + const row = r as { plan_id: string; client_id: string; next_run: string; amount_paise: number | null; client_name: string } + return { planId: row.plan_id, clientId: row.client_id, clientName: row.client_name, nextRun: row.next_run, amountPaise: row.amount_paise } + }) + + const renewalsThisMonth = db.prepare( + `SELECT cm.id AS cm_id, cm.client_id, cm.next_renewal, c.name AS client_name + FROM client_module cm JOIN client c ON c.id = cm.client_id + WHERE cm.active=1 AND cm.next_renewal >= ? AND cm.next_renewal <= ? ORDER BY cm.next_renewal`, + ).all(today, endOfMonth(today)).map((r) => { + const row = r as { cm_id: string; client_id: string; next_renewal: string; client_name: string } + return { clientModuleId: row.cm_id, clientId: row.client_id, clientName: row.client_name, nextRenewal: row.next_renewal } + }) + + const followUpsToday = listOpenFollowUps(db, today).map((f) => ({ + id: f.id, clientId: f.clientId, clientName: f.clientName, onDate: f.onDate, + followUpOn: f.followUpOn!, notes: f.notes, + })) + + const recentPayments = db.prepare( + `SELECT p.id, p.client_id, p.received_on, p.amount_paise, p.mode, c.name AS client_name + FROM payment p JOIN client c ON c.id = p.client_id ORDER BY p.id DESC LIMIT 10`, + ).all().map((r) => { + const row = r as { id: string; client_id: string; received_on: string; amount_paise: number; mode: string; client_name: string } + return { id: row.id, clientId: row.client_id, clientName: row.client_name, receivedOn: row.received_on, amountPaise: row.amount_paise, mode: row.mode } + }) + + const queue = listQueue(db).map((rem) => { + const client = db.prepare(`SELECT name FROM client WHERE id=?`).get(rem.clientId) as { name: string } | undefined + const doc = rem.docId !== null + ? db.prepare(`SELECT doc_no FROM document WHERE id=?`).get(rem.docId) as { doc_no: string | null } | undefined + : undefined + return { ...rem, clientName: client?.name ?? '', docNo: doc?.doc_no ?? null } + }) + + return { + today, overdue, dueThisWeek, renewalsThisMonth, followUpsToday, recentPayments, queue, + totals: { + overduePaise, + queued: queue.filter((q) => q.status === 'queued').length, + failed: queue.filter((q) => q.status === 'failed').length, + }, + } +} +``` + +Add the route in `api.ts` (import + handler): + +```ts +import { dashboardView } from './repos-dashboard' +``` + +```ts + // ---------- dashboard ---------- + r.get('/dashboard', requireAuth, (_req, res) => { + const today = new Date().toISOString().slice(0, 10) + res.json({ ok: true, view: dashboardView(db, today) }) + }) +``` + +Wire the scheduler in `apps/hq/src/server.ts` — add imports, `startScheduler`, and the `opts` param: + +```ts +import type { DB } from './db' +import { pollBounces } from './bounces' +import { renderPdf } from './pdf' +import { runDailyScan } from './scheduler' +``` + +```ts +function schedulerDeps(db: DB) { + const company = (): Record => + Object.fromEntries((db.prepare(`SELECT key, value FROM setting WHERE key LIKE 'company.%'`) + .all() as { key: string; value: string }[]).map((r) => [r.key, r.value])) + const gmail = { + f: fetch, + clientId: process.env['GOOGLE_CLIENT_ID'] ?? '', + clientSecret: process.env['GOOGLE_CLIENT_SECRET'] ?? '', + keyHex: process.env['HQ_SECRET_KEY'] ?? '', + } + return { gmail, renderPdf, company } +} + +/** Boot the scan + bounce poll now and every 6h. unref() so it never blocks exit. */ +export function startScheduler(db: DB): NodeJS.Timeout { + const deps = schedulerDeps(db) + const today = (): string => new Date().toISOString().slice(0, 10) + const tick = (): void => { + void runDailyScan(db, deps, today()).catch((e: unknown) => console.error('[scheduler] scan failed', e)) + void pollBounces(db, deps).catch((e: unknown) => console.error('[scheduler] bounce poll failed', e)) + } + tick() + const handle = setInterval(tick, 6 * 60 * 60 * 1000) + handle.unref() + return handle +} +``` + +Change `startServer` to take options and start the scheduler only when asked (default off keeps existing tests interval-free): + +```ts +export function startServer(port: number, opts: { scheduler?: boolean } = {}): http.Server { + const app = express() + const db = openDb(process.env['HQ_DATA_DIR']) + seedIfEmpty(db) + app.locals['db'] = db + app.use(express.json({ limit: '2mb' })) + app.use('/api', apiRouter(db)) + const webDist = path.resolve(moduleDir, '../../hq-web/dist') + if (fs.existsSync(webDist)) app.use('/', express.static(webDist)) + if (opts.scheduler === true) startScheduler(db) + return app.listen(port) +} +``` + +And the direct-launch block passes `{ scheduler: true }`: + +```ts +if (process.argv[1]?.endsWith('server.cjs') || process.argv[1]?.endsWith('server.ts')) { + const PORT = Number(process.env['HQ_PORT'] ?? 5182) + startServer(PORT, { scheduler: true }) + console.log(`SiMS HQ console on http://localhost:${PORT}`) +} +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/dashboard.test.ts` → PASS. Full `npm test` green (HQ-1's `health.test.ts` still exits cleanly — `startServer(0)` starts no interval). `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/repos-dashboard.ts apps/hq/src/api.ts apps/hq/src/server.ts apps/hq/test/dashboard.test.ts +git commit -m "feat(hq): dashboard money view and 6-hourly scheduler wiring" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 11: `apps/hq-web` — API client, Dashboard page (new default route), nav + +**Files:** +- Modify: `apps/hq-web/src/api.ts` (types + typed calls), `apps/hq-web/src/Layout.tsx` (nav), `apps/hq-web/src/main.tsx` (routes) +- Create: `apps/hq-web/src/pages/Dashboard.tsx` + +**Interfaces / contracts:** +- `api.ts` gains the server shapes (camelCase JSON, mirroring the repos) and typed calls. The Dashboard becomes `/`; the client list moves to `/clients`. +- `Dashboard.tsx` is a plain fetch-render-act page (`useData` from `Clients`, `@sims/ui` components) with: four `StatCard`s (overdue ₹, queued, failed, follow-ups), the **manual reminder queue** (`Send` for sendable kinds → `POST /reminders/:id/send`, `Dismiss` → `POST /reminders/:id/dismiss`; a 409 surfaces the Gmail error inline), then Overdue / Due-this-week / Renewals-this-month / Follow-ups / Recent-payments tables. Rows deep-link to the document or client. + +- [ ] **Step 1: Implement the API client additions.** Append to `apps/hq-web/src/api.ts`: + +```ts +// ---------- HQ-2 shapes ---------- + +export type Cadence = 'monthly' | 'yearly' +export interface RecurringPlan { + id: string; clientId: string; clientModuleId: string | null; cadence: Cadence + amountPaise: number | null; nextRun: string; policy: 'auto' | 'manual'; active: boolean +} + +export type AmcPaidStatus = 'paid' | 'unpaid' | 'unbilled' +export interface AmcContract { + id: string; clientId: string; coverage: string; periodFrom: string; periodTo: string + amountPaise: number; renewalReminderDays: number; legacyPaid: boolean | null + invoiceDocId: string | null; active: boolean; paidStatus: AmcPaidStatus +} + +export interface InteractionType { code: string; label: string } +export type Outcome = 'positive' | 'neutral' | 'negative' +export interface Interaction { + id: string; clientId: string; typeCode: string; onDate: string; staffId: string + notes: string; outcome: Outcome | null; followUpOn: string | null; createdAt: string +} + +export type ReminderRuleKind = + | 'invoice_overdue' | 'renewal_due' | 'amc_expiring' | 'follow_up' | 'recurring_generated' | 'email_bounced' +export type ReminderStatus = 'queued' | 'sent' | 'failed' | 'dismissed' +export interface Reminder { + id: string; ruleKind: ReminderRuleKind; subjectId: string; duePeriod: string; clientId: string + docId: string | null; status: ReminderStatus; policyApplied: 'auto' | 'manual' + error: string | null; createdAt: string; sentAt: string | null +} + +export interface DashboardView { + today: string + overdue: { docId: string; docNo: string | null; clientId: string; clientName: string; outstandingPaise: number; daysOverdue: number }[] + dueThisWeek: { planId: string; clientId: string; clientName: string; nextRun: string; amountPaise: number | null }[] + renewalsThisMonth: { clientModuleId: string; clientId: string; clientName: string; nextRenewal: string }[] + followUpsToday: { id: string; clientId: string; clientName: string; onDate: string; followUpOn: string; notes: string }[] + recentPayments: { id: string; clientId: string; clientName: string; receivedOn: string; amountPaise: number; mode: string }[] + queue: (Reminder & { clientName: string; docNo: string | null })[] + totals: { overduePaise: number; queued: number; failed: number } +} + +// ---------- HQ-2 calls ---------- + +export const getDashboard = (): Promise => + apiFetch<{ view: DashboardView }>('/dashboard').then((r) => r.view) +export const getReminders = (status?: ReminderStatus): Promise => + apiFetch<{ reminders: Reminder[] }>(`/reminders${status !== undefined ? `?status=${status}` : ''}`).then((r) => r.reminders) +export const sendReminder = (id: string): Promise => + apiFetch<{ reminder: Reminder }>(`/reminders/${id}/send`, { method: 'POST', body: '{}' }).then((r) => r.reminder) +export const dismissReminder = (id: string): Promise => + apiFetch<{ reminder: Reminder }>(`/reminders/${id}/dismiss`, { method: 'POST', body: '{}' }).then((r) => r.reminder) + +export const getRecurringPlans = (clientId: string): Promise => + apiFetch<{ plans: RecurringPlan[] }>(`/recurring?clientId=${clientId}`).then((r) => r.plans) +export const createRecurringPlan = (clientId: string, body: Record): Promise => + apiFetch<{ plan: RecurringPlan }>(`/clients/${clientId}/recurring`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.plan) +export const updateRecurringPlan = (id: string, body: Record): Promise => + apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.plan) +export const deactivateRecurringPlan = (id: string): Promise => + apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.plan) + +export const getAmc = (clientId: string): Promise => + apiFetch<{ contracts: AmcContract[] }>(`/clients/${clientId}/amc`).then((r) => r.contracts) +export const createAmc = (clientId: string, body: Record): Promise => + apiFetch<{ contract: AmcContract }>(`/clients/${clientId}/amc`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.contract) +export const updateAmc = (id: string, body: Record): Promise => + apiFetch<{ contract: AmcContract }>(`/amc/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.contract) +export const deactivateAmc = (id: string): Promise => + apiFetch<{ contract: AmcContract }>(`/amc/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.contract) +export const generateAmcRenewalInvoice = (id: string): Promise => + apiFetch<{ document: Doc }>(`/amc/${id}/renewal-invoice`, { method: 'POST', body: '{}' }).then((r) => r.document) + +export const getInteractionTypes = (): Promise => + apiFetch<{ types: InteractionType[] }>('/interaction-types').then((r) => r.types) +export const getInteractions = (clientId: string): Promise => + apiFetch<{ interactions: Interaction[] }>(`/clients/${clientId}/interactions`).then((r) => r.interactions) +export const createInteraction = (clientId: string, body: Record): Promise => + apiFetch<{ interaction: Interaction }>(`/clients/${clientId}/interactions`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.interaction) +export const updateInteraction = (id: string, body: Record): Promise => + apiFetch<{ interaction: Interaction }>(`/interactions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.interaction) +``` + +- [ ] **Step 2: Create `apps/hq-web/src/pages/Dashboard.tsx`** + +```tsx +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { formatINR } from '@sims/domain' +import { Badge, Button, DataTable, EmptyState, Notice, PageHeader, StatCard, Stats } from '@sims/ui' +import { getDashboard, sendReminder, dismissReminder, type Reminder } from '../api' +import { useData } from './Clients' + +const inr = (p: number) => formatINR(p) + +const RULE_LABEL: Record = { + invoice_overdue: 'Overdue invoice', renewal_due: 'Renewal due', amc_expiring: 'AMC expiring', + follow_up: 'Follow-up', recurring_generated: 'Recurring invoice', email_bounced: 'Email bounced', +} +const SENDABLE = new Set(['invoice_overdue', 'renewal_due', 'amc_expiring', 'recurring_generated']) +const toneFor = (status: string): 'ok' | 'warn' | 'err' => + status === 'failed' ? 'err' : status === 'sent' ? 'ok' : 'warn' + +/** Dashboard home — today's money + the manual reminder queue (spec §3). */ +export function Dashboard() { + const nav = useNavigate() + const dash = useData(getDashboard, []) + const [err, setErr] = useState('') + const v = dash.data + + return ( +
+ + {dash.error !== undefined && {dash.error}} + {err !== '' && {err}} + {v === undefined ? Loading… : ( + <> + + + + + + + +

Reminder queue

+ {v.queue.length === 0 ? Nothing waiting — all clear. : ( + ({ + kind: RULE_LABEL[rem.ruleKind] ?? rem.ruleKind, + client: rem.clientName, + ref: rem.docNo ?? rem.duePeriod, + status: {rem.status}, + error: rem.error ?? '—', + act: , + }))} + /> + )} + +
nav(`/documents/${v.overdue[i]!.docId}`)} + map={(o) => ({ no: o.docNo ?? '—', client: o.clientName, days: o.daysOverdue, due: inr(o.outstandingPaise) })} /> + +
({ client: d.clientName, on: d.nextRun, amt: d.amountPaise !== null ? inr(d.amountPaise) : 'from price book' })} /> + +
nav(`/clients/${v.renewalsThisMonth[i]!.clientId}`)} + map={(r) => ({ client: r.clientName, on: r.nextRenewal })} /> + +
nav(`/clients/${v.followUpsToday[i]!.clientId}`)} + map={(f) => ({ client: f.clientName, on: f.followUpOn, notes: f.notes !== '' ? f.notes : '—' })} /> + +
({ client: p.clientName, on: p.receivedOn, mode: p.mode, amt: inr(p.amountPaise) })} /> + + )} +
+ ) +} + +/** A titled table with an empty state — keeps the dashboard body declarative. */ +function Section(props: { + title: string; empty: string; rows: T[] + columns: { key: string; label: string; numeric?: boolean }[] + map: (row: T) => Record; onRowClick?: (i: number) => void +}) { + return ( + <> +

{props.title}

+ {props.rows.length === 0 ? {props.empty} : ( + props.onRowClick!(i) } : {})} + rows={props.rows.map(props.map)} + /> + )} + + ) +} + +function QueueActions(props: { rem: Reminder & { docNo: string | null }; onDone: () => void; onError: (m: string) => void }) { + const [busy, setBusy] = useState(false) + const run = (p: Promise) => { + setBusy(true); props.onError('') + p.then(props.onDone).catch((e: Error) => props.onError(e.message)).finally(() => setBusy(false)) + } + return ( +
+ {SENDABLE.has(props.rem.ruleKind) && ( + + )} + +
+ ) +} +``` + +- [ ] **Step 3: Nav + routes.** In `apps/hq-web/src/Layout.tsx` change the `NAV` array to lead with the dashboard: + +```ts +const NAV = [ + { to: '/', label: 'Dashboard' }, + { to: '/clients', label: 'Clients' }, + { to: '/modules', label: 'Modules' }, + { to: '/documents/new', label: 'New Document' }, +] +``` + +In `apps/hq-web/src/main.tsx` import `Dashboard` and re-point the routes (dashboard is home; clients list moves to `/clients`): + +```tsx +import { Dashboard } from './pages/Dashboard' +// ... + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +``` + +(Update the `Clients.tsx` "New client" success navigation and any `/` link that meant the client list to `/clients` — the row-click handlers already use `/clients/:id`, which is unchanged.) + +- [ ] **Step 4: Verify** — `cd apps/hq-web && npm run typecheck && npm run build` → clean. With the server running (`npm start` in `apps/hq`), `npm run dev`: after login the Dashboard is the landing page; the nav shows Dashboard · Clients · Modules · New Document. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq-web/src/api.ts apps/hq-web/src/pages/Dashboard.tsx apps/hq-web/src/Layout.tsx apps/hq-web/src/main.tsx +git commit -m "feat(hq-web): dashboard money view with manual reminder queue as default route" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 12: `apps/hq-web` — ClientDetail Recurring / AMC / Interactions sections + full browser verification + +**Files:** +- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` (three new sections below the existing Payments & dues block) + +**Section contracts** (each a plain fetch-render-act block using `useData` + `@sims/ui`, exactly like the existing Modules/Documents/Payments sections in this file; `role()` from `../api` gates owner-only forms). Add these fetches near the top of `ClientDetail`: + +```tsx +import { role, getRecurringPlans, createRecurringPlan, deactivateRecurringPlan, + getAmc, createAmc, generateAmcRenewalInvoice, getInteractions, getInteractionTypes, createInteraction, + updateInteraction, type AmcContract, type Interaction, type InteractionType, type RecurringPlan } from '../api' +// inside ClientDetail(): +const plans = useData(() => getRecurringPlans(id), [id]) +const amc = useData(() => getAmc(id), [id]) +const interactions = useData(() => getInteractions(id), [id]) +const types = useData(getInteractionTypes, []) +const isOwner = role() === 'owner' +``` + +- **Recurring section** (`

Recurring plans

`): + - Table columns: Module (resolve via `moduleName(cm.moduleId)` — look the plan's `clientModuleId` up in `cms.data`), Cadence, Amount (`plan.amountPaise !== null ? inr(plan.amountPaise) : 'price book'`), Next run, Policy (`Badge` — `auto` = accent, `manual` = warn), Active, and a `Deactivate` button per active row (`deactivateRecurringPlan(plan.id).then(() => plans.reload())`). + - Owner-only "New recurring plan" form (render only when `isOwner`): a **client-module select** (from `cms.data`, label = module name + kind), a **cadence select** (`monthly`/`yearly`), an **amount (₹, optional)** input (blank → priced from the module's price book at generation; `fromRupees` when present), a **next-run date**, and a **policy select** (`auto`/`manual`). On save call `createRecurringPlan(id, { clientModuleId, cadence, nextRun, policy, ...(amount ? { amountPaise: fromRupees(amount) } : {}) })` then `plans.reload()`; show the thrown error (e.g. "No monthly price…") in a `Notice`. + +- **AMC section** (`

AMC contracts

`): + - Table columns: Coverage, Period (`periodFrom → periodTo`), Amount (`inr(amountPaise)`), Reminder days, Paid (`Badge` from `contract.paidStatus`: `paid`=ok, `unpaid`=err, `unbilled`=warn), and actions: **Generate renewal invoice** (`generateAmcRenewalInvoice(contract.id).then((doc) => nav(\`/documents/${doc.id}\`))`) shown when `paidStatus !== 'unpaid'`, plus **Deactivate**. + - Owner-only "New AMC" form: coverage, period-from, period-to, amount (₹ → `fromRupees`), renewal-reminder-days (default 30). On save `createAmc(id, {...})` then `amc.reload()`. + +- **Interactions section** (`

Interactions

`): + - "Log interaction" form (all roles): type select (from `types.data`), on-date (default `today()`), notes textarea, outcome select (blank / positive / neutral / negative), follow-up date (optional). On save `createInteraction(id, { typeCode, onDate, notes, ...(outcome ? { outcome } : {}), ...(followUpOn ? { followUpOn } : {}) })` then `interactions.reload()`. + - Timeline table (newest first): Date, Type (label via `types.data`), Notes, Outcome (`Badge`: positive=ok, neutral=warn, negative=err, none `—`), Follow-up (an inline `type="date"` input that PATCHes via `updateInteraction(i.id, { followUpOn: value || null })` — lets staff clear/reschedule a follow-up, which the next scan picks up). + +- [ ] **Step 1: Implement the three sections** in `ClientDetail.tsx`, following the existing section/sub-component style in the same file (the `AssignModuleForm`, `RowDate`, `PaymentForm` patterns). Reuse `inr`, `today`, and `moduleName` already defined at the top of the file. + +- [ ] **Step 2: Typecheck + build both web workspaces** — `cd apps/hq-web && npm run typecheck && npm run build` → clean. Root `npm run typecheck` (both apps) → clean. + +- [ ] **Step 3: Full browser verification (the HQ-2 walk).** Start the API (`cd apps/hq && npm start`), note the seeded owner password on first boot, then `cd apps/hq-web && npm run dev` and sign in. + 1. **Recurring → generation:** on a client with a subscribed module (assign one with a monthly price if needed), create a **recurring plan**, cadence monthly, next-run = **today**, policy **manual**. Trigger a scan without waiting 6h by restarting the API (`startScheduler` runs on boot) **or** temporarily run `npx tsx -e "import('./apps/hq/src/scheduler.js')..."`; the invoice appears under the client's Documents and a **Recurring invoice** row appears in the Dashboard queue as `queued`. + 2. **Manual send:** on the Dashboard queue click **Send**. With Gmail not connected, expect the amber banner-style error `gmail-not-connected`/`gmail-token-dead` and the row flips to `failed` (invoice still exists — confirm it did **not** regenerate). After `gmail-connect` on the server, **Send** succeeds and the row flips to `sent`. + 3. **Overdue:** back-date an issued unpaid invoice (or use an old one); after a scan the Dashboard **Overdue** list shows it with the outstanding amount, and an **Overdue invoice** reminder is in the queue. + 4. **AMC:** on ClientDetail add an AMC contract, **Generate renewal invoice** → opens the new invoice (₹ amount + 18% GST); the AMC Paid badge reads `unpaid`; record full payment → badge flips to `paid`. An AMC expiring within its reminder window shows an **AMC expiring** queue row after a scan. + 5. **Interactions:** log a `site_visit` with a follow-up date of today; the Dashboard **Follow-ups today** list and a **Follow-up** queue row both appear; **Dismiss** clears it. + 6. **Idempotency:** restart the API twice (two boot scans) and confirm the queue does **not** grow — no duplicate reminders, no duplicate recurring invoices. +- [ ] **Step 4: Full suite green** — root `npm test` (HQ-1's original suite + every HQ-2 test) all pass; `npm run typecheck` clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq-web/src/pages/ClientDetail.tsx +git commit -m "feat(hq-web): client 360 recurring, amc and interaction sections" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Deferred out of HQ-2 (explicitly) + +SMS pack-balance tracking (founder decision pending); AWS usage/cost pull + margin view + cross-client cost chart (HQ-3); reports — dues aging, module-wise revenue, client profitability (HQ-3); receipt PDFs on payment (trivial follow-up); per-rule auto/manual toggles for the dunning/renewal/amc rules (only recurring plans carry an `auto` policy in HQ-2 — everything else is the manual queue); WhatsApp as a second channel (templates already live in one place, `reminder-templates.ts`, so it slots in later); deploy scripts for the AWS instance. + +## Verification (whole plan) + +1. `npm test` — every HQ-2 task's suite plus HQ-1's original suite stay green. The scheduler, send, and bounce tests all run offline (injected `fetch` + `renderPdf`). +2. `npm run typecheck` — clean across root, `apps/hq`, and `apps/hq-web` (per-app typecheck is part of the root gate). +3. Determinism check: every scheduler/bounce test passes `today`/`now` explicitly; no test reads the wall clock for logic. Re-running any scan test asserts zero new rows (idempotency). +4. Browser walk (Task 12 Step 3) — the founder-visible loop works end to end: recurring invoice generated → queued → sent; overdue/renewal/AMC/follow-up surfaced on the dashboard; one-click send and dismiss; a failed send parked (never regenerated); duplicate-free across restarts. + +## Self-review against spec §3 semantics + +- **Idempotency key per (rule, due-period):** every reminder is created through `upsertReminder` → `INSERT OR IGNORE` on `UNIQUE (rule_kind, subject_id, due_period)`. Overdue uses a month bucket; renewal/amc/follow-up use the concrete due date; recurring uses the `next_run` being consumed; bounce uses the `email_log.id`. Re-runs and post-downtime catch-up create no duplicates (asserted in scheduler-scan, scheduler-recurring catch-up=3, and bounces re-poll tests). ✅ +- **At-most-once across crashes/catch-up:** recurring generation + `next_run` advance + reminder insert are one `db.transaction` (atomic); auto-send happens only after commit and only flips `queued→sent/failed`. A crash before commit rolls the whole period back; a crash after commit but mid-send leaves the reminder `queued` (invoice exists, schedule advanced) for a human to send — never an auto-retry, never a double invoice. ✅ +- **Failed send never advances the schedule:** for recurring, `next_run` advances on **generation**, not send; a failed auto-send leaves the reminder `failed` in the manual queue with `error` visible and the issued invoice intact — the UNIQUE key forbids regeneration (asserted: failed-send test keeps exactly one invoice and re-scan generates nothing). For dunning/renewal/amc sends, a failure sets the reminder `failed` and it stays in the queue for retry; nothing is marked done. ✅ +- **"sent" ≠ "delivered" / bounce:** `email_log.status='sent'` only means Gmail accepted the message; `pollBounces` later reads mailer-daemon/postmaster DSNs, flips the matching `email_log.bounced` to 1 (once — `bounced=0` guard) and raises an `email_bounced` dashboard reminder keyed on the `email_log.id`. The connect script requests `gmail.readonly` alongside `gmail.send` at first consent. ✅ +- **Manual + auto both exist:** auto = `recurring_plan.policy='auto'` (auto-generates and auto-sends); manual = the dashboard queue with one-click send for every other rule and for `manual` recurring plans. ✅ +- **Token death stays first-class:** both send paths (`sendDocumentEmail`, `sendReminderEmail`) and `pollBounces` call `markAccountDead` on `invalid_grant`, so the existing Layout Gmail banner lights up and auto-sends halt to the manual queue — consistent with HQ-1. ✅