43 KiB
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
settingrows, 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 samedb.transaction. - Portable SQL: no engine-specific SQL beyond the existing
ON CONFLICT … DO NOTHINGupsert idiom already used inseed.ts. Schema changes land in BOTHapps/hq/src/db.ts(migrate(), SQLite) andapps/hq/src/migrations-pg.ts(Postgres). - Strict TS, green before landing:
npm run typecheckandnpm testclean 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_idon tick; stalled-projects query.apps/hq/src/repos-modules.ts— MODIFY:ensureProjectMilestonescall 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_idcolumn (SQLitemigrate());client_modulemodule-code lookup helper if needed.apps/hq/src/migrations-pg.ts— MODIFY: mirror thepayment_idcolumn 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 (mirrorsscripts/apply-name-fixes.ts).apps/hq/src/repos-payments.ts— MODIFY: extendclientLedgerwithoutstanding+ per-paymentallocations.apps/hq/src/repos-documents.ts— MODIFY: makesourcea settable draft field (default unchanged).apps/hq/src/repos-renewals.ts— MODIFY: tag the renewal proformasource='module_renewal'; ADDgenerateBulkSmsPurchaseQuote.apps/hq/src/api.ts— MODIFY: route for bulk-SMS-purchase; ledger route already returnsclientLedger(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[]>— resolvesproject.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:
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:
/** 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:
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
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 withSELECT code FROM moduleduring Step 3; the mockup assumesMOBILEAPP— adjust the key to the real code.) -
Step 1: Write the failing test
Create apps/hq/test/seed-templates.test.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:
// 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
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_STATUSmap, 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:
// 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):
/** 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:
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
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) andapps/hq/src/migrations-pg.ts(Postgres) - Modify:
apps/hq/src/repos-milestones.ts(Milestonetype,listProjectMilestones,setMilestoneoptionalpaymentId) - Modify:
apps/hq/src/api.ts(acceptpaymentIdin 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 apaymentId, stores it and mirrors the payment'sreceived_onintodone_on. -
Produces:
MilestonegainspaymentId: 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:
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):
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 | nulltointerface Milestoneand toMilestoneRow(payment_id: string | null). - Include
payment_idin theSELECToflistProjectMilestonesand map it:paymentId: r.payment_id. - Extend
setMilestonesignature with a trailingpaymentId?: string. WhendoneandpaymentIdis provided, resolve the payment'sreceived_onand use it as the stamp, and setpayment_id:
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:
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
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:
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:
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):
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
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,Paymenttypes — web mirror) - Test:
apps/hq/test/ledger-outstanding.test.ts
Interfaces:
-
Produces:
ClientLedgergainsoutstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[]and eachPaymentin the ledger gainsallocations: { docNo: string; amountPaise: number }[]. -
Consumes: existing
outstandingPaise(db, docId)(already exported inrepos-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:
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:
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:
// 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
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?,insertDocRowsource, INSERT column) - Modify:
apps/hq/src/repos-renewals.ts(source='module_renewal'; ADDgenerateBulkSmsPurchaseQuote) - 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 forsmsQtySMS,source='bulk_sms_topup'. -
Produces:
createDraftaccepts optionalsource(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:
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
sourcesettable on a draft
In apps/hq/src/repos-documents.ts:
- Add
source?: stringtointerface DraftInput. - Thread it into
insertDocRow: addsource?: stringto its param object, addsourceto the INSERT column list + a?placeholder, and passa.source ?? 'manual'(confirm the schema default; keep current behaviour when omitted). IncreateDraft, passsource: input.sourceintoinsertDocRow.
// 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:
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:
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
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 thePendingProjectshape. -
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.
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:
/** 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
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.tssetup — the implementer copies that concrete setup; not a code placeholder in shipped source. - Verify before done: every task ends with
npm testfor its file plusnpm 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.