# Client Detail Redesign — Backend & Data Foundation 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. **Goal:** Build the data + repo layer the redesigned Client Detail page needs — per-module onboarding templates, a milestone→status auto-drive, milestone↔payment links, an extended ledger (outstanding + allocations), and a bulk-SMS-purchase document path — with a one-time migration that moves existing SMS/RTGS/Mobile-App projects onto their new step lists without losing ticks. **Architecture:** Everything is a plain exported function taking the `DB` handle first (portable-repo pattern). Onboarding step lists are DB `setting` rows resolved by module code (config over code). Schema changes are additive `ALTER TABLE` in `migrate()` (SQLite) mirrored in `migrations-pg.ts` (Postgres). No money math changes — the ledger extension only *reads* existing `payment_allocation` / `document` rows. Every mutation writes an audit row in the same transaction. **Tech Stack:** TypeScript (strict), `better-sqlite3` (dev) / Postgres (prod), Express, Vitest. ## Global Constraints - Money is integer paise; never introduce floats. This plan touches **no** billing math (`computeBill`, allocation) — the ledger extension reads existing columns only. - Config over code, dated: onboarding step lists are `setting` rows, never code constants (the code default is a fallback only). - Append-only audit: every mutation calls `writeAudit(db, userId, action, entity, id, before, after)` in the same `db.transaction`. - Portable SQL: no engine-specific SQL beyond the existing `ON CONFLICT … DO NOTHING` upsert idiom already used in `seed.ts`. Schema changes land in BOTH `apps/hq/src/db.ts` (`migrate()`, SQLite) and `apps/hq/src/migrations-pg.ts` (Postgres). - Strict TS, green before landing: `npm run typecheck` and `npm test` clean before anything lands. - Run all commands from the repo root `C:/SiMS/hq`. Tests: `npm test --workspace @sims/hq` (vitest). Typecheck: `npm run typecheck`. --- ## File Structure - `apps/hq/src/repos-milestones.ts` — MODIFY: per-module template resolution; step→status auto-drive; `payment_id` on tick; stalled-projects query. - `apps/hq/src/repos-modules.ts` — MODIFY: `ensureProjectMilestones` call site already lives here (assignment); no change unless noted. - `apps/hq/src/seed.ts` — MODIFY: seed per-module template settings (SMS / RTGS / Mobile App). - `apps/hq/src/db.ts` — MODIFY: `project_milestone.payment_id` column (SQLite `migrate()`); `client_module` module-code lookup helper if needed. - `apps/hq/src/migrations-pg.ts` — MODIFY: mirror the `payment_id` column for Postgres. - `apps/hq/src/migrate-onboarding-templates.ts` — CREATE: the one-time, idempotent milestone-swap migration function. - `apps/hq/scripts/migrate-onboarding-templates.ts` — CREATE: thin CLI wrapper that opens the DB and runs the migration (mirrors `scripts/apply-name-fixes.ts`). - `apps/hq/src/repos-payments.ts` — MODIFY: extend `clientLedger` with `outstanding` + per-payment `allocations`. - `apps/hq/src/repos-documents.ts` — MODIFY: make `source` a settable draft field (default unchanged). - `apps/hq/src/repos-renewals.ts` — MODIFY: tag the renewal proforma `source='module_renewal'`; ADD `generateBulkSmsPurchaseQuote`. - `apps/hq/src/api.ts` — MODIFY: route for bulk-SMS-purchase; ledger route already returns `clientLedger` (shape widens automatically). - Tests under `apps/hq/test/` — one file per task as noted. --- ## Task 1: Per-module onboarding template resolution **Files:** - Modify: `apps/hq/src/repos-milestones.ts` - Test: `apps/hq/test/milestones-templates.test.ts` **Interfaces:** - Produces: `milestoneTemplate(db: DB, moduleCode?: string): Promise` — resolves `project.milestone_template:` → `project.milestone_template` → `FALLBACK_TEMPLATE`. - Produces: `ensureProjectMilestones(db: DB, clientModuleId: string): Promise` — now resolves the template by the client_module's module code. - [ ] **Step 1: Write the failing test** Create `apps/hq/test/milestones-templates.test.ts`: ```ts import { describe, it, expect, beforeEach } from 'vitest' import { openDb, type DB } from '../src/db' import { milestoneTemplate } from '../src/repos-milestones' describe('per-module milestone templates', () => { let db: DB beforeEach(async () => { db = await openDb(':memory:') }) it('falls back to the global template when no module-specific one exists', async () => { await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`, JSON.stringify([{ key: 'a', label: 'A' }])) expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'a', label: 'A' }]) }) it('prefers the module-specific template over the global one', async () => { await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`, JSON.stringify([{ key: 'a', label: 'A' }])) await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`, JSON.stringify([{ key: 'doc', label: 'Document collection' }])) expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'doc', label: 'Document collection' }]) // A different module still gets the global one. expect(await milestoneTemplate(db, 'RTGS')).toEqual([{ key: 'a', label: 'A' }]) }) it('falls back to the code default when nothing is seeded', async () => { const t = await milestoneTemplate(db, 'SMS') expect(t.map((e) => e.key)).toContain('go_live') }) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test --workspace @sims/hq -- milestones-templates` Expected: FAIL — `milestoneTemplate` does not accept a module code / does not read the `:CODE` key. - [ ] **Step 3: Implement the resolution** In `apps/hq/src/repos-milestones.ts`, replace `milestoneTemplate` and update `ensureProjectMilestones`: ```ts /** The checklist for a module (owner-editable per module via setting; global default; code fallback). */ export async function milestoneTemplate(db: DB, moduleCode?: string): Promise { const parse = (value: string): TemplateEntry[] | undefined => { try { const p = JSON.parse(value) as TemplateEntry[] return Array.isArray(p) && p.length > 0 ? p : undefined } catch { return undefined } } if (moduleCode !== undefined && moduleCode !== '') { const perModule = await db.get<{ value: string }>( `SELECT value FROM setting WHERE key=?`, `project.milestone_template:${moduleCode}`) const parsed = perModule !== undefined ? parse(perModule.value) : undefined if (parsed !== undefined) return parsed } const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`) const parsedGlobal = row !== undefined ? parse(row.value) : undefined return parsedGlobal ?? FALLBACK_TEMPLATE } ``` Then update `ensureProjectMilestones` to resolve by module code: ```ts export async function ensureProjectMilestones(db: DB, clientModuleId: string): Promise { const cm = await db.get<{ module_code: string }>( `SELECT m.code AS module_code FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE cm.id=?`, clientModuleId) const template = await milestoneTemplate(db, cm?.module_code) const existing = new Set( (await db.all<{ key: string }>(`SELECT key FROM project_milestone WHERE client_module_id=?`, clientModuleId)) .map((r) => r.key), ) await db.transaction(async () => { let sort = 0 for (const t of template) { if (!existing.has(t.key)) { await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) VALUES (?, ?, ?, ?, 0, NULL, ?)`, uuidv7(), clientModuleId, t.key, t.label, sort, ) } sort++ } }) } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npm test --workspace @sims/hq -- milestones-templates` Expected: PASS (3 tests). - [ ] **Step 5: Typecheck + commit** ```bash npm run typecheck git add apps/hq/src/repos-milestones.ts apps/hq/test/milestones-templates.test.ts git commit -m "feat(onboarding): resolve milestone template per module (config over code)" ``` --- ## Task 2: Seed per-module onboarding templates **Files:** - Modify: `apps/hq/src/seed.ts` - Test: `apps/hq/test/seed-templates.test.ts` **Interfaces:** - Consumes: `milestoneTemplate` (Task 1). - Produces: seeded settings `project.milestone_template:SMS|RTGS|MOBILEAPP`. (Confirm the Mobile App module code with `SELECT code FROM module` during Step 3; the mockup assumes `MOBILEAPP` — adjust the key to the real code.) - [ ] **Step 1: Write the failing test** Create `apps/hq/test/seed-templates.test.ts`: ```ts import { describe, it, expect, beforeEach } from 'vitest' import { openDb, type DB } from '../src/db' import { seedIfEmpty } from '../src/seed' import { milestoneTemplate } from '../src/repos-milestones' describe('seed per-module onboarding templates', () => { let db: DB beforeEach(async () => { db = await openDb(':memory:'); await seedIfEmpty(db) }) it('seeds the SMS 9-step list', async () => { const keys = (await milestoneTemplate(db, 'SMS')).map((e) => e.key) expect(keys).toEqual([ 'document_collection', 'dlt_registration', 'account_creation', 'installation', 'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', ]) }) it('seeds the RTGS list ending in payment steps', async () => { const keys = (await milestoneTemplate(db, 'RTGS')).map((e) => e.key) expect(keys).toEqual([ 'document_collection', 'server_checkup', 'setup_configuration', 'installation', 'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', ]) }) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test --workspace @sims/hq -- seed-templates` Expected: FAIL — per-module templates not seeded (falls back to global keys). - [ ] **Step 3: Add the seed block** In `apps/hq/src/seed.ts`, after the existing `project.milestone_template` seed (line ~101), add: ```ts // Per-module onboarding templates (config over code). SMS/RTGS/Mobile App differ; each ends // with advance + balance payment so a payment tick maps cleanly on migration. const PER_MODULE_TEMPLATES: Record = { SMS: [ { key: 'document_collection', label: 'Document collection' }, { key: 'dlt_registration', label: 'DLT registration' }, { key: 'account_creation', label: 'Account creation & registration' }, { key: 'installation', label: 'Installation' }, { key: 'testing', label: 'Testing' }, { key: 'training', label: 'Training' }, { key: 'go_live', label: 'Go-live' }, { key: 'advance_payment', label: 'Advance payment' }, { key: 'balance_payment', label: 'Balance payment' }, ], RTGS: [ { key: 'document_collection', label: 'Document collection' }, { key: 'server_checkup', label: 'Server checkup' }, { key: 'setup_configuration', label: 'Setup & configuration' }, { key: 'installation', label: 'Installation' }, { key: 'testing', label: 'Testing' }, { key: 'training', label: 'Training' }, { key: 'go_live', label: 'Go-live' }, { key: 'advance_payment', label: 'Advance payment' }, { key: 'balance_payment', label: 'Balance payment' }, ], MOBILEAPP: [ { key: 'requirement_setup', label: 'Requirement & setup' }, { key: 'configuration', label: 'Configuration' }, { key: 'data_import', label: 'Data import' }, { key: 'installation', label: 'Installation' }, { key: 'testing', label: 'Testing' }, { key: 'training', label: 'Training' }, { key: 'go_live', label: 'Go-live' }, { key: 'advance_payment', label: 'Advance payment' }, { key: 'balance_payment', label: 'Balance payment' }, ], } let seededPerModule = 0 for (const [code, list] of Object.entries(PER_MODULE_TEMPLATES)) { seededPerModule += (await db.run( INSERT_SETTING_SQL, `project.milestone_template:${code}`, JSON.stringify(list))).changes } if (seededPerModule > 0) { await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template:*', undefined, { modules: Object.keys(PER_MODULE_TEMPLATES) }) } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npm test --workspace @sims/hq -- seed-templates` Expected: PASS. Also run `npm test --workspace @sims/hq -- milestones-templates` — still green. - [ ] **Step 5: Commit** ```bash npm run typecheck git add apps/hq/src/seed.ts apps/hq/test/seed-templates.test.ts git commit -m "feat(onboarding): seed SMS/RTGS/MobileApp onboarding templates" ``` --- ## Task 3: Milestone→status auto-drive + go-live **Files:** - Modify: `apps/hq/src/repos-milestones.ts` (`setMilestone`) - Test: `apps/hq/test/milestone-status.test.ts` **Interfaces:** - Consumes: `setMilestone(db, userId, clientModuleId, key, done, doneOn?)` (existing signature unchanged). - Produces: a module-status advance side-effect keyed by a `STEP_STATUS` map, written in the same transaction and audited. - [ ] **Step 1: Write the failing test** Create `apps/hq/test/milestone-status.test.ts`. Use the existing test helpers to create a client + module + client_module (copy the setup from `apps/hq/test/tickets.test.ts` which already builds a `client_module`). Assert: ```ts // after seeding milestones for an SMS client_module at status 'quoted': it('ticking installation advances status to installed', async () => { await setMilestone(db, 'u1', cmId, 'installation', true) const cm = await getClientModule(db, cmId) expect(cm!.status).toBe('installed') }) it('ticking go_live advances status to live', async () => { await setMilestone(db, 'u1', cmId, 'go_live', true) expect((await getClientModule(db, cmId))!.status).toBe('live') }) it('unticking never downgrades status', async () => { await setMilestone(db, 'u1', cmId, 'go_live', true) await setMilestone(db, 'u1', cmId, 'go_live', false) expect((await getClientModule(db, cmId))!.status).toBe('live') }) it('a lower step never downgrades a higher status', async () => { await setMilestone(db, 'u1', cmId, 'go_live', true) // live await setMilestone(db, 'u1', cmId, 'installation', true) // still live expect((await getClientModule(db, cmId))!.status).toBe('live') }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test --workspace @sims/hq -- milestone-status` Expected: FAIL — status stays `quoted`. - [ ] **Step 3: Implement the auto-drive** In `apps/hq/src/repos-milestones.ts`, add the map and fold the advance into `setMilestone`'s transaction (after the `UPDATE project_milestone` and its audit, before `return`): ```ts /** Ticking a step can advance the coarse client_module.status — never downgrade. Ranks match * the status CHECK order: quoted = { quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7, } const STEP_STATUS: Record = { installation: 'installed', training: 'trained', go_live: 'live', } async function advanceStatusForStep(db: DB, userId: string, clientModuleId: string, key: string): Promise { const target = STEP_STATUS[key] if (target === undefined) return const cm = await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, clientModuleId) if (cm === undefined) return if ((STATUS_RANK[cm.status] ?? 0) >= (STATUS_RANK[target] ?? 0)) return // never downgrade await db.run(`UPDATE client_module SET status=? WHERE id=?`, target, clientModuleId) await writeAudit(db, userId, 'update', 'client_module', clientModuleId, { status: cm.status }, { status: target }) } ``` Inside `setMilestone`, after the milestone `writeAudit` and before `return listProjectMilestones(...)`, add: ```ts if (done) await advanceStatusForStep(db, userId, clientModuleId, key) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npm test --workspace @sims/hq -- milestone-status` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash npm run typecheck git add apps/hq/src/repos-milestones.ts apps/hq/test/milestone-status.test.ts git commit -m "feat(onboarding): status line auto-drives client_module.status (never downgrades)" ``` --- ## Task 4: `payment_id` on milestone + link on payment-step tick **Files:** - Modify: `apps/hq/src/db.ts` (`migrate()` SQLite) and `apps/hq/src/migrations-pg.ts` (Postgres) - Modify: `apps/hq/src/repos-milestones.ts` (`Milestone` type, `listProjectMilestones`, `setMilestone` optional `paymentId`) - Modify: `apps/hq/src/api.ts` (accept `paymentId` in the milestone POST body) - Test: `apps/hq/test/milestone-payment.test.ts` **Interfaces:** - Produces: `setMilestone(db, userId, clientModuleId, key, done, doneOn?, paymentId?)` — when a payment step is ticked with a `paymentId`, stores it and mirrors the payment's `received_on` into `done_on`. - Produces: `Milestone` gains `paymentId: string | null`. - [ ] **Step 1: Add the column (schema, both engines)** In `apps/hq/src/db.ts` `migrate()`, alongside the other `PRAGMA table_info` blocks, add: ```ts const milestoneCols = db.prepare(`PRAGMA table_info(project_milestone)`).all() as { name: string }[] if (!milestoneCols.some((c) => c.name === 'payment_id')) { db.exec(`ALTER TABLE project_milestone ADD COLUMN payment_id TEXT`) } ``` In `apps/hq/src/migrations-pg.ts`, mirror it (follow the file's existing additive-migration idiom — an `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS payment_id text`). - [ ] **Step 2: Write the failing test** Create `apps/hq/test/milestone-payment.test.ts` (reuse the client_module setup + record a payment via `recordPayment`): ```ts it('linking a payment to advance_payment stores payment_id and mirrors received_on', async () => { const pay = await recordPayment(db, 'u1', { clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00, }) await setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id) const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'advance_payment')! expect(ms.done).toBe(true) expect(ms.doneOn).toBe('2026-05-01') expect(ms.paymentId).toBe(pay.payment.id) }) ``` - [ ] **Step 3: Run test to verify it fails** Run: `npm test --workspace @sims/hq -- milestone-payment` Expected: FAIL — `setMilestone` has no `paymentId` param; `Milestone` has no `paymentId`. - [ ] **Step 4: Implement** In `repos-milestones.ts`: - Add `paymentId: string | null` to `interface Milestone` and to `MilestoneRow` (`payment_id: string | null`). - Include `payment_id` in the `SELECT` of `listProjectMilestones` and map it: `paymentId: r.payment_id`. - Extend `setMilestone` signature with a trailing `paymentId?: string`. When `done` and `paymentId` is provided, resolve the payment's `received_on` and use it as the stamp, and set `payment_id`: ```ts export async function setMilestone( db: DB, userId: string, clientModuleId: string, key: string, done: boolean, doneOn?: string, paymentId?: string, ): Promise { return db.transaction(async () => { const before = await db.get( `SELECT key, label, done, done_on, sort, payment_id FROM project_milestone WHERE client_module_id=? AND key=?`, clientModuleId, key) if (before === undefined) throw new Error('Milestone not found on this project') if (done && doneOn !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(doneOn)) { throw new Error('doneOn must be YYYY-MM-DD') } let stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null let linkedPayment: string | null = null if (done && paymentId !== undefined && paymentId !== '') { const pay = await db.get<{ received_on: string }>( `SELECT received_on FROM payment WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`, paymentId, clientModuleId) if (pay === undefined) throw new Error('Payment not found for this client') linkedPayment = paymentId if (doneOn === undefined) stamp = pay.received_on // link date wins unless caller forced one } await db.run( `UPDATE project_milestone SET done=?, done_on=?, payment_id=? WHERE client_module_id=? AND key=?`, done ? 1 : 0, stamp, done ? linkedPayment : null, clientModuleId, key) await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, { key, done, doneOn: stamp, paymentId: linkedPayment }) if (done) await advanceStatusForStep(db, userId, clientModuleId, key) return listProjectMilestones(db, clientModuleId) }) } ``` In `api.ts` milestone POST route, read `paymentId` from the body and pass it through: ```ts const milestones = await setMilestone(db, staffId(res), id, body.key, body.done, typeof body.doneOn === 'string' ? body.doneOn : undefined, typeof (body as { paymentId?: unknown }).paymentId === 'string' ? (body as { paymentId?: string }).paymentId : undefined) ``` - [ ] **Step 5: Run tests to verify they pass** Run: `npm test --workspace @sims/hq -- milestone-payment milestone-status milestones-templates` Expected: PASS (all). - [ ] **Step 6: Commit** ```bash npm run typecheck git add apps/hq/src/db.ts apps/hq/src/migrations-pg.ts apps/hq/src/repos-milestones.ts apps/hq/src/api.ts apps/hq/test/milestone-payment.test.ts git commit -m "feat(onboarding): link advance/balance payment steps to real payments" ``` --- ## Task 5: One-time migration — swap seeded modules onto new templates, preserve ticks **Files:** - Create: `apps/hq/src/migrate-onboarding-templates.ts` - Create: `apps/hq/scripts/migrate-onboarding-templates.ts` - Test: `apps/hq/test/migrate-onboarding-templates.test.ts` **Interfaces:** - Produces: `migrateOnboardingTemplates(db: DB): Promise<{ projects: number; carried: number; dropped: number }>` — idempotent; for every client_module whose module has a per-module template setting, replaces the old rows with the module's template, carrying mapped ticks (`done` + `done_on` + `payment_id`). **Mapping (old generic key → new key):** `installed→installation`, `go_live→go_live`, `advance_paid→advance_payment`, `balance_paid→balance_payment`, `setup_done→setup_configuration` (only if the new template has that key). Old keys with no mapped target (e.g. `amc_start`, or `setup_done` for SMS) are dropped and counted. - [ ] **Step 1: Write the failing test** Create `apps/hq/test/migrate-onboarding-templates.test.ts`: ```ts import { describe, it, expect, beforeEach } from 'vitest' import { openDb, type DB } from '../src/db' import { seedIfEmpty } from '../src/seed' import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' import { listProjectMilestones } from '../src/repos-milestones' // + helpers to create an SMS client_module and seed the OLD generic milestones on it describe('onboarding template migration', () => { let db: DB beforeEach(async () => { db = await openDb(':memory:'); await seedIfEmpty(db) }) it('swaps an SMS project to the 9-step list carrying installed+go_live ticks', async () => { const cmId = /* create SMS client_module */ '' // seed OLD generic rows with two ticked: for (const [i, r] of [ ['advance_paid', 0, null], ['setup_done', 0, null], ['installed', 1, '2026-03-25'], ['go_live', 1, '2026-04-02'], ['balance_paid', 0, null], ['amc_start', 0, null], ].entries()) { await db.run(`INSERT INTO project_milestone (id,client_module_id,key,label,done,done_on,sort) VALUES (?,?,?,?,?,?,?)`, `m${i}`, cmId, r[0], String(r[0]), r[1], r[2], i) } const res = await migrateOnboardingTemplates(db) expect(res.projects).toBe(1) const ms = await listProjectMilestones(db, cmId) expect(ms.map((m) => m.key)).toEqual([ 'document_collection','dlt_registration','account_creation','installation', 'testing','training','go_live','advance_payment','balance_payment']) const installation = ms.find((m) => m.key === 'installation')! expect(installation.done).toBe(true) expect(installation.doneOn).toBe('2026-03-25') expect(ms.find((m) => m.key === 'go_live')!.done).toBe(true) // old setup_done / amc_start gone: expect(ms.some((m) => m.key === 'setup_done' || m.key === 'amc_start')).toBe(false) }) it('is idempotent (second run changes nothing new)', async () => { /* run twice; assert the milestone set is identical after each */ }) it('leaves a module without a per-module template untouched', async () => { /* create a client_module for a module with NO :CODE setting; assert its rows unchanged */ }) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test --workspace @sims/hq -- migrate-onboarding-templates` Expected: FAIL — module `migrate-onboarding-templates` does not exist. - [ ] **Step 3: Implement the migration** Create `apps/hq/src/migrate-onboarding-templates.ts`: ```ts import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import { milestoneTemplate } from './repos-milestones' import type { DB } from './db' /** old generic key → new template key (only applied if the new template has that key). */ const KEY_MAP: Record = { installed: 'installation', go_live: 'go_live', advance_paid: 'advance_payment', balance_paid: 'balance_payment', setup_done: 'setup_configuration', } interface OldRow { key: string; done: number; done_on: string | null; payment_id: string | null } /** * Idempotent: for every client_module whose module has a per-module onboarding template, * rebuild its milestone rows from that template, carrying mapped ticks (done/done_on/payment_id). * Unmapped old ticks are dropped (counted). Modules with no per-module template are untouched. */ export async function migrateOnboardingTemplates(db: DB): Promise<{ projects: number; carried: number; dropped: number }> { // Which module codes have a per-module template setting? const codes = (await db.all<{ key: string }>( `SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`)) .map((r) => r.key.slice('project.milestone_template:'.length)) let projects = 0, carried = 0, dropped = 0 for (const code of codes) { const template = await milestoneTemplate(db, code) const templateKeys = new Set(template.map((t) => t.key)) const cms = await db.all<{ id: string }>( `SELECT cm.id FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE m.code=?`, code) for (const { id: cmId } of cms) { const oldRows = await db.all( `SELECT key, done, done_on, payment_id FROM project_milestone WHERE client_module_id=?`, cmId) // Build the carried tick-state keyed by NEW key. const carriedState = new Map() let alreadyNew = true for (const r of oldRows) { if (!templateKeys.has(r.key)) alreadyNew = false const target = templateKeys.has(r.key) ? r.key : KEY_MAP[r.key] if (target !== undefined && templateKeys.has(target)) { if (r.done === 1) carriedState.set(target, r) } else if (r.done === 1) { dropped++ } } // Idempotency: if the project's keys already ARE exactly the template, skip. const oldKeySet = new Set(oldRows.map((r) => r.key)) const identical = alreadyNew && oldKeySet.size === templateKeys.size && [...templateKeys].every((k) => oldKeySet.has(k)) if (identical) continue projects++ await db.transaction(async () => { await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cmId) let sort = 0 for (const t of template) { const c = carriedState.get(t.key) await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, uuidv7(), cmId, t.key, t.label, c !== undefined ? 1 : 0, c?.done_on ?? null, c?.payment_id ?? null, sort) if (c !== undefined) carried++ sort++ } await writeAudit(db, 'system', 'migrate_onboarding', 'client_module', cmId, undefined, { module: code, carried: [...carriedState.keys()] }) }) } } return { projects, carried, dropped } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npm test --workspace @sims/hq -- migrate-onboarding-templates` Expected: PASS (3 tests). - [ ] **Step 5: Create the CLI wrapper** Create `apps/hq/scripts/migrate-onboarding-templates.ts` (mirror `scripts/apply-name-fixes.ts` open-DB pattern): ```ts import { openDb } from '../src/db' import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' async function main(): Promise { const db = await openDb(process.env['HQ_DB_PATH'] ?? 'data/hq.db') const res = await migrateOnboardingTemplates(db) console.log(`onboarding migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped`) } main().catch((e) => { console.error(e); process.exit(1) }) ``` - [ ] **Step 6: Commit** ```bash npm run typecheck git add apps/hq/src/migrate-onboarding-templates.ts apps/hq/scripts/migrate-onboarding-templates.ts apps/hq/test/migrate-onboarding-templates.test.ts git commit -m "feat(onboarding): one-time migration swapping seeded modules to new templates (ticks preserved)" ``` --- ## Task 6: Extend the ledger with outstanding + per-payment allocations **Files:** - Modify: `apps/hq/src/repos-payments.ts` (`ClientLedger`, `clientLedger`) - Modify: `apps/hq-web/src/api.ts` (`Ledger`, `Payment` types — web mirror) - Test: `apps/hq/test/ledger-outstanding.test.ts` **Interfaces:** - Produces: `ClientLedger` gains `outstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[]` and each `Payment` in the ledger gains `allocations: { docNo: string; amountPaise: number }[]`. - Consumes: existing `outstandingPaise(db, docId)` (already exported in `repos-payments.ts`). - [ ] **Step 1: Write the failing test** Create `apps/hq/test/ledger-outstanding.test.ts`. Build a client with one issued INVOICE (partly paid) and one open PROFORMA; record a payment; then: ```ts it('reports outstanding open documents oldest-first with correct due amounts', async () => { const led = await clientLedger(db, clientId) expect(led.outstanding.map((o) => o.docNo)).toEqual(['INV-0001']) // proforma optional; see label rule expect(led.outstanding[0]!.outstandingPaise).toBe(/* payable - allocated - credited */ 0) }) it('attaches allocations to each payment', async () => { const led = await clientLedger(db, clientId) const p = led.payments[0]! expect(p.allocations.reduce((s, a) => s + a.amountPaise, 0)).toBeLessThanOrEqual(p.amountPaise + p.tdsPaise) }) ``` (Fill in exact expected paise from the fixture you build — pick round amounts so the assertions are concrete.) - [ ] **Step 2: Run test to verify it fails** Run: `npm test --workspace @sims/hq -- ledger-outstanding` Expected: FAIL — `outstanding` / `allocations` undefined. - [ ] **Step 3: Implement the extension** In `apps/hq/src/repos-payments.ts`, widen the interface and `clientLedger`: ```ts export interface LedgerOutstanding { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number } export interface LedgerPayment extends Payment { allocations: { docNo: string; amountPaise: number }[] } export interface ClientLedger { documents: Doc[]; payments: LedgerPayment[]; advancePaise: number; outstanding: LedgerOutstanding[] } ``` In `clientLedger`, after building `documents`: ```ts // Per-payment allocations (docNo + amount) for the "Settled → document" column. const allocRows = await db.all<{ payment_id: string; doc_no: string | null; amount_paise: number }>( `SELECT a.payment_id, d.doc_no, a.amount_paise FROM payment_allocation a JOIN document d ON d.id = a.document_id JOIN payment p ON p.id = a.payment_id WHERE p.client_id=?`, clientId) const allocByPayment = new Map() for (const r of allocRows) { const list = allocByPayment.get(r.payment_id) ?? [] list.push({ docNo: r.doc_no ?? '—', amountPaise: r.amount_paise }) allocByPayment.set(r.payment_id, list) } const ledgerPayments: LedgerPayment[] = payments.map((p) => ({ ...p, allocations: allocByPayment.get(p.id) ?? [] })) // Open documents with money still owed — issued invoices (outstanding > 0) + open proformas. const moduleName = async (doc: Doc): Promise => doc.payload.lines.map((l) => l.name).filter((n) => n !== '').join(', ') const outstanding: LedgerOutstanding[] = [] for (const d of documents) { if (d.status === 'cancelled' || d.status === 'paid' || d.status === 'lost') continue if (d.docType === 'INVOICE' && d.docNo !== null) { const out = await outstandingPaise(db, d.id) if (out > 0) outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: out }) } else if (d.docType === 'PROFORMA' && (d.status === 'sent' || d.status === 'accepted' || d.status === 'draft')) { outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: d.payablePaise }) } } outstanding.sort((a, b) => (a.docNo ?? '').localeCompare(b.docNo ?? '')) ``` Return `{ documents, payments: ledgerPayments, advancePaise: settling - row.total, outstanding }`. - [ ] **Step 4: Mirror the web types** In `apps/hq-web/src/api.ts`, update `Payment` (add `allocations: { docNo: string; amountPaise: number }[]`) and `Ledger` (add `outstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[]`). - [ ] **Step 5: Run tests + typecheck** Run: `npm test --workspace @sims/hq -- ledger-outstanding` → PASS. Run: `npm test --workspace @sims/hq` (full suite — confirm the 416 existing tests stay green; the ledger shape only widened). `npm run typecheck` clean. - [ ] **Step 6: Commit** ```bash git add apps/hq/src/repos-payments.ts apps/hq-web/src/api.ts apps/hq/test/ledger-outstanding.test.ts git commit -m "feat(payments): ledger exposes outstanding docs + per-payment allocations" ``` --- ## Task 7: Bulk-SMS-purchase document + source tagging **Files:** - Modify: `apps/hq/src/repos-documents.ts` (`DraftInput.source?`, `insertDocRow` source, INSERT column) - Modify: `apps/hq/src/repos-renewals.ts` (`source='module_renewal'`; ADD `generateBulkSmsPurchaseQuote`) - Modify: `apps/hq/src/api.ts` (POST route for bulk SMS purchase) - Test: `apps/hq/test/bulk-sms-purchase.test.ts` **Interfaces:** - Produces: `generateBulkSmsPurchaseQuote(db, userId, clientModuleId, smsQty): Promise` — a PROFORMA priced via the usage rate card for `smsQty` SMS, `source='bulk_sms_topup'`. - Produces: `createDraft` accepts optional `source` (default preserves current behaviour); documents carry it for the frontend's Quote/Renewal/Bulk-top-up label. - [ ] **Step 1: Write the failing test** Create `apps/hq/test/bulk-sms-purchase.test.ts`. Set up an SMS module with a usage rate card (copy the rate-card setup from an existing rate-card test), an SMS client_module, then: ```ts it('raises a proforma tagged bulk_sms_topup for the given SMS quantity', async () => { const doc = await generateBulkSmsPurchaseQuote(db, 'u1', cmId, 100000) expect(doc.docType).toBe('PROFORMA') expect(doc.source).toBe('bulk_sms_topup') expect(doc.payload.lines[0]!.qty).toBe(100000) expect(doc.payablePaise).toBeGreaterThan(0) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test --workspace @sims/hq -- bulk-sms-purchase` Expected: FAIL — `generateBulkSmsPurchaseQuote` not exported. - [ ] **Step 3: Make `source` settable on a draft** In `apps/hq/src/repos-documents.ts`: - Add `source?: string` to `interface DraftInput`. - Thread it into `insertDocRow`: add `source?: string` to its param object, add `source` to the INSERT column list + a `?` placeholder, and pass `a.source ?? 'manual'` (confirm the schema default; keep current behaviour when omitted). In `createDraft`, pass `source: input.source` into `insertDocRow`. ```ts // insertDocRow INSERT — add `source` column: `INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, due_date, status, ref_doc_id, taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise, payload, source, created_by, created_at) VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, // ...bind a.source ?? 'manual' in the source position. ``` - [ ] **Step 4: Add the generator + tag the renewal** In `apps/hq/src/repos-renewals.ts`, tag the existing renewal proforma and add the bulk generator: ```ts export async function generateModuleRenewalQuote(db: DB, userId: string, clientModuleId: string): Promise { const cm = await getClientModule(db, clientModuleId) if (cm === null) throw new Error('Client module not found') return createDraft(db, userId, { docType: 'PROFORMA', clientId: cm.clientId, source: 'module_renewal', lines: [{ moduleId: cm.moduleId, qty: 1, kind: cm.kind, edition: cm.edition }], }) } /** Ad-hoc bulk SMS credit purchase — a PROFORMA priced by the usage rate card for `smsQty` units. */ export async function generateBulkSmsPurchaseQuote( db: DB, userId: string, clientModuleId: string, smsQty: number, ): Promise { if (!Number.isInteger(smsQty) || smsQty <= 0) throw new Error('SMS quantity must be a positive integer') const cm = await getClientModule(db, clientModuleId) if (cm === null) throw new Error('Client module not found') return createDraft(db, userId, { docType: 'PROFORMA', clientId: cm.clientId, source: 'bulk_sms_topup', lines: [{ moduleId: cm.moduleId, qty: smsQty, kind: cm.kind, edition: cm.edition }], }) } ``` - [ ] **Step 5: Add the API route** In `apps/hq/src/api.ts`, near the other document routes, add: ```ts r.post('/client-modules/:id/bulk-sms-quote', requireAuth, async (req, res) => { try { const qty = Number((req.body as { smsQty?: unknown }).smsQty) const doc = await generateBulkSmsPurchaseQuote(db, staffId(res), String(req.params['id'] ?? ''), qty) res.json({ ok: true, document: doc }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) ``` Import `generateBulkSmsPurchaseQuote` alongside `generateModuleRenewalQuote`. - [ ] **Step 6: Run tests + full suite** Run: `npm test --workspace @sims/hq -- bulk-sms-purchase` → PASS. Run: `npm test --workspace @sims/hq` → all green (source default preserved the existing document tests). `npm run typecheck` clean. - [ ] **Step 7: Commit** ```bash git add apps/hq/src/repos-documents.ts apps/hq/src/repos-renewals.ts apps/hq/src/api.ts apps/hq/test/bulk-sms-purchase.test.ts git commit -m "feat(sms): bulk SMS purchase proforma + document source tagging (renewal vs top-up)" ``` --- ## Task 8: Stalled-onboarding query **Files:** - Modify: `apps/hq/src/repos-milestones.ts` - Modify: `apps/hq/src/api.ts` (route) - Test: `apps/hq/test/stalled-onboarding.test.ts` **Interfaces:** - Produces: `stalledProjects(db: DB, olderThanDays: number, today: string): Promise` — active projects whose earliest pending step's most recent done_on (or module creation) is older than N days, i.e. parked. Reuses the `PendingProject` shape. - [ ] **Step 1: Write the failing test** Create `apps/hq/test/stalled-onboarding.test.ts`: build an active SMS project with early steps done on an old date and later steps pending; assert it appears for a small `olderThanDays` and not for a large one. ```ts it('lists a project parked past the threshold', async () => { // last done_on = 2026-03-01, today = 2026-07-19 → ~140 days const stalled = await stalledProjects(db, 30, '2026-07-19') expect(stalled.map((p) => p.clientModuleId)).toContain(cmId) }) it('excludes a recently-progressed project', async () => { const stalled = await stalledProjects(db, 365, '2026-07-19') expect(stalled).toHaveLength(0) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test --workspace @sims/hq -- stalled-onboarding` Expected: FAIL — `stalledProjects` not exported. - [ ] **Step 3: Implement** In `apps/hq/src/repos-milestones.ts`: ```ts /** Active projects with at least one pending step whose progress last moved > olderThanDays ago. */ export async function stalledProjects(db: DB, olderThanDays: number, today: string): Promise { const rows = await db.all( `SELECT pm.client_module_id AS "clientModuleId", cm.client_id AS "clientId", c.name AS "clientName", m.code AS "moduleCode", m.name AS "moduleName", MAX(pm.done_on) AS last_done FROM project_milestone pm JOIN client_module cm ON cm.id = pm.client_module_id JOIN client c ON c.id = cm.client_id JOIN module m ON m.id = cm.module_id WHERE cm.active = 1 AND cm.status NOT IN ('live','expired','cancelled') GROUP BY pm.client_module_id HAVING SUM(CASE WHEN pm.done = 0 THEN 1 ELSE 0 END) > 0`, ) const cutoff = new Date(Date.parse(today) - olderThanDays * 86400_000).toISOString().slice(0, 10) return rows .filter((r) => (r.last_done ?? '0000-00-00') < cutoff) .map(({ last_done: _l, ...rest }) => rest) } ``` In `api.ts` add a route: `r.get('/projects/stalled', requireAuth, …)` reading an `?days=` query (default from a `project.stall_days` setting, fallback 30) and `new Date().toISOString().slice(0,10)` for today. - [ ] **Step 4: Run tests to verify they pass** Run: `npm test --workspace @sims/hq -- stalled-onboarding` → PASS. - [ ] **Step 5: Commit** ```bash npm run typecheck git add apps/hq/src/repos-milestones.ts apps/hq/src/api.ts apps/hq/test/stalled-onboarding.test.ts git commit -m "feat(onboarding): stalled-project query for the dashboard/MIS tile" ``` --- ## Backend plan self-review - **Spec coverage:** §2 templates → T1/T2; §3 migration → T5; §7.1 status auto-drive → T3; §7.2 payment link → T4; §5 ledger → T6; §4a bulk SMS + §7.3 doc source → T7; §7.4 stalled → T8. Per-module Documents (§4) is pure frontend (client-side filter) — no backend task, handled in Plan 2. - **Type consistency:** `milestoneTemplate(db, moduleCode?)`, `setMilestone(..., doneOn?, paymentId?)`, `ClientLedger.outstanding` / `LedgerPayment.allocations`, `generateBulkSmsPurchaseQuote(db, userId, cmId, smsQty)`, `stalledProjects(db, days, today)` — names used consistently across tasks and into Plan 2. - **Placeholder scan:** test fixtures marked "fill in exact paise" (T6) and the client_module setup helpers (T3–T8) reference the existing `apps/hq/test/tickets.test.ts` setup — the implementer copies that concrete setup; not a code placeholder in shipped source. - **Verify before done:** every task ends with `npm test` for its file plus `npm run typecheck`; T6 and T7 additionally run the full suite because they touch shared read/write paths. **Confirm before building:** the Mobile App module `code` (assumed `MOBILEAPP`) — check `SELECT code, name FROM module` and set the seed key + migration to the real code.