You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/superpowers/plans/2026-07-10-hq2-reminders.md

2784 lines
149 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# HQ-2 — Reminders, Recurring, AMC, Interactions & Dashboard Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Every task is a full TDD loop: write the failing test verbatim → run it to watch it fail → write the implementation → run it to green → commit. No task depends on code a later task writes.
**Goal:** Ship HQ-2 of [docs/14-SPEC-HQ-CONSOLE.md](../../14-SPEC-HQ-CONSOLE.md) on top of the HQ-1 `apps/hq` + `apps/hq-web` codebase: recurring plans that generate invoices on schedule, a reminder engine (auto-send + manual queue, idempotent across crashes/catch-up, bounce-aware), AMC contracts with derived paid-state, an interaction/visit/call/training log with follow-ups, and a dashboard money-view that becomes the default page after login. All grounded in the real HQ-1 interfaces (`createDraft`/`issueDocument`, `sendDocumentEmail`, `getAccount`/`TokenDeadError`, `priceOn`, `writeAudit`, `formatINR`).
**Architecture:** Extend the HQ server pattern exactly as HQ-1 established it — express + better-sqlite3 behind plain-function repositories (`repos-*.ts`), pure logic reused from `@sims/domain` (`formatINR`, `fromRupees`, `uuidv7`, `fyOf`) and the HQ document engine. **New tables are added to the `SCHEMA` string in `db.ts` via `CREATE TABLE IF NOT EXISTS`** (no ALTER needed — the table simply appears on next `openDb`); **the one new column** (`email_log.bounced`) is added through the existing guarded `migrate(db)` PRAGMA/ALTER path. The scheduler is a **pure function** `runDailyScan(db, deps, today)` with an injected clock (never `Date.now()` inside logic) so tests are deterministic; it is wired into `server.ts` to run on boot and every 6h. Idempotency lives entirely in one UNIQUE constraint — `reminder(rule_kind, subject_id, due_period)` — reached via `INSERT OR IGNORE`, which is what makes catch-up after downtime safe with zero extra bookkeeping.
**Tech Stack:** unchanged from HQ-1 — TypeScript (ESM, strict), express ^4.21, better-sqlite3 ^11.7, puppeteer ^23 (PDF, injected into scheduler/reminder deps so tests never launch Chromium), vitest, React 19 + Vite 6 + react-router 7, `@sims/ui`. No new dependencies (bounce polling is `fetch`-based, same as `gmail.ts`).
## Global Constraints
Everything from the HQ-1 plan still holds; the load-bearing ones for HQ-2, plus the extensions:
- All money is **integer paise** (`Paise` from `@sims/domain`); never floats. Amounts render with `formatINR`.
- All ids are **UUIDv7** via `uuidv7()` from `@sims/domain` (ids sort by creation time — reused for `ORDER BY id`).
- **Every mutation writes an `audit_log` row** via `writeAudit(db, userId, action, entity, entityId, before?, after?)`. Scheduler-originated mutations use `userId = 'system'`.
- SQL stays **portable** (D12 guardrail): standard SQL; SQLite/Postgres-only dialect (`INSERT OR IGNORE`, `ON CONFLICT`) is commented as a portability quirk, matching `series.ts`/`seed.ts`.
- **New tables → `SCHEMA` const in `db.ts` with `CREATE TABLE IF NOT EXISTS`.** New tables need no migration entry. **New columns → guarded `migrate(db)`** (PRAGMA `table_info` check then `ALTER TABLE … ADD COLUMN`), exactly like the existing `module.quote_content` block.
- HQ prices are **GST-exclusive** (`priceIncludesTax: false`); recurring/AMC invoices flow through the same `createDraft``computeBill` path, so GST (CGST/SGST vs IGST) is computed identically to HQ-1.
- Issued invoices are **never edited or deleted**. A recurring invoice, once generated and issued, exists permanently; a failed *send* never deletes or regenerates it.
- **Scheduler determinism:** `runDailyScan(db, deps, today: string)` takes the business date as an ISO `YYYY-MM-DD` param. Wall-clock timestamps (`created_at`, `sent_at`) come from `deps.now?.() ?? new Date().toISOString()` so tests pin them.
- **Idempotency / at-most-once:** the reminder row is created once per `(rule_kind, subject_id, due_period)` via `INSERT OR IGNORE`. Recurring generation, `next_run` advance, and the reminder insert are one better-sqlite3 **transaction** (atomic); the async email send happens *after* commit and only ever transitions an already-committed reminder from `queued`→`sent`/`failed`. A crash therefore never double-generates and never auto-double-sends.
- **Failed send never advances the schedule:** for recurring, `next_run` advances on *generation* (the invoice existing is the spec's condition), not on send — a failed send parks the reminder in the manual queue with its `error` visible; the invoice still exists and is re-sent from the queue, never regenerated (the UNIQUE key forbids regeneration).
- **"sent" ≠ "delivered":** `email_log.status='sent'` means Gmail accepted the message. Delivery failures surface later via `pollBounces``email_log.bounced=1` + an `email_bounced` dashboard reminder.
- Server port **5182**. Secrets: `HQ_SECRET_KEY`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` (already used by HQ-1). Never log tokens.
- Tests: vitest, colocated under `apps/hq/test/` (already in the vitest `include`). Puppeteer is injected as `deps.renderPdf`, and `fetch` as `deps.gmail.f`, so every HQ-2 test runs offline and Chromium-free.
- **Per-app typecheck is part of the root gate:** `npm run typecheck` runs `tsc -p tsconfig.json && npm run typecheck --workspaces --if-present`. Both `apps/hq` and `apps/hq-web` must typecheck clean at every commit.
- Commit after every task. Each commit message ends with the trailer — use the two-`-m` form:
```bash
git commit -m "feat(hq): <subject>" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
## Scope (locked — do not reopen)
**In:** recurring_plan (generation on `next_run`), reminder engine (auto + manual queue, idempotent, bounce-aware), amc_contract (+ renewal invoice, derived paid), interaction + interaction_type (+ follow-ups), dashboard money-view (default route) with the manual reminder queue, bounce detection (Gmail `readonly` scope + `pollBounces`), and the routes/pages for all of it.
**Out (do not plan):** SMS pack-balance tracking (founder decision pending); AWS cost pull + cross-client chart (HQ-3); receipt PDFs. Per-rule auto/manual toggles for the *dunning* rules (overdue/renewal/amc) are intentionally not built — the only **auto** path is `recurring_plan.policy='auto'`; dunning/renewal/amc/follow-up reminders always land in the **manual queue** (this satisfies the founder's "both modes exist": auto = recurring, manual = everything else, and recurring plans carry their own `auto`/`manual` policy). State this in the recurring task, don't re-litigate it.
## File Structure (HQ-2 additions)
```
apps/hq/
src/db.ts — MODIFY: 5 new CREATE TABLE IF NOT EXISTS + email_log.bounced in migrate()
src/seed.ts — MODIFY: seed interaction_type rows, AMC module, reminder.* settings
src/repos-recurring.ts — NEW: recurring_plan CRUD
src/repos-amc.ts — NEW: amc_contract CRUD + renewal invoice + derived paid
src/repos-interactions.ts — NEW: interaction_type + interaction CRUD + follow-ups
src/reminder-templates.ts — NEW: subject+body text per rule_kind (formatINR, Indian business English)
src/repos-reminders.ts — NEW: reminder upsert/list/dismiss + sendReminder orchestration + settings helpers
src/gmail.ts — MODIFY: add sendReminderEmail (attachment-optional, no document coupling)
src/scheduler.ts — NEW: runDailyScan (pure), startScheduler (setInterval, unref)
src/bounces.ts — NEW: pollBounces + markBounce (fetch-based, injectable Fetcher)
src/repos-payments.ts — MODIFY: export outstandingPaise(db, docId)
src/repos-dashboard.ts — NEW: dashboardView aggregation
src/api.ts — MODIFY: recurring / amc / interactions / reminders / dashboard routes
src/server.ts — MODIFY: startServer(port, { scheduler }) wiring
scripts/gmail-connect.ts — MODIFY: SCOPE gains gmail.readonly (for bounce polling)
test/*.test.ts — NEW: one file per task
apps/hq-web/
src/api.ts — MODIFY: types + typed calls for recurring/amc/interactions/reminders/dashboard
src/Layout.tsx — MODIFY: nav gains Dashboard (default) + Clients
src/main.tsx — MODIFY: '/' → Dashboard, '/clients' → Clients
src/pages/Dashboard.tsx — NEW: money view + manual reminder queue
src/pages/ClientDetail.tsx — MODIFY: AMC, Interactions, Recurring sections
```
---
### Task 1: HQ-2 schema — new tables, `email_log.bounced`, seeds
**Files:**
- Modify: `apps/hq/src/db.ts` (append 5 tables to `SCHEMA`; add the `bounced` column to `migrate`), `apps/hq/src/seed.ts` (seed lookups + settings + AMC module)
- Test: `apps/hq/test/hq2-schema.test.ts`
**Interfaces:**
- Produces (schema only — no new TS exports): tables `recurring_plan`, `amc_contract`, `interaction`, `interaction_type`, `reminder`; column `email_log.bounced INTEGER NOT NULL DEFAULT 0`. `seedIfEmpty` additionally seeds the 7 `interaction_type` rows, one `module` row `code='AMC'`, and settings `reminders.overdue_days='7'`, `reminders.renewal_days='15'`.
- Note on `reminder.doc_id`: the locked column list is extended with **one** nullable column, `doc_id TEXT`, carrying the document a reminder concerns (the overdue invoice, the generated recurring invoice, an AMC renewal invoice). It is required so the manual-queue **Send** action and the recurring auto-send can attach/reference the right PDF; `subject_id` stays the logical subject (plan/module/contract/interaction/invoice). For `invoice_overdue`, `subject_id == doc_id`.
- [ ] **Step 1: Write the failing test**
```ts
// apps/hq/test/hq2-schema.test.ts
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
describe('hq2 schema', () => {
it('creates every HQ-2 table', () => {
const db = openDb(':memory:')
const names = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]).map((r) => r.name)
for (const t of ['recurring_plan', 'amc_contract', 'interaction', 'interaction_type', 'reminder'])
expect(names, `missing table ${t}`).toContain(t)
})
it('adds the bounced column to email_log', () => {
const db = openDb(':memory:')
const cols = (db.prepare(`PRAGMA table_info(email_log)`).all() as { name: string }[]).map((c) => c.name)
expect(cols).toContain('bounced')
})
it('enforces the reminder idempotency key', () => {
const db = openDb(':memory:')
const ins = db.prepare(
`INSERT OR IGNORE INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
VALUES (?, 'invoice_overdue', 's1', '2026-07', 'c1', 'queued', 'manual', '2026-07-10T00:00:00Z')`,
)
expect(ins.run('r1').changes).toBe(1)
expect(ins.run('r2').changes).toBe(0) // same (rule_kind, subject_id, due_period) → ignored
})
it('seeds interaction types, the AMC module and reminder settings', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const types = (db.prepare(`SELECT code FROM interaction_type`).all() as { code: string }[]).map((r) => r.code)
expect(types).toEqual(expect.arrayContaining(['call', 'site_visit', 'training', 'complaint']))
expect(db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get()).toMatchObject({ n: 1 })
const s = (db.prepare(`SELECT value FROM setting WHERE key='reminders.overdue_days'`).get() as { value: string } | undefined)
expect(s?.value).toBe('7')
})
})
```
- [ ] **Step 2: Run to verify FAIL**`npx vitest run apps/hq/test/hq2-schema.test.ts` → FAIL (tables/column/seeds absent).
- [ ] **Step 3: Implement.** Append to the `SCHEMA` template string in `apps/hq/src/db.ts` (before the closing backtick):
```sql
CREATE TABLE IF NOT EXISTS recurring_plan (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL,
client_module_id TEXT, -- source of the invoice line's module; required for generation in HQ-2
cadence TEXT NOT NULL CHECK (cadence IN ('monthly','yearly')),
amount_paise INTEGER, -- NULL = price from module_price_book at generation time
next_run TEXT NOT NULL,
policy TEXT NOT NULL DEFAULT 'manual' CHECK (policy IN ('auto','manual')),
active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS amc_contract (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, coverage TEXT NOT NULL DEFAULT '',
period_from TEXT NOT NULL, period_to TEXT NOT NULL, amount_paise INTEGER NOT NULL,
renewal_reminder_days INTEGER NOT NULL DEFAULT 30,
legacy_paid INTEGER, -- manual flag for imported contracts only; else paid derives from invoice_doc_id
invoice_doc_id TEXT, active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS interaction_type (
code TEXT PRIMARY KEY, label TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS interaction (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, type_code TEXT NOT NULL,
on_date TEXT NOT NULL, staff_id TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '',
outcome TEXT CHECK (outcome IN ('positive','neutral','negative')),
follow_up_on TEXT, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS reminder (
id TEXT PRIMARY KEY,
rule_kind TEXT NOT NULL CHECK (rule_kind IN
('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced')),
subject_id TEXT NOT NULL, due_period TEXT NOT NULL, client_id TEXT NOT NULL,
doc_id TEXT, -- the document this reminder concerns (overdue/recurring/amc invoice); NULL otherwise
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')),
policy_applied TEXT NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')),
error TEXT, created_at TEXT NOT NULL, sent_at TEXT,
UNIQUE (rule_kind, subject_id, due_period) -- the idempotency key: at-most-once per due-period
);
```
In `migrate(db)` add the guarded column (same shape as the existing `quote_content` block):
```ts
const emailCols = db.prepare(`PRAGMA table_info(email_log)`).all() as { name: string }[]
if (!emailCols.some((c) => c.name === 'bounced')) {
db.exec(`ALTER TABLE email_log ADD COLUMN bounced INTEGER NOT NULL DEFAULT 0`)
}
```
In `apps/hq/src/seed.ts` — import `createModule` and extend `seedIfEmpty` (after the existing GST18 block):
```ts
import { createModule } from './repos-modules'
// ...
const INTERACTION_TYPES: [string, string][] = [
['call', 'Call'], ['site_visit', 'Site visit'], ['training', 'Training'],
['courtesy_meeting', 'Courtesy meeting'], ['committee_meeting', 'Committee meeting'],
['demo', 'Demo'], ['complaint', 'Complaint'],
]
const REMINDER_SETTINGS: Record<string, string> = {
'reminders.overdue_days': '7',
'reminders.renewal_days': '15',
}
```
and inside `seedIfEmpty`, after the tax-class seed:
```ts
const typeInsert = db.prepare(
`INSERT INTO interaction_type (code, label) VALUES (?, ?) ON CONFLICT (code) DO NOTHING`,
)
let seededTypes = 0
for (const [code, label] of INTERACTION_TYPES) seededTypes += typeInsert.run(code, label).changes
if (seededTypes > 0) writeAudit(db, 'system', 'seed', 'interaction_type', '*', undefined, { count: seededTypes })
let seededReminderSettings = 0
for (const [key, value] of Object.entries(REMINDER_SETTINGS)) seededReminderSettings += insert.run(key, value).changes
if (seededReminderSettings > 0) writeAudit(db, 'system', 'seed', 'setting', 'reminders.*', undefined, REMINDER_SETTINGS)
const amc = db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get() as { n: number }
if (amc.n === 0) {
// The AMC/renewal invoice line hangs off a real module so it flows through
// createDraft → computeBill unchanged. SAC 998719 (maintenance/repair) — CA to confirm.
createModule(db, 'system', {
code: 'AMC', name: 'Annual Maintenance Contract', sac: '998719',
allowedKinds: ['yearly', 'one_time'],
})
}
```
(`insert` is the existing `INSERT … ON CONFLICT (key) DO NOTHING` setting statement already defined in `seedIfEmpty`.)
- [ ] **Step 4: Run tests**`npx vitest run apps/hq/test/hq2-schema.test.ts` → PASS. `npm run typecheck` → clean.
- [ ] **Step 5: Commit**
```bash
git add apps/hq/src/db.ts apps/hq/src/seed.ts apps/hq/test/hq2-schema.test.ts
git commit -m "feat(hq): hq-2 schema — recurring/amc/interaction/reminder tables + bounce column + seeds" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 2: Recurring plans — repo + owner-gated routes
**Files:**
- Create: `apps/hq/src/repos-recurring.ts`
- Modify: `apps/hq/src/api.ts`
- Test: `apps/hq/test/recurring.test.ts`
**Interfaces:**
- Produces:
- `RecurringPlan = { id: string; clientId: string; clientModuleId: string | null; cadence: 'monthly'|'yearly'; amountPaise: number | null; nextRun: string; policy: 'auto'|'manual'; active: boolean }`
- `createRecurringPlan(db, userId, input: { clientId: string; clientModuleId: string; cadence: 'monthly'|'yearly'; amountPaise?: number; nextRun: string; policy?: 'auto'|'manual' }): RecurringPlan` — validates the client and client_module exist and belong together; validates that a price is resolvable (either `amountPaise` given, or the client_module's module has a `priceOn` for the cadence's kind on `nextRun`). **`clientModuleId` is required in HQ-2** (it is the module for the invoice line; the schema keeps the column nullable for future client-level plans).
- `getRecurringPlan(db, id): RecurringPlan | null`; `listRecurringPlans(db, clientId?): RecurringPlan[]`.
- `updateRecurringPlan(db, userId, id, patch: { cadence?; amountPaise?: number | null; nextRun?; policy?; active? }): RecurringPlan`.
- `deactivateRecurringPlan(db, userId, id): RecurringPlan` (sets `active=0`).
- `CADENCE_KIND: Record<'monthly'|'yearly', Kind> = { monthly: 'monthly', yearly: 'yearly' }` (exported — reused by the scheduler).
- Routes (create/update/deactivate are **owner-gated** — money): `GET /api/recurring?clientId=`, `POST /api/clients/:id/recurring` (owner), `PATCH /api/recurring/:id` (owner), `POST /api/recurring/:id/deactivate` (owner).
- [ ] **Step 1: Write the failing test**
```ts
// apps/hq/test/recurring.test.ts
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { createClient } from '../src/repos-clients'
import { assignModule, createModule, setPrice } from '../src/repos-modules'
import {
createRecurringPlan, listRecurringPlans, updateRecurringPlan, deactivateRecurringPlan,
} from '../src/repos-recurring'
function setup() {
const db = openDb(':memory:')
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud Hosting', allowedKinds: ['monthly', 'yearly'] })
setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-04-01' })
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })
return { db, c, m, cm }
}
describe('recurring plans', () => {
it('creates a plan, lists it, updates and deactivates with audit', () => {
const { db, c, cm } = setup()
const p = createRecurringPlan(db, 'u1', {
clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', policy: 'auto',
})
expect(p.policy).toBe('auto')
expect(p.amountPaise).toBeNull() // price resolves from the module at generation
expect(listRecurringPlans(db, c.id)).toHaveLength(1)
const up = updateRecurringPlan(db, 'u1', p.id, { amountPaise: 2_500_00 })
expect(up.amountPaise).toBe(2_500_00)
const off = deactivateRecurringPlan(db, 'u1', p.id)
expect(off.active).toBe(false)
const audits = db.prepare(`SELECT action FROM audit_log WHERE entity='recurring_plan'`).all()
expect(audits.length).toBe(3) // create + update + deactivate
})
it('rejects a plan whose module has no price and no explicit amount', () => {
const db = openDb(':memory:')
const c = createClient(db, 'u1', { name: 'X', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] })
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
expect(() => createRecurringPlan(db, 'u1', {
clientId: c.id, clientModuleId: cm.id, cadence: 'yearly', nextRun: '2026-08-01',
})).toThrow(/price/i)
})
it('rejects a client_module that belongs to another client', () => {
const { db, cm } = setup()
const other = createClient(db, 'u1', { name: 'Other', stateCode: '32' })
expect(() => createRecurringPlan(db, 'u1', {
clientId: other.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', amountPaise: 100_00,
})).toThrow(/client/i)
})
})
```
- [ ] **Step 2: Run to verify FAIL**`npx vitest run apps/hq/test/recurring.test.ts` → FAIL (module not found).
- [ ] **Step 3: Implement `apps/hq/src/repos-recurring.ts`**
```ts
import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit'
import type { DB } from './db'
import { getClient } from './repos-clients'
import { getClientModule, priceOn, type Kind } from './repos-modules'
/** Recurring billing plans — plain functions over the handle (D12 portable-repo pattern). */
export type Cadence = 'monthly' | 'yearly'
export const CADENCE_KIND: Record<Cadence, Kind> = { monthly: 'monthly', yearly: 'yearly' }
export interface RecurringPlan {
id: string; clientId: string; clientModuleId: string | null; cadence: Cadence
amountPaise: number | null; nextRun: string; policy: 'auto' | 'manual'; active: boolean
}
interface RecurringRow {
id: string; client_id: string; client_module_id: string | null; cadence: string
amount_paise: number | null; next_run: string; policy: string; active: number
}
function toPlan(r: RecurringRow): RecurringPlan {
return {
id: r.id, clientId: r.client_id, clientModuleId: r.client_module_id,
cadence: r.cadence as Cadence, amountPaise: r.amount_paise, nextRun: r.next_run,
policy: r.policy as 'auto' | 'manual', active: r.active === 1,
}
}
export function getRecurringPlan(db: DB, id: string): RecurringPlan | null {
const row = db.prepare(`SELECT * FROM recurring_plan WHERE id=?`).get(id) as RecurringRow | undefined
return row === undefined ? null : toPlan(row)
}
export function listRecurringPlans(db: DB, clientId?: string): RecurringPlan[] {
const rows = clientId === undefined
? db.prepare(`SELECT * FROM recurring_plan ORDER BY id DESC`).all() as RecurringRow[]
: db.prepare(`SELECT * FROM recurring_plan WHERE client_id=? ORDER BY id DESC`).all(clientId) as RecurringRow[]
return rows.map(toPlan)
}
export interface CreateRecurringInput {
clientId: string; clientModuleId: string; cadence: Cadence
amountPaise?: number; nextRun: string; policy?: 'auto' | 'manual'
}
export function createRecurringPlan(db: DB, userId: string, input: CreateRecurringInput): RecurringPlan {
if (getClient(db, input.clientId) === null) throw new Error('Client not found')
const cm = getClientModule(db, input.clientModuleId)
if (cm === null) throw new Error('Client module not found')
if (cm.clientId !== input.clientId) throw new Error('Client module belongs to another client')
if (input.amountPaise !== undefined && (!Number.isInteger(input.amountPaise) || input.amountPaise < 0)) {
throw new Error('amountPaise must be a non-negative integer (paise)')
}
// A plan must be priceable at generation time: explicit amount, or a resolvable module price.
if (input.amountPaise === undefined) {
const kind = CADENCE_KIND[input.cadence]
if (priceOn(db, cm.moduleId, kind, cm.edition, input.nextRun) === null) {
throw new Error(`No ${kind} price for the module on ${input.nextRun}; set an amount or add a price`)
}
}
const id = uuidv7()
db.prepare(
`INSERT INTO recurring_plan (id, client_id, client_module_id, cadence, amount_paise, next_run, policy, active)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
).run(
id, input.clientId, input.clientModuleId, input.cadence,
input.amountPaise ?? null, input.nextRun, input.policy ?? 'manual',
)
const plan = getRecurringPlan(db, id)!
writeAudit(db, userId, 'create', 'recurring_plan', id, undefined, plan)
return plan
}
export interface RecurringPatch {
cadence?: Cadence; amountPaise?: number | null; nextRun?: string
policy?: 'auto' | 'manual'; active?: boolean
}
export function updateRecurringPlan(db: DB, userId: string, id: string, patch: RecurringPatch): RecurringPlan {
const before = getRecurringPlan(db, id)
if (before === null) throw new Error('Recurring plan not found')
const sets: string[] = []
const args: unknown[] = []
if (patch.cadence !== undefined) { sets.push('cadence=?'); args.push(patch.cadence) }
if (patch.amountPaise !== undefined) { sets.push('amount_paise=?'); args.push(patch.amountPaise) }
if (patch.nextRun !== undefined) { sets.push('next_run=?'); args.push(patch.nextRun) }
if (patch.policy !== undefined) { sets.push('policy=?'); args.push(patch.policy) }
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (sets.length > 0) {
args.push(id)
db.prepare(`UPDATE recurring_plan SET ${sets.join(', ')} WHERE id=?`).run(...args)
}
const after = getRecurringPlan(db, id)!
writeAudit(db, userId, 'update', 'recurring_plan', id, before, after)
return after
}
export function deactivateRecurringPlan(db: DB, userId: string, id: string): RecurringPlan {
return updateRecurringPlan(db, userId, id, { active: false })
}
```
Wire routes in `apps/hq/src/api.ts` — add the import and the block (owner gate on writes, matching the modules block):
```ts
import {
createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans,
updateRecurringPlan, type CreateRecurringInput, type RecurringPatch,
} from './repos-recurring'
```
```ts
// ---------- recurring plans ----------
r.get('/recurring', requireAuth, (req, res) => {
const clientId = typeof req.query['clientId'] === 'string' ? req.query['clientId'] : undefined
res.json({ ok: true, plans: listRecurringPlans(db, clientId) })
})
r.post('/clients/:id/recurring', requireAuth, requireOwner, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
try {
const plan = createRecurringPlan(db, staffId(res), {
...(req.body as Omit<CreateRecurringInput, 'clientId'>), clientId: id,
})
res.json({ ok: true, plan })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.patch('/recurring/:id', requireAuth, requireOwner, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return }
try {
res.json({ ok: true, plan: updateRecurringPlan(db, staffId(res), id, req.body as RecurringPatch) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.post('/recurring/:id/deactivate', requireAuth, requireOwner, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return }
res.json({ ok: true, plan: deactivateRecurringPlan(db, staffId(res), id) })
})
```
- [ ] **Step 4: Run tests**`npx vitest run apps/hq/test/recurring.test.ts` → PASS. `npm run typecheck` → clean.
- [ ] **Step 5: Commit**
```bash
git add apps/hq/src/repos-recurring.ts apps/hq/src/api.ts apps/hq/test/recurring.test.ts
git commit -m "feat(hq): recurring plan repo and owner-gated routes" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 3: AMC contracts — repo, renewal invoice, derived paid state, routes
**Files:**
- Create: `apps/hq/src/repos-amc.ts`
- Modify: `apps/hq/src/api.ts`
- Test: `apps/hq/test/amc.test.ts`
**Interfaces:**
- Consumes: `createDraft`, `issueDocument`, `getDocument`; `outstandingPaise` (added in Task 7 — until then AMC paid-state uses the local `outstandingOf` helper defined here that mirrors `repos-payments`; **when Task 7 lands, switch `amcPaidStatus` to import `outstandingPaise`**). To keep Task 3 self-contained, this task defines its own tiny outstanding read; Task 7 does not require editing Task 3.
- Produces:
- `AmcContract = { id: string; clientId: string; coverage: string; periodFrom: string; periodTo: string; amountPaise: number; renewalReminderDays: number; legacyPaid: boolean | null; invoiceDocId: string | null; active: boolean }`
- `createAmc(db, userId, input: { clientId; coverage?; periodFrom; periodTo; amountPaise; renewalReminderDays?; legacyPaid?: boolean }): AmcContract`
- `getAmc(db, id): AmcContract | null`; `listAmc(db, clientId): AmcContract[]`
- `updateAmc(db, userId, id, patch): AmcContract`; `deactivateAmc(db, userId, id): AmcContract`
- `generateAmcRenewalInvoice(db, userId, amcId): Doc` — builds one INVOICE line via the seeded `AMC` module (`description = coverage`, `qty 1`, `kind 'yearly'`, `unitPricePaise = amc.amountPaise`), `createDraft``issueDocument`, then sets `amc_contract.invoice_doc_id`; throws if an invoice is already linked and unpaid.
- `amcPaidStatus(db, amc): 'paid' | 'unpaid' | 'unbilled'``legacyPaid === true``'paid'`; else no `invoiceDocId``'unbilled'`; else outstanding on the linked invoice `<= 0``'paid'` else `'unpaid'`.
- Routes: `GET /api/clients/:id/amc`, `POST /api/clients/:id/amc` (owner), `PATCH /api/amc/:id` (owner), `POST /api/amc/:id/deactivate` (owner), `POST /api/amc/:id/renewal-invoice` (owner).
- [ ] **Step 1: Write the failing test**
```ts
// apps/hq/test/amc.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 { recordPayment } from '../src/repos-payments'
import {
createAmc, listAmc, generateAmcRenewalInvoice, amcPaidStatus, getAmc,
} from '../src/repos-amc'
function setup() {
const db = openDb(':memory:'); seedIfEmpty(db) // seeds company.state_code, GST18, AMC module
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
return { db, c }
}
describe('amc contracts', () => {
it('creates, generates a renewal invoice, and derives paid from settlement', () => {
const { db, c } = setup()
const amc = createAmc(db, 'u1', {
clientId: c.id, coverage: 'On-site support', periodFrom: '2026-04-01', periodTo: '2027-03-31',
amountPaise: 20_000_00,
})
expect(listAmc(db, c.id)).toHaveLength(1)
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unbilled')
const inv = generateAmcRenewalInvoice(db, 'u1', amc.id)
expect(inv.docType).toBe('INVOICE')
expect(inv.payablePaise).toBe(23_600_00) // 20,000 + 18% GST intra-state
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unpaid')
recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 23_600_00 })
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('paid')
})
it('honours the legacy_paid flag for imported contracts without an invoice', () => {
const { db, c } = setup()
const amc = createAmc(db, 'u1', {
clientId: c.id, periodFrom: '2025-04-01', periodTo: '2026-03-31', amountPaise: 10_000_00, legacyPaid: true,
})
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('paid')
})
})
```
- [ ] **Step 2: Run to verify FAIL**`npx vitest run apps/hq/test/amc.test.ts` → FAIL.
- [ ] **Step 3: Implement `apps/hq/src/repos-amc.ts`**
```ts
import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit'
import type { DB } from './db'
import { getClient } from './repos-clients'
import { getModule } from './repos-modules'
import { createDraft, getDocument, issueDocument, type Doc } from './repos-documents'
/** AMC contracts — plain functions over the handle (D12 pattern). Paid state is
* derived from the linked invoice's settlement; legacy_paid is a manual flag for
* imported contracts only, so there is one source of truth and no drift. */
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
}
interface AmcRow {
id: string; client_id: string; coverage: string; period_from: string; period_to: string
amount_paise: number; renewal_reminder_days: number; legacy_paid: number | null
invoice_doc_id: string | null; active: number
}
function toAmc(r: AmcRow): AmcContract {
return {
id: r.id, clientId: r.client_id, coverage: r.coverage,
periodFrom: r.period_from, periodTo: r.period_to, amountPaise: r.amount_paise,
renewalReminderDays: r.renewal_reminder_days,
legacyPaid: r.legacy_paid === null ? null : r.legacy_paid === 1,
invoiceDocId: r.invoice_doc_id, active: r.active === 1,
}
}
export function getAmc(db: DB, id: string): AmcContract | null {
const row = db.prepare(`SELECT * FROM amc_contract WHERE id=?`).get(id) as AmcRow | undefined
return row === undefined ? null : toAmc(row)
}
export function listAmc(db: DB, clientId: string): AmcContract[] {
const rows = db.prepare(`SELECT * FROM amc_contract WHERE client_id=? ORDER BY id DESC`).all(clientId) as AmcRow[]
return rows.map(toAmc)
}
export interface CreateAmcInput {
clientId: string; coverage?: string; periodFrom: string; periodTo: string
amountPaise: number; renewalReminderDays?: number; legacyPaid?: boolean
}
export function createAmc(db: DB, userId: string, input: CreateAmcInput): AmcContract {
if (getClient(db, input.clientId) === null) throw new Error('Client not found')
if (!Number.isInteger(input.amountPaise) || input.amountPaise < 0) {
throw new Error('amountPaise must be a non-negative integer (paise)')
}
const id = uuidv7()
db.prepare(
`INSERT INTO amc_contract (id, client_id, coverage, period_from, period_to, amount_paise,
renewal_reminder_days, legacy_paid, invoice_doc_id, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 1)`,
).run(
id, input.clientId, input.coverage ?? '', input.periodFrom, input.periodTo, input.amountPaise,
input.renewalReminderDays ?? 30, input.legacyPaid === undefined ? null : input.legacyPaid ? 1 : 0,
)
const amc = getAmc(db, id)!
writeAudit(db, userId, 'create', 'amc_contract', id, undefined, amc)
return amc
}
export interface AmcPatch {
coverage?: string; periodFrom?: string; periodTo?: string; amountPaise?: number
renewalReminderDays?: number; legacyPaid?: boolean | null; active?: boolean
}
export function updateAmc(db: DB, userId: string, id: string, patch: AmcPatch): AmcContract {
const before = getAmc(db, id)
if (before === null) throw new Error('AMC contract not found')
const sets: string[] = []
const args: unknown[] = []
if (patch.coverage !== undefined) { sets.push('coverage=?'); args.push(patch.coverage) }
if (patch.periodFrom !== undefined) { sets.push('period_from=?'); args.push(patch.periodFrom) }
if (patch.periodTo !== undefined) { sets.push('period_to=?'); args.push(patch.periodTo) }
if (patch.amountPaise !== undefined) { sets.push('amount_paise=?'); args.push(patch.amountPaise) }
if (patch.renewalReminderDays !== undefined) { sets.push('renewal_reminder_days=?'); args.push(patch.renewalReminderDays) }
if (patch.legacyPaid !== undefined) { sets.push('legacy_paid=?'); args.push(patch.legacyPaid === null ? null : patch.legacyPaid ? 1 : 0) }
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (sets.length > 0) {
args.push(id)
db.prepare(`UPDATE amc_contract SET ${sets.join(', ')} WHERE id=?`).run(...args)
}
const after = getAmc(db, id)!
writeAudit(db, userId, 'update', 'amc_contract', id, before, after)
return after
}
export function deactivateAmc(db: DB, userId: string, id: string): AmcContract {
return updateAmc(db, userId, id, { active: false })
}
/** Renewal invoice for an AMC: one line on the seeded AMC module, priced at the
* contract amount, issued and linked back via invoice_doc_id. */
export function generateAmcRenewalInvoice(db: DB, userId: string, amcId: string): Doc {
const amc = getAmc(db, amcId)
if (amc === null) throw new Error('AMC contract not found')
if (amc.invoiceDocId !== null) {
const existing = getDocument(db, amc.invoiceDocId)
if (existing !== null && existing.status !== 'cancelled' && outstandingOf(db, existing.id) > 0) {
throw new Error(`AMC already has an unpaid renewal invoice ${existing.docNo}`)
}
}
const amcModule = db.prepare(`SELECT id FROM module WHERE code='AMC'`).get() as { id: string } | undefined
if (amcModule === undefined) throw new Error('AMC module missing — run the seed')
return db.transaction(() => {
const draft = createDraft(db, userId, {
docType: 'INVOICE', clientId: amc.clientId,
lines: [{
moduleId: amcModule.id, description: amc.coverage !== '' ? amc.coverage : 'Annual Maintenance',
qty: 1, kind: 'yearly', unitPricePaise: amc.amountPaise,
}],
})
const inv = issueDocument(db, userId, draft.id)
db.prepare(`UPDATE amc_contract SET invoice_doc_id=? WHERE id=?`).run(inv.id, amcId)
writeAudit(db, userId, 'update', 'amc_contract', amcId, { invoiceDocId: amc.invoiceDocId }, { invoiceDocId: inv.id })
return inv
})()
}
/** Local outstanding read (payable allocations non-cancelled credit notes) —
* mirrors repos-payments.outstandingOf. Task 7 exports outstandingPaise; this can
* switch to it then, but does not depend on Task 7 to compile. */
function outstandingOf(db: DB, docId: string): number {
const doc = getDocument(db, docId)
if (doc === null) return 0
const alloc = (db.prepare(
`SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`,
).get(docId) as { total: number }).total
const credited = (db.prepare(
`SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document
WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`,
).get(docId) as { total: number }).total
return doc.payablePaise - alloc - credited
}
export function amcPaidStatus(db: DB, amc: AmcContract): 'paid' | 'unpaid' | 'unbilled' {
if (amc.legacyPaid === true) return 'paid'
if (amc.invoiceDocId === null) return 'unbilled'
return outstandingOf(db, amc.invoiceDocId) <= 0 ? 'paid' : 'unpaid'
}
```
Wire routes in `api.ts` (import + block; writes owner-gated):
```ts
import {
amcPaidStatus, createAmc, deactivateAmc, generateAmcRenewalInvoice, getAmc, listAmc,
updateAmc, type AmcPatch, type CreateAmcInput,
} from './repos-amc'
```
```ts
// ---------- amc ----------
r.get('/clients/:id/amc', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
const contracts = listAmc(db, id).map((a) => ({ ...a, paidStatus: amcPaidStatus(db, a) }))
res.json({ ok: true, contracts })
})
r.post('/clients/:id/amc', requireAuth, requireOwner, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
try {
const amc = createAmc(db, staffId(res), { ...(req.body as Omit<CreateAmcInput, 'clientId'>), clientId: id })
res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.patch('/amc/:id', requireAuth, requireOwner, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
try {
const amc = updateAmc(db, staffId(res), id, req.body as AmcPatch)
res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.post('/amc/:id/deactivate', requireAuth, requireOwner, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
const amc = deactivateAmc(db, staffId(res), id)
res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } })
})
r.post('/amc/:id/renewal-invoice', requireAuth, requireOwner, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
try {
res.json({ ok: true, document: generateAmcRenewalInvoice(db, staffId(res), id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
```
- [ ] **Step 4: Run tests**`npx vitest run apps/hq/test/amc.test.ts` → PASS (verify the golden `23_600_00` paisa). `npm run typecheck` → clean.
- [ ] **Step 5: Commit**
```bash
git add apps/hq/src/repos-amc.ts apps/hq/src/api.ts apps/hq/test/amc.test.ts
git commit -m "feat(hq): amc contracts with renewal invoice and derived paid state" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 4: Interactions — types, log, follow-ups, routes
**Files:**
- Create: `apps/hq/src/repos-interactions.ts`
- Modify: `apps/hq/src/api.ts`
- Test: `apps/hq/test/interactions.test.ts`
**Interfaces:**
- Produces:
- `InteractionType = { code: string; label: string }`; `listInteractionTypes(db): InteractionType[]`; `createInteractionType(db, userId, { code, label }): InteractionType` (owner-only at the route; the lookup is user-extensible).
- `Interaction = { id: string; clientId: string; typeCode: string; onDate: string; staffId: string; notes: string; outcome: 'positive'|'neutral'|'negative' | null; followUpOn: string | null; createdAt: string }`
- `createInteraction(db, userId, input: { clientId; typeCode; onDate; notes?; outcome?; followUpOn? }): Interaction` — validates the client exists and `typeCode` is a known type; `staffId` is the acting user.
- `listInteractions(db, clientId): Interaction[]` (newest first — the timeline).
- `updateInteraction(db, userId, id, patch: { notes?; outcome?; followUpOn? }): Interaction`.
- `listOpenFollowUps(db, onOrBefore: string): (Interaction & { clientName: string })[]` — interactions whose `follow_up_on <= onOrBefore`, newest due first (feeds the dashboard + the `follow_up` reminder rule).
- Routes: `GET /api/interaction-types`, `POST /api/interaction-types` (owner), `GET /api/clients/:id/interactions`, `POST /api/clients/:id/interactions`, `PATCH /api/interactions/:id`.
- [ ] **Step 1: Write the failing test**
```ts
// apps/hq/test/interactions.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 {
createInteraction, listInteractions, listInteractionTypes, listOpenFollowUps, updateInteraction,
} from '../src/repos-interactions'
describe('interactions', () => {
it('logs a typed interaction, updates outcome, and surfaces due follow-ups', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
expect(listInteractionTypes(db).length).toBeGreaterThanOrEqual(7)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const i = createInteraction(db, 'u1', {
clientId: c.id, typeCode: 'site_visit', onDate: '2026-07-01',
notes: 'Installed POS; owner wants training', followUpOn: '2026-07-08',
})
expect(listInteractions(db, c.id)).toHaveLength(1)
const up = updateInteraction(db, 'u1', i.id, { outcome: 'positive' })
expect(up.outcome).toBe('positive')
expect(listOpenFollowUps(db, '2026-07-10').map((f) => f.id)).toContain(i.id)
expect(listOpenFollowUps(db, '2026-07-05')).toHaveLength(0) // not yet due
})
it('rejects an unknown interaction type', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'X', stateCode: '32' })
expect(() => createInteraction(db, 'u1', { clientId: c.id, typeCode: 'telepathy', onDate: '2026-07-01' }))
.toThrow(/type/i)
})
})
```
- [ ] **Step 2: Run to verify FAIL**`npx vitest run apps/hq/test/interactions.test.ts` → FAIL.
- [ ] **Step 3: Implement `apps/hq/src/repos-interactions.ts`**
```ts
import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit'
import type { DB } from './db'
import { getClient } from './repos-clients'
/** Interaction log — the "memories into the system" table (D12 plain-repo pattern). */
export interface InteractionType { code: string; label: string }
export function listInteractionTypes(db: DB): InteractionType[] {
return db.prepare(`SELECT code, label FROM interaction_type ORDER BY label`).all() as InteractionType[]
}
export function createInteractionType(db: DB, userId: string, input: { code: string; label: string }): InteractionType {
if (input.code.trim() === '' || input.label.trim() === '') throw new Error('code and label are required')
db.prepare(`INSERT INTO interaction_type (code, label) VALUES (?, ?)`).run(input.code.trim(), input.label.trim())
writeAudit(db, userId, 'create', 'interaction_type', input.code, undefined, input)
return { code: input.code.trim(), label: input.label.trim() }
}
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
}
interface InteractionRow {
id: string; client_id: string; type_code: string; on_date: string; staff_id: string
notes: string; outcome: string | null; follow_up_on: string | null; created_at: string
}
function toInteraction(r: InteractionRow): Interaction {
return {
id: r.id, clientId: r.client_id, typeCode: r.type_code, onDate: r.on_date, staffId: r.staff_id,
notes: r.notes, outcome: r.outcome as Outcome | null, followUpOn: r.follow_up_on, createdAt: r.created_at,
}
}
export function getInteraction(db: DB, id: string): Interaction | null {
const row = db.prepare(`SELECT * FROM interaction WHERE id=?`).get(id) as InteractionRow | undefined
return row === undefined ? null : toInteraction(row)
}
export function listInteractions(db: DB, clientId: string): Interaction[] {
const rows = db.prepare(
`SELECT * FROM interaction WHERE client_id=? ORDER BY on_date DESC, id DESC`,
).all(clientId) as InteractionRow[]
return rows.map(toInteraction)
}
export interface CreateInteractionInput {
clientId: string; typeCode: string; onDate: string
notes?: string; outcome?: Outcome; followUpOn?: string | null
}
const OUTCOMES: Outcome[] = ['positive', 'neutral', 'negative']
export function createInteraction(db: DB, userId: string, input: CreateInteractionInput): Interaction {
if (getClient(db, input.clientId) === null) throw new Error('Client not found')
const type = db.prepare(`SELECT code FROM interaction_type WHERE code=?`).get(input.typeCode)
if (type === undefined) throw new Error(`Unknown interaction type: ${input.typeCode}`)
if (input.outcome !== undefined && !OUTCOMES.includes(input.outcome)) throw new Error(`Unknown outcome: ${input.outcome}`)
const id = uuidv7()
db.prepare(
`INSERT INTO interaction (id, client_id, type_code, on_date, staff_id, notes, outcome, follow_up_on, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id, input.clientId, input.typeCode, input.onDate, userId, input.notes ?? '',
input.outcome ?? null, input.followUpOn ?? null, new Date().toISOString(),
)
const interaction = getInteraction(db, id)!
writeAudit(db, userId, 'create', 'interaction', id, undefined, interaction)
return interaction
}
export interface InteractionPatch { notes?: string; outcome?: Outcome | null; followUpOn?: string | null }
export function updateInteraction(db: DB, userId: string, id: string, patch: InteractionPatch): Interaction {
const before = getInteraction(db, id)
if (before === null) throw new Error('Interaction not found')
if (patch.outcome !== undefined && patch.outcome !== null && !OUTCOMES.includes(patch.outcome)) {
throw new Error(`Unknown outcome: ${patch.outcome}`)
}
const sets: string[] = []
const args: unknown[] = []
if (patch.notes !== undefined) { sets.push('notes=?'); args.push(patch.notes) }
if (patch.outcome !== undefined) { sets.push('outcome=?'); args.push(patch.outcome) }
if (patch.followUpOn !== undefined) { sets.push('follow_up_on=?'); args.push(patch.followUpOn) }
if (sets.length > 0) {
args.push(id)
db.prepare(`UPDATE interaction SET ${sets.join(', ')} WHERE id=?`).run(...args)
}
const after = getInteraction(db, id)!
writeAudit(db, userId, 'update', 'interaction', id, before, after)
return after
}
/** Interactions with a follow-up date on or before the given date — the follow-ups queue. */
export function listOpenFollowUps(db: DB, onOrBefore: string): (Interaction & { clientName: string })[] {
const rows = db.prepare(
`SELECT i.*, c.name AS client_name FROM interaction i JOIN client c ON c.id = i.client_id
WHERE i.follow_up_on IS NOT NULL AND i.follow_up_on <= ?
ORDER BY i.follow_up_on DESC, i.id DESC`,
).all(onOrBefore) as (InteractionRow & { client_name: string })[]
return rows.map((r) => ({ ...toInteraction(r), clientName: r.client_name }))
}
```
Wire routes in `api.ts` (import + block; type-create owner-gated, logging is staff-allowed):
```ts
import {
createInteraction, createInteractionType, getInteraction, listInteractions,
listInteractionTypes, updateInteraction, type CreateInteractionInput, type InteractionPatch,
} from './repos-interactions'
```
```ts
// ---------- interactions ----------
r.get('/interaction-types', requireAuth, (_req, res) => {
res.json({ ok: true, types: listInteractionTypes(db) })
})
r.post('/interaction-types', requireAuth, requireOwner, (req, res) => {
try {
const type = createInteractionType(db, staffId(res), req.body as { code: string; label: string })
res.json({ ok: true, type })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.get('/clients/:id/interactions', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
res.json({ ok: true, interactions: listInteractions(db, id) })
})
r.post('/clients/:id/interactions', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
try {
const interaction = createInteraction(db, staffId(res), {
...(req.body as Omit<CreateInteractionInput, 'clientId'>), clientId: id,
})
res.json({ ok: true, interaction })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.patch('/interactions/:id', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getInteraction(db, id) === null) { res.status(404).json({ ok: false, error: 'Interaction not found' }); return }
try {
res.json({ ok: true, interaction: updateInteraction(db, staffId(res), id, req.body as InteractionPatch) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
```
- [ ] **Step 4: Run tests**`npx vitest run apps/hq/test/interactions.test.ts` → PASS. `npm run typecheck` → clean.
- [ ] **Step 5: Commit**
```bash
git add apps/hq/src/repos-interactions.ts apps/hq/src/api.ts apps/hq/test/interactions.test.ts
git commit -m "feat(hq): interaction log with types, follow-ups and routes" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### 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: 'SiMS',
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 <noreply@anthropic.com>"
```
---
### 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<SendResult>` — 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<Buffer>; company: () => Record<string, string>; now?: () => string }`
- `sendReminder(db, deps, reminderId, userId): Promise<SendResult>` — 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': 'SiMS' })
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@sims.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<SendResult> {
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<Buffer>
company: () => Record<string, string>
now?: () => string
}
export async function sendReminder(
db: DB, deps: SendReminderDeps, reminderId: string, userId: string,
): Promise<SendResult> {
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 <noreply@anthropic.com>"
```
---
### 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<string, number>; autoSent: number; autoFailed: number }`
- `runDailyScan(db, deps, today: string): Promise<ScanResult>`**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': 'SiMS' }),
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<string, number>
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<string, number>, kind: string): void {
created[kind] = (created[kind] ?? 0) + 1
}
export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promise<ScanResult> {
const now = deps.now?.() ?? new Date().toISOString()
const created: Record<string, number> = {}
// --- 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 <noreply@anthropic.com>"
```
---
### 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': 'SiMS' }), now: () => '2026-07-10T00:00:00Z' }
}
function world() {
const db = openDb(':memory:'); seedIfEmpty(db)
saveAccount(db, 'us@sims.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, number>): string[] {
const plans = db.prepare(
`SELECT id FROM recurring_plan WHERE active=1 AND next_run <= ?`,
).all(today) as { id: string }[]
const autoQueue: string[] = []
for (const { id } of plans) {
let guard = 0
for (;;) {
if (guard++ > 240) break // safety cap: ~20 years of monthly against a corrupt next_run
const claim = db.transaction(() => claimAndGenerate(db, id, today, now))()
if (claim === 'done') break
if (claim.created) {
bump(created, 'recurring_generated')
if (claim.auto) autoQueue.push(claim.reminderId)
}
}
}
return autoQueue
}
```
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 <noreply@anthropic.com>"
```
---
### 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@sims.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<string>()
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 <noreply@anthropic.com>"
```
---
### 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': 'SiMS' }), 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<string, string> =>
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 <noreply@anthropic.com>"
```
---
### 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<DashboardView> =>
apiFetch<{ view: DashboardView }>('/dashboard').then((r) => r.view)
export const getReminders = (status?: ReminderStatus): Promise<Reminder[]> =>
apiFetch<{ reminders: Reminder[] }>(`/reminders${status !== undefined ? `?status=${status}` : ''}`).then((r) => r.reminders)
export const sendReminder = (id: string): Promise<Reminder> =>
apiFetch<{ reminder: Reminder }>(`/reminders/${id}/send`, { method: 'POST', body: '{}' }).then((r) => r.reminder)
export const dismissReminder = (id: string): Promise<Reminder> =>
apiFetch<{ reminder: Reminder }>(`/reminders/${id}/dismiss`, { method: 'POST', body: '{}' }).then((r) => r.reminder)
export const getRecurringPlans = (clientId: string): Promise<RecurringPlan[]> =>
apiFetch<{ plans: RecurringPlan[] }>(`/recurring?clientId=${clientId}`).then((r) => r.plans)
export const createRecurringPlan = (clientId: string, body: Record<string, unknown>): Promise<RecurringPlan> =>
apiFetch<{ plan: RecurringPlan }>(`/clients/${clientId}/recurring`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.plan)
export const updateRecurringPlan = (id: string, body: Record<string, unknown>): Promise<RecurringPlan> =>
apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.plan)
export const deactivateRecurringPlan = (id: string): Promise<RecurringPlan> =>
apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.plan)
export const getAmc = (clientId: string): Promise<AmcContract[]> =>
apiFetch<{ contracts: AmcContract[] }>(`/clients/${clientId}/amc`).then((r) => r.contracts)
export const createAmc = (clientId: string, body: Record<string, unknown>): Promise<AmcContract> =>
apiFetch<{ contract: AmcContract }>(`/clients/${clientId}/amc`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.contract)
export const updateAmc = (id: string, body: Record<string, unknown>): Promise<AmcContract> =>
apiFetch<{ contract: AmcContract }>(`/amc/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.contract)
export const deactivateAmc = (id: string): Promise<AmcContract> =>
apiFetch<{ contract: AmcContract }>(`/amc/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.contract)
export const generateAmcRenewalInvoice = (id: string): Promise<Doc> =>
apiFetch<{ document: Doc }>(`/amc/${id}/renewal-invoice`, { method: 'POST', body: '{}' }).then((r) => r.document)
export const getInteractionTypes = (): Promise<InteractionType[]> =>
apiFetch<{ types: InteractionType[] }>('/interaction-types').then((r) => r.types)
export const getInteractions = (clientId: string): Promise<Interaction[]> =>
apiFetch<{ interactions: Interaction[] }>(`/clients/${clientId}/interactions`).then((r) => r.interactions)
export const createInteraction = (clientId: string, body: Record<string, unknown>): Promise<Interaction> =>
apiFetch<{ interaction: Interaction }>(`/clients/${clientId}/interactions`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.interaction)
export const updateInteraction = (id: string, body: Record<string, unknown>): Promise<Interaction> =>
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<string, string> = {
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 (
<div className="wf-page">
<PageHeader title="Dashboard" desc="Todays money — dues, renewals, follow-ups, and the reminder queue." />
{dash.error !== undefined && <Notice tone="err">{dash.error}</Notice>}
{err !== '' && <Notice tone="err">{err}</Notice>}
{v === undefined ? <EmptyState>Loading</EmptyState> : (
<>
<Stats>
<StatCard label="Overdue" value={inr(v.totals.overduePaise)} hint={`${v.overdue.length} invoice(s)`} />
<StatCard label="Queued reminders" value={String(v.totals.queued)} />
<StatCard label="Failed sends" value={String(v.totals.failed)} />
<StatCard label="Follow-ups today" value={String(v.followUpsToday.length)} />
</Stats>
<h3>Reminder queue</h3>
{v.queue.length === 0 ? <EmptyState>Nothing waiting all clear.</EmptyState> : (
<DataTable
columns={[
{ key: 'kind', label: 'Kind' }, { key: 'client', label: 'Client' },
{ key: 'ref', label: 'Reference' }, { key: 'status', label: 'Status' },
{ key: 'error', label: 'Error' }, { key: 'act', label: '' },
]}
rows={v.queue.map((rem) => ({
kind: RULE_LABEL[rem.ruleKind] ?? rem.ruleKind,
client: rem.clientName,
ref: rem.docNo ?? rem.duePeriod,
status: <Badge tone={toneFor(rem.status)}>{rem.status}</Badge>,
error: rem.error ?? '—',
act: <QueueActions rem={rem} onDone={dash.reload} onError={setErr} />,
}))}
/>
)}
<Section title="Overdue" empty="No overdue invoices." rows={v.overdue}
columns={[{ key: 'no', label: 'Invoice' }, { key: 'client', label: 'Client' }, { key: 'days', label: 'Days', numeric: true }, { key: 'due', label: 'Outstanding', numeric: true }]}
onRowClick={(i) => nav(`/documents/${v.overdue[i]!.docId}`)}
map={(o) => ({ no: o.docNo ?? '—', client: o.clientName, days: o.daysOverdue, due: inr(o.outstandingPaise) })} />
<Section title="Due this week" empty="No recurring bills due this week." rows={v.dueThisWeek}
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Runs on' }, { key: 'amt', label: 'Amount', numeric: true }]}
map={(d) => ({ client: d.clientName, on: d.nextRun, amt: d.amountPaise !== null ? inr(d.amountPaise) : 'from price book' })} />
<Section title="Renewals this month" empty="No renewals this month." rows={v.renewalsThisMonth}
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Renews on' }]}
onRowClick={(i) => nav(`/clients/${v.renewalsThisMonth[i]!.clientId}`)}
map={(r) => ({ client: r.clientName, on: r.nextRenewal })} />
<Section title="Follow-ups today" empty="No follow-ups due." rows={v.followUpsToday}
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Due' }, { key: 'notes', label: 'Notes' }]}
onRowClick={(i) => nav(`/clients/${v.followUpsToday[i]!.clientId}`)}
map={(f) => ({ client: f.clientName, on: f.followUpOn, notes: f.notes !== '' ? f.notes : '—' })} />
<Section title="Recent payments" empty="No payments recorded yet." rows={v.recentPayments}
columns={[{ key: 'client', label: 'Client' }, { key: 'on', label: 'Received' }, { key: 'mode', label: 'Mode' }, { key: 'amt', label: 'Amount', numeric: true }]}
map={(p) => ({ client: p.clientName, on: p.receivedOn, mode: p.mode, amt: inr(p.amountPaise) })} />
</>
)}
</div>
)
}
/** A titled table with an empty state — keeps the dashboard body declarative. */
function Section<T>(props: {
title: string; empty: string; rows: T[]
columns: { key: string; label: string; numeric?: boolean }[]
map: (row: T) => Record<string, unknown>; onRowClick?: (i: number) => void
}) {
return (
<>
<h3 style={{ marginTop: 20 }}>{props.title}</h3>
{props.rows.length === 0 ? <EmptyState>{props.empty}</EmptyState> : (
<DataTable
columns={props.columns}
{...(props.onRowClick !== undefined ? { onRowClick: (_r: unknown, i: number) => 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<unknown>) => {
setBusy(true); props.onError('')
p.then(props.onDone).catch((e: Error) => props.onError(e.message)).finally(() => setBusy(false))
}
return (
<div style={{ display: 'flex', gap: 6 }}>
{SENDABLE.has(props.rem.ruleKind) && (
<Button tone="primary" onClick={() => run(sendReminder(props.rem.id))}>{busy ? '…' : 'Send'}</Button>
)}
<Button onClick={() => run(dismissReminder(props.rem.id))}>Dismiss</Button>
</div>
)
}
```
- [ ] **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'
// ...
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} />
<Route path="/modules" element={<Modules />} />
<Route path="/documents/new" element={<NewDocument />} />
<Route path="/documents/:id" element={<DocumentView />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
```
(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 <noreply@anthropic.com>"
```
---
### 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** (`<h3>Recurring plans</h3>`):
- 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** (`<h3>AMC contracts</h3>`):
- 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** (`<h3>Interactions</h3>`):
- "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 <noreply@anthropic.com>"
```
---
## 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. ✅