|
|
# 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="Today’s 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. ✅
|