|
|
# 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 store-server-shaped HQ 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>"
|
|
|
```
|
|
|
|
|
|
---
|