|
|
# 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 (T3–T8) reference the existing `apps/hq/test/tickets.test.ts` setup — the implementer copies that concrete setup; not a code placeholder in shipped source.
|
|
|
- **Verify before done:** every task ends with `npm test` for its file plus `npm run typecheck`; T6 and T7 additionally run the full suite because they touch shared read/write paths.
|
|
|
|
|
|
**Confirm before building:** the Mobile App module `code` (assumed `MOBILEAPP`) — check `SELECT code, name FROM module` and set the seed key + migration to the real code.
|