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

942 lines
43 KiB
Markdown

This file contains ambiguous Unicode characters!

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

# 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<TemplateEntry[]>` — resolves `project.milestone_template:<CODE>``project.milestone_template``FALLBACK_TEMPLATE`.
- Produces: `ensureProjectMilestones(db: DB, clientModuleId: string): Promise<void>` — 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<TemplateEntry[]> {
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<void> {
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<string, { key: string; label: string }[]> = {
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<ordered<installing<installed<trained<live<expired<cancelled. */
const STATUS_RANK: Record<string, number> = {
quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7,
}
const STEP_STATUS: Record<string, string> = {
installation: 'installed', training: 'trained', go_live: 'live',
}
async function advanceStatusForStep(db: DB, userId: string, clientModuleId: string, key: string): Promise<void> {
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<Milestone[]> {
return db.transaction(async () => {
const before = await db.get<MilestoneRow>(
`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<string, string> = {
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<OldRow>(
`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<string, OldRow>()
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<void> {
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<string, { docNo: string; amountPaise: number }[]>()
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<string> =>
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<Doc>` — 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<Doc> {
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<Doc> {
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<PendingProject[]>` — 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<PendingProject[]> {
const rows = await db.all<PendingProject & { last_done: string | null }>(
`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 (T3T8) 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.