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...

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 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_templateFALLBACK_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 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:

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_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:

// 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)"

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:

  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 | 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:
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, 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:

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?, 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:

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.
// 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 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.

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 (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.