Compare commits

...

10 Commits

Author SHA1 Message Date
Thomas Joise 9d3087c1fe feat(client-detail): module details as 50/50 report with single edit per group
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise d05c7ee31b docs: bring README/STATUS/CLAUDE/DECISIONS current (D34-D37)
Records the work since the last doc pass: employee master import + invited/first-login
flag (D34), client type PACS/Store + directory-sourced LTD numbers and the name-correction
session (D35), login history with IP/device (D36), and the society category matching the
Kerala co-op directory taxonomy (D37). Adds the login_event table, the Login history
screen, and the client type/category fields; bumps the schema table count 31->32. Decisions
D34-D37 appended to 06-DECISIONS.md.
2 days ago
Thomas Joise 67f69fc999 style(client-detail): CSS for 50/50 sheet, status line, outstanding panel
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 75fb41c366 feat(onboarding): stalled-project query for the dashboard/MIS tile
Adds stalledProjects(db, olderThanDays, today) to repos-milestones.ts:
active projects with a pending step whose last progress (MAX done_on,
or module creation if nothing ticked) is older than N days. GET
/projects/stalled reads ?days= with a project.stall_days setting
fallback (default 30).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 195d821bef feat(sms): bulk SMS purchase proforma + document source tagging (renewal vs top-up)
Threads an optional `source` through DraftInput/insertDocRow (default 'hq',
preserving current behaviour when omitted), tags module renewals as
'module_renewal', and adds generateBulkSmsPurchaseQuote — an ad-hoc bulk SMS
credit top-up priced via the existing usage rate card, tagged
'bulk_sms_topup' so the client-detail document list can distinguish it from a
subscription renewal. New route: POST /client-modules/:id/bulk-sms-quote.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise fdf31b4dbd feat(payments): ledger exposes outstanding docs + per-payment allocations
clientLedger now attaches allocations (docNo + amount) to each payment and
returns an outstanding[] list — issued invoices with outstandingPaise > 0
plus open (draft/sent/accepted) proformas, oldest-first by docNo. Read-only
extension over the existing settlement math (outstandingPaise, allocations);
no money/allocation logic changed. Mirrors the widened Payment/Ledger shapes
onto apps/hq-web/src/api.ts for the upcoming Payments tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 4b2e44c3ea test(onboarding-migration): add coverage for dropped ticked milestone keys
Add one test case to cover the scenario where old milestone keys that
don't map to the new template (amc_start, setup_done for SMS) are ticked
and should be counted in the migration's dropped tally. Verifies both
dropped and carried counts are accurate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 6bce248c2e feat(onboarding): one-time migration swapping seeded modules to new templates (ticks preserved)
Idempotent migrateOnboardingTemplates(db): for every client_module whose module
has a per-module onboarding template (task 2), rebuilds its milestone rows from
that template, carrying mapped ticks (installed->installation, go_live->go_live,
advance_paid->advance_payment, balance_paid->balance_payment, setup_done->
setup_configuration when present) with done_on and payment_id intact. Unmapped
ticks (amc_start, or setup_done where the new template has no matching step) are
dropped and counted. Audited per project; a second run is a no-op via an
identical-key-set short-circuit. Adds the CLI wrapper (mirrors
apply-name-fixes.ts's open-DB idiom, Postgres or SQLite) for the real one-time
run against production data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise cd235b622b feat(onboarding): link advance/balance payment steps to real payments
Milestone.paymentId + project_milestone.payment_id (SQLite + Postgres,
additive). setMilestone(..., paymentId?) resolves the payment's
received_on to stamp done_on when a payment-step tick links a real
payment; the linked payment must belong to the same client. api.ts
threads paymentId from the milestone POST body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise b2c49832d5 feat(onboarding): status line auto-drives client_module.status (never downgrades)
Ticking the installation/training/go_live onboarding milestone steps now
advances the coarse client_module.status (installed/trained/live), audited
in the same transaction. A STATUS_RANK guard blocks downgrades from
unticking or ticking an earlier step after a later one already landed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago

@ -17,7 +17,7 @@ dashboard, an append-only audit log, an APEX importer, and a scheduler.
| Part | Where | Notes |
|---|---|---|
| Backend | `apps/hq/src` | Express over `better-sqlite3`, DB at `data/hq.db` (WAL) |
| Frontend | `apps/hq-web/src` | React + Vite. **Home** card launcher is the landing (`/`); Dashboard (My Day) is at `/dashboard` (D30). Pages: Home / Dashboard / Pipeline / Reminders / Tickets / Onboarding / Clients / ClientDetail / Documents / NewDocument / DocumentView / Renewals / Modules / Module data / Portals / Reports / MIS cockpit (owner-only) / SMS credits / Employees (owner-only) / Profile / APEX Import (owner-only) / Audit log (owner-only) / Settings. Theme = light/dark, 7 accents, 4 neutral palettes (Warm/Slate/Graphite/Zinc), Geist font -- all in Settings > Appearance (D29) |
| Frontend | `apps/hq-web/src` | React + Vite. **Home** card launcher is the landing (`/`); Dashboard (My Day) is at `/dashboard` (D30). Pages: Home / Dashboard / Pipeline / Reminders / Tickets / Onboarding / Clients / ClientDetail / Documents / NewDocument / DocumentView / Renewals / Modules / Module data / Portals / Reports / MIS cockpit (owner-only) / SMS credits / Employees (owner-only) / Profile / APEX Import (owner-only) / Audit log (owner-only) / Login history (owner-only) / Settings. Theme = light/dark, 7 accents, 4 neutral palettes (Warm/Slate/Graphite/Zinc), Geist font -- all in Settings > Appearance (D29) |
| Repos | `apps/hq/src/repos-*.ts` | one file per domain; plain functions over a `DB` handle |
| Schema | `apps/hq/src/db.ts` + `apps/hq/schema.sql` | `CREATE TABLE IF NOT EXISTS` + additive `migrate()` |
| Shared packages | `packages/*` | trimmed forks — see below |
@ -70,6 +70,11 @@ cd apps/hq-web && npm run dev
- **SMS gateway** (D28): the SMS module's per-client balance is pulled live from the provider
panel (`sms.balance_api_url`, keyless) — manual "Refresh from gateway" + a daily scheduler
pass; low balances surface on the SMS credits screen, cockpit tile and dashboard alert.
- **Clients carry a type + category** (D35/D37): `client_type` (PACS/Store) and `category`
(Kerala co-op taxonomy — Service Bank / Vanitha / Housing / Employees / …), both derived from
the name and filterable on the Clients screen. **Employees** log in by username; imported staff
get a default `<name>@123` with a `must_change_password` "Invited" flag (D34). **Login history**
(D36): every sign-in is logged with IP + device (`login_event`), owner-viewable.
- Postgres integration tests: `HQ_PG_TEST_URL=postgres://hq:hq_dev@localhost:5432/hq_test npm test`
(wipes that database each run — never point it at real data).
- Health check: `GET /api/health` → `{ ok: true }`.

@ -39,6 +39,11 @@ document and conversation we've had with them.
- **Renewals** command center, an owner **MIS cockpit**, an **audit-log viewer**, and
**database backups** (`npm run backup` → compressed, restorable `pg_dump`). Credentials are
stored in the clear (no `HQ_SECRET_KEY`), so the DB and its backups are the security boundary.
- **Employees** with per-user logins (imported from the master; an "invited" flag until they
change their default password), and a **login history** (every sign-in with IP + device).
- **Client classification** — each client is a **PACS** or **Store**, and carries a **society
category** matching the Kerala co-op directory (Service Bank / Vanitha / Housing / Employees /
…); both are filterable on the Clients screen.
## Build state (start here)
@ -58,7 +63,7 @@ runbook (server, HTTPS, backups, Gmail/AWS/import wiring, the Postgres switch) i
- **Frontend — `apps/hq-web`**: React + Vite. Pages: Home, Dashboard, Pipeline, Reminders,
Tickets, Onboarding, Clients, ClientDetail, Documents, NewDocument, DocumentView, Renewals,
SMS credits, Modules, Module data, Portals, Reports, MIS cockpit (owner), Employees (owner),
Profile, APEX Import (owner), Audit log (owner), Settings.
Profile, APEX Import (owner), Audit log (owner), Login history (owner), Settings.
- **Shared packages** (trimmed forks): `@sims/domain` (ids, money, business-day, doc-series,
gstin, documents), `@sims/auth` (pin/scrypt), `@sims/billing-engine` (compute, tax — our
invoices are GST documents, computed to the paisa), `@sims/ui` (design system).

@ -1,7 +1,7 @@
# SiMS HQ — Build Status
_Internal ops console for running our software business. Not shipped to clients._
_Last reviewed: 2026-07-19 · Version 0.1.0_
_Last reviewed: 2026-07-20 · Version 0.1.0_
HQ is the back-office console we use to run ~300 Classic client relationships:
the client book, our software module catalogue and dated price book, quotations /
@ -35,6 +35,18 @@ clear** — `HQ_SECRET_KEY` was removed, so portal/gateway passwords, modul
passwords and the Gmail token are plaintext and keyless (login passwords stay scrypt-hashed);
a DB dump exposes them, so the DB file and its backups are the security boundary.
**Since 2026-07-19 (D34–D37).** **Employee master imported** (11 staff; username = first name,
default password `<name>@123`) with a `must_change_password` "Invited" flag that clears on the
first self password-change (D34). Clients gained a **type** (PACS vs Store) and a **society
category** matching the Kerala co-op directory taxonomy (Service Bank / Urban / Vanitha /
Housing / Employees / Agricultural / …), both derived from the name and filterable on the
Clients screen (D35/D37). A founder-approved **data-correction session** cleaned 187 client
names and appended **LTD/registration numbers to 141 PACS**, sourced from the Kerala govt co-op
directory via a multi-agent web-lookup (D35). **Login history** (D36): every sign-in attempt
(success/failed/locked) is logged with IP + device in a `login_event` table, viewable on a new
owner **Login history** screen; an invited user is sent to Profile to change their default
password on first sign-in.
## Verification state
| Check | Result |
@ -82,7 +94,7 @@ live under `apps/hq/test/`, `apps/hq-web/test/`, and
## Database schema (`apps/hq/src/db.ts`)
The schema creates 31 tables on open; `migrate()` additively backfills the
The schema creates 32 tables on open; `migrate()` additively backfills the
post-launch columns on older DBs (`module.quote_content`, `email_log.bounced`,
`document.due_date`, `staff_user.phone`/`title`, `client.owner_id` plus the
D18 support fields `anydesk`/`os`/`district`/`sector`/`db_password_enc`, and
@ -95,7 +107,8 @@ and widens two CHECK constraints via a guarded, transactional table rebuild
| --- | --- |
| `staff_user` | Console logins / employees — owner · manager · staff role, scrypt salt+hash, active flag |
| `session` | Bearer session tokens with expiry |
| `client` | Client registry — code, name, GSTIN, state, contacts (JSON), status, account owner (`owner_id` → `staff_user`), source |
| `login_event` | Login attempt log — staff_id/username, outcome (success/failed/locked), IP, user-agent (D36) |
| `client` | Client registry — code, name, GSTIN, state, contacts (JSON), status, account owner (`owner_id`), `client_type` (PACS/Store, D35), `category` (society type, D37), source |
| `module` | Software module catalogue — SAC, allowed billing kinds, multi-sub flag, quote-content lines |
| `module_price_book` | Dated prices per module/edition/kind (latest effective row wins) |
| `client_module` | A module assigned to a client — lifecycle status, install/complete/train dates, next renewal, provider/username/password (plaintext, D32), module-defined `field_values` (D21) |
@ -296,6 +309,7 @@ command palette, Gmail status pill, theme/accent switcher and a user menu
| `/portals` | `Portals` | Every stored login across all modules — Open ↗ links, 🔑 = password on file (reveal on Client 360) |
| `/mis` | `Mis` | Owner cockpit: this-month billing / collections / outstanding / pipeline / renewals / tickets / SMS-low, each tile click-through |
| `/audit` | `Audit` | Owner audit-log viewer: entity/action filters (from server facets), paginated before/after key-diff |
| `/login-history` | `LoginHistory` | Owner login history: every sign-in (success/failed/locked) with IP + parsed device, outcome filter, paginated (D36) |
| `/settings` | `Settings` | Settings hub (Pinterest-style left rail, `?s=` deep-links): Company profile · Document template (live preview) · Reminders (dated cadence rows, append-only) · Sharing default expiry · Integrations status · Appearance. Owner-only except Appearance; `/settings/template` redirects here |
| `/employees` | `Employees` | Owner-only staff management: add / edit / re-role / reset password / deactivate / reactivate, surfacing the last-owner and self-deactivation guards |
| `/profile` | `Profile` | Self-service: edit display name/phone (email + role read-only), change own password (current password required; other sessions purged) |

@ -217,11 +217,13 @@ export interface Payment {
id: string; clientId: string; receivedOn: string; mode: PaymentMode
reference: string; amountPaise: number; tdsPaise: number
createdBy: string; createdAt: string
allocations: { docNo: string; amountPaise: number }[]
}
export interface Ledger {
documents: Doc[]; payments: Payment[]; advancePaise: number
modulePaid: { moduleId: string; billedPaise: number; settledPaise: number }[]
outstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[]
}
// ---------- typed calls ----------

@ -483,3 +483,41 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse
}
.no-print { display: none !important; }
}
/* ---- Client Detail redesign (D-CDR) ---- */
/* 50/50 service sheet */
.cdr-split { display: grid; grid-template-columns: 1fr 1fr; gap: 0 30px; }
.cdr-split > .col { padding: 13px 0; min-width: 0; }
.cdr-split > .col + .col { border-left: 1px solid var(--border); padding-left: 30px; }
.cdr-kv { display: flex; align-items: baseline; gap: 10px; padding: 6px 0; min-width: 0; }
.cdr-kv + .cdr-kv { border-top: 1px dashed var(--border); }
.cdr-kv > .k { flex: none; width: 120px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-dim); font-weight: 600; padding-top: 2px; }
.cdr-kv > .v { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.cdr-kv > .v .txt { overflow: hidden; text-overflow: ellipsis; }
@media (max-width: 720px) {
.cdr-split { grid-template-columns: 1fr; }
.cdr-split > .col + .col { border-left: none; padding-left: 0; border-top: 1px solid var(--border); }
}
/* horizontal onboarding status line */
.cdr-track-wrap { overflow-x: auto; padding: 10px 2px 4px; }
.cdr-track { display: flex; min-width: min-content; }
.cdr-node { flex: 1 0 112px; display: flex; flex-direction: column; align-items: center; text-align: center; position: relative; padding: 0 4px; }
.cdr-node .bar { position: absolute; top: 11px; right: 50%; width: 100%; height: 2px; background: var(--border); }
.cdr-node.done .bar, .cdr-node.now .bar { background: var(--accent); }
.cdr-node:first-child .bar { display: none; }
.cdr-node .dot { position: relative; z-index: 1; width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--border-strong); background: var(--bg-raised); display: grid; place-items: center; font-size: 11px; color: transparent; cursor: pointer; }
.cdr-node.done .dot { background: var(--ok); border-color: var(--ok); color: #fff; }
.cdr-node.now .dot { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
.cdr-node .nm { font-size: 11px; margin-top: 7px; max-width: 104px; line-height: 1.35; }
.cdr-node.done .nm { color: var(--text-dim); }
.cdr-node.now .nm { font-weight: 600; }
.cdr-node .dt { font-size: 10px; color: var(--text-dim); font-family: var(--mono); margin-top: 3px; }
/* outstanding panel */
.cdr-out { border: 1px solid color-mix(in srgb, var(--warn) 30%, var(--border)); border-radius: var(--radius); overflow: hidden; }
.cdr-out .oh { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
.cdr-out .orow { display: flex; align-items: center; gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 12.5px; }
.cdr-out .orow:last-child { border-bottom: none; }
.cdr-out .orow .due { margin-left: auto; font-family: var(--mono); font-weight: 600; }
.cdr-out .ofoot { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--bg-inset); }
/* document-type pill */
.doc-pill { font-size: 10.5px; font-weight: 600; letter-spacing: .3px; border-radius: 999px; padding: 1px 8px; border: 1px solid var(--border); color: var(--text-dim); background: var(--bg-inset); }

@ -1,4 +1,4 @@
import { Fragment, useState, type CSSProperties, type ReactNode } from 'react'
import { useState, type CSSProperties, type ReactNode } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import {
@ -959,23 +959,27 @@ function BranchList(props: { clientId: string; branches: Branch[]; onChanged: ()
}
/**
* Per-service operational data (D20 APEX sms_clients_list parity), following
* SupportCard's exact visual pattern: label/value cells, the portal password as
* with managerial audited reveal (gated like the DB password), and Edit
* switching provider/username/remark + the labeled details into inputs one
* audited PATCH saves the lot.
* Per-service operational data (D20 APEX sms_clients_list parity) + D21 module-defined
* fields, rendered as a 50/50 report (Access | Details D-CDR): read-only key/value rows,
* the portal password as with managerial audited reveal (gated like the DB password),
* secret fieldSpec values as their own inline SecretField, and ONE Edit per column that
* swaps the whole card into a single form provider/username/remark/password + the
* freeform details + the non-secret fieldSpec values all save through one Save (two
* audited calls in sequence: patchClientModule for D20, setModuleFields for D21).
*/
function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: FieldDef[]; onChanged: () => void }) {
const toast = useToast()
const managerial = isManagerial()
const cm = props.cm
const sortedFieldSpec = [...props.fieldSpec].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
const nonSecretFields = sortedFieldSpec.filter((fld) => fld.type !== 'secret')
const secretFields = sortedFieldSpec.filter((fld) => fld.type === 'secret')
const [revealed, setRevealed] = useState<string | undefined>()
const [settingPw, setSettingPw] = useState(false)
const [pw, setPw] = useState('')
const [busy, setBusy] = useState(false)
const [editing, setEditing] = useState(false)
const [f, setF] = useState({ provider: '', username: '', remark: '', password: '' })
const [details, setDetails] = useState<ServiceDetail[]>([])
const [fieldDraft, setFieldDraft] = useState<Record<string, string>>({})
const copy = (text: string, what: string) => {
navigator.clipboard.writeText(text).then(() => toast.ok(`${what} copied`)).catch(() => toast.err('Copy failed'))
@ -988,42 +992,68 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const savePw = () => {
if (busy || pw === '') return
setBusy(true)
patchClientModule(cm.id, { password: pw })
.then(() => { toast.ok('Portal password stored (encrypted)'); setSettingPw(false); setPw(''); setRevealed(undefined); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const startEdit = () => {
setF({ provider: cm.provider ?? '', username: cm.username ?? '', remark: cm.remark ?? '', password: '' })
setDetails(cm.details.map((d) => ({ ...d })))
setFieldDraft(Object.fromEntries(nonSecretFields.map((fld) => [fld.key, cm.fieldValues[fld.key] ?? ''])))
setEditing(true)
}
const saveEdit = () => {
if (busy) return
const cleaned = details.filter((d) => d.label.trim() !== '' || d.value.trim() !== '')
if (cleaned.some((d) => d.label.trim() === '')) { toast.err('Every detail needs a label'); return }
const missingRequired = nonSecretFields.find((fld) => fld.required === true && (fieldDraft[fld.key] ?? '').trim() === '')
if (missingRequired !== undefined) { toast.err(`${missingRequired.label} is required`); return }
setBusy(true)
// Only the fieldSpec keys that actually changed — '' clears a value server-side.
const changedFields = Object.fromEntries(
nonSecretFields
.filter((fld) => (fieldDraft[fld.key] ?? '') !== (cm.fieldValues[fld.key] ?? ''))
.map((fld) => [fld.key, fieldDraft[fld.key] ?? '']),
)
patchClientModule(cm.id, {
provider: f.provider, username: f.username, remark: f.remark,
details: cleaned.map((d) => ({ label: d.label.trim(), value: d.value })),
// Password only when the managerial user typed one (blank = keep current).
...(managerial && f.password.trim() !== '' ? { password: f.password } : {}),
})
.then(() => { toast.ok('Service data saved'); setEditing(false); props.onChanged() })
.then(() => (Object.keys(changedFields).length > 0 ? setModuleFields(cm.id, changedFields) : undefined))
.then(() => { toast.ok('Service data saved'); setEditing(false); setRevealed(undefined); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const cell = (label: string, value: string | undefined, mono = false, copyable = false) => (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6, marginRight: 18 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>{label}</span>
<span className={mono ? 'mono' : undefined}>{value ?? '—'}</span>
{copyable && value !== undefined && <Button onClick={() => copy(value, label)}>Copy</Button>}
</span>
// D21 non-secret fieldSpec input — moved here from the old always-on ModuleFieldsForm so
// it saves alongside D20 through this one Edit/Save. Secret fields keep their own inline
// SecretField widget (independent Set/Update/Reveal) and stay out of this form.
const fieldControl = (fld: FieldDef) => {
const v = fieldDraft[fld.key] ?? ''
const set = (val: string) => setFieldDraft((p) => ({ ...p, [fld.key]: val }))
if (fld.type === 'boolean') {
return (
<select className="wf" style={{ width: 120 }} value={v} onChange={(e) => set(e.target.value)}>
<option value=""></option>
<option value="true">true</option>
<option value="false">false</option>
</select>
)
}
if (fld.type === 'select') {
return (
<select className="wf" style={{ width: 180 }} value={v} onChange={(e) => set(e.target.value)}>
<option value=""></option>
{(fld.options ?? []).map((o) => <option key={o} value={o}>{o}</option>)}
</select>
)
}
if (fld.type === 'number') {
return <input className="wf num" type="number" style={{ width: 140 }} value={v} onChange={(e) => set(e.target.value)} />
}
if (fld.type === 'date') {
return <input className="wf" type="date" style={{ width: 160 }} value={v} onChange={(e) => set(e.target.value)} />
}
return <input className="wf" style={{ width: 200 }} value={v} onChange={(e) => set(e.target.value)} />
}
if (editing) {
return (
@ -1040,6 +1070,29 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
)}
<Field label="Remark"><input className="wf" style={{ width: 220 }} value={f.remark} onChange={(e) => setF((p) => ({ ...p, remark: e.target.value }))} /></Field>
</div>
{nonSecretFields.length > 0 && (
<>
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0 10px' }} />
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 10 }}>
{nonSecretFields.map((fld) => {
// A field holding an http(s) value gets a direct "Open ↗" link (portal/console shortcut).
const val = fieldDraft[fld.key] ?? ''
const isUrl = /^https?:\/\//i.test(val.trim())
return (
<Field key={fld.key} label={fld.required === true ? `${fld.label} *` : fld.label}>
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
{fieldControl(fld)}
{isUrl && (
<a href={val.trim()} target="_blank" rel="noopener noreferrer"
title="Open in a new tab" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>Open </a>
)}
</span>
</Field>
)
})}
</div>
</>
)}
{details.map((d, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input
@ -1065,145 +1118,59 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; fieldSpec: Fie
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<span style={{ fontWeight: 600, marginRight: 10 }}>{props.name}</span>
{cell('Provider', cm.provider ?? undefined)}
{cell('Username', cm.username ?? undefined, true, true)}
{cm.details.map((d, i) => <Fragment key={i}>{cell(d.label, d.value)}</Fragment>)}
{cell('Remark', cm.remark ?? undefined)}
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>Portal password</span>
<div className="cdr-split">
<div className="col">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="lbl" style={{ fontWeight: 700 }}>Access</span>
<span style={{ flex: 1, height: 1, background: 'var(--border)' }} />
<Button onClick={startEdit}>Edit</Button>
</div>
<Kv label="Provider" value={cm.provider ?? undefined} />
<Kv label="Login" value={cm.username ?? undefined} mono copyable onCopy={() => copy(cm.username ?? '', 'Username')} />
<div className="cdr-kv">
<span className="k">Password</span>
<span className="v">
{revealed !== undefined
? <><span className="mono">{revealed}</span><Button onClick={() => copy(revealed, 'Portal password')}>Copy</Button><Button onClick={() => setRevealed(undefined)}>Hide</Button></>
: cm.hasPassword
? <><span className="mono"></span>{managerial && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</>
: <span style={{ opacity: 0.6 }}>not set</span>}
{managerial && !settingPw && <Button onClick={() => setSettingPw(true)}>{cm.hasPassword ? 'Update' : 'Set…'}</Button>}
{managerial && settingPw && (
<>
<input className="wf mono" type="password" style={{ width: 160 }} placeholder="Portal password" aria-label="Portal password"
value={pw} onChange={(e) => setPw(e.target.value)} />
<Button tone="primary" onClick={savePw}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => { setSettingPw(false); setPw('') }}>Cancel</Button>
</>
)}
</span>
<span style={{ flex: 1 }} />
</div>
{cm.remark !== null && cm.remark !== '' && <Kv label="Remark" value={cm.remark} />}
</div>
<div className="col">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="lbl" style={{ fontWeight: 700 }}>Details</span>
<span style={{ flex: 1, height: 1, background: 'var(--border)' }} />
<Button onClick={startEdit}>Edit</Button>
</div>
{/* D21: dynamic form from the owning module's fieldSpec, below the D20 block. */}
{props.fieldSpec.length > 0 && (
<>
<div style={{ borderTop: '1px solid var(--border)', margin: '12px 0 10px' }} />
<ModuleFieldsForm cm={cm} fieldSpec={props.fieldSpec} onChanged={props.onChanged} />
</>
)}
{nonSecretFields.map((fld) => (
<Kv key={fld.key} label={fld.label} value={cm.fieldValues[fld.key]} />
))}
{cm.details.map((d, i) => <Kv key={`d${i}`} label={d.label} value={d.value} />)}
{secretFields.map((fld) => (
<div className="cdr-kv" key={fld.key}>
<span className="k">{fld.label}</span>
<span className="v"><SecretField cm={cm} field={fld} managerial={managerial} onChanged={props.onChanged} /></span>
</div>
))}
</div>
</div>
</div>
)
}
/**
* D21 module-defined fields (config over code): renders one input per field the owning
* module DECLARES in its fieldSpec. Non-secret edits collect into a single audited PUT
* (setModuleFields); secret fields never leave the server unrevealed with a
* managerial audited Reveal + Set/Update (setModuleSecret), exactly like the portal
* password. Values come from cm.fieldValues; secret set-state from cm.secretKeys.
*/
function ModuleFieldsForm(props: { cm: ClientModule; fieldSpec: FieldDef[]; onChanged: () => void }) {
const toast = useToast()
const managerial = isManagerial()
const cm = props.cm
const sorted = [...props.fieldSpec].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
const nonSecret = sorted.filter((f) => f.type !== 'secret')
const secrets = sorted.filter((f) => f.type === 'secret')
const seed = (): Record<string, string> =>
Object.fromEntries(nonSecret.map((f) => [f.key, cm.fieldValues[f.key] ?? '']))
const [draft, setDraft] = useState<Record<string, string>>(seed)
const [busy, setBusy] = useState(false)
const dirty = nonSecret.some((f) => (draft[f.key] ?? '') !== (cm.fieldValues[f.key] ?? ''))
const missingRequired = nonSecret.find((f) => f.required === true && (draft[f.key] ?? '').trim() === '')
const save = () => {
if (busy) return
if (missingRequired !== undefined) { toast.err(`${missingRequired.label} is required`); return }
setBusy(true)
// Send only the changed keys; '' clears server-side.
const values = Object.fromEntries(
nonSecret
.filter((f) => (draft[f.key] ?? '') !== (cm.fieldValues[f.key] ?? ''))
.map((f) => [f.key, draft[f.key] ?? '']),
)
setModuleFields(cm.id, values)
.then((updated) => {
toast.ok('Fields saved')
setDraft(Object.fromEntries(nonSecret.map((f) => [f.key, updated.fieldValues[f.key] ?? ''])))
props.onChanged()
})
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusy(false))
}
const control = (f: FieldDef) => {
const v = draft[f.key] ?? ''
const set = (val: string) => setDraft((p) => ({ ...p, [f.key]: val }))
if (f.type === 'boolean') {
return (
<select className="wf" style={{ width: 120 }} value={v} onChange={(e) => set(e.target.value)}>
<option value=""></option>
<option value="true">true</option>
<option value="false">false</option>
</select>
)
}
if (f.type === 'select') {
return (
<select className="wf" style={{ width: 180 }} value={v} onChange={(e) => set(e.target.value)}>
<option value=""></option>
{(f.options ?? []).map((o) => <option key={o} value={o}>{o}</option>)}
</select>
)
}
if (f.type === 'number') {
return <input className="wf num" type="number" style={{ width: 140 }} value={v} onChange={(e) => set(e.target.value)} />
}
if (f.type === 'date') {
return <input className="wf" type="date" style={{ width: 160 }} value={v} onChange={(e) => set(e.target.value)} />
}
return <input className="wf" style={{ width: 200 }} value={v} onChange={(e) => set(e.target.value)} />
}
return (
<div>
{nonSecret.length > 0 && (
<>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'flex-end' }}>
{nonSecret.map((f) => {
// A field holding an http(s) value gets a direct "Open ↗" link (portal/console shortcut).
const val = draft[f.key] ?? ''
const isUrl = /^https?:\/\//i.test(val.trim())
/** One label/value row in the 50/50 service-data report; `—` for empty, optional mono + copy. */
function Kv(props: { label: string; value: string | undefined; mono?: boolean; copyable?: boolean; onCopy?: () => void }) {
const v = props.value
return (
<Field key={f.key} label={f.required === true ? `${f.label} *` : f.label}>
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
{control(f)}
{isUrl && (
<a href={val.trim()} target="_blank" rel="noopener noreferrer"
title="Open in a new tab" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>Open </a>
)}
<div className="cdr-kv">
<span className="k">{props.label}</span>
<span className="v">
{v !== undefined && v !== '' ? <span className={`txt${props.mono === true ? ' mono' : ''}`}>{v}</span> : <span style={{ opacity: 0.5 }}></span>}
{props.copyable === true && v !== undefined && v !== '' && <Button onClick={props.onCopy}>Copy</Button>}
</span>
</Field>
)
})}
</div>
<Toolbar>
<Button tone="primary" disabled={busy || !dirty} onClick={save}>{busy ? 'Saving…' : 'Save fields'}</Button>
</Toolbar>
</>
)}
{secrets.map((f) => (
<SecretField key={f.key} cm={cm} field={f} managerial={managerial} onChanged={props.onChanged} />
))}
</div>
)
}

@ -0,0 +1,19 @@
// apps/hq/scripts/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time
// migration swapping existing SMS/RTGS/MobileApp projects off the old generic onboarding
// checklist onto their new per-module template, carrying mapped ticks. Idempotent — safe
// to re-run (a second run does nothing).
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/migrate-onboarding-templates.ts
import { openDb } from '../src/db'
import { openPgDb } from '../src/db-pg'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
async function main() {
const pgUrl = process.env['DATABASE_URL'] ?? ''
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
const res = await migrateOnboardingTemplates(db)
// eslint-disable-next-line no-console
console.log(`Onboarding template migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped.`)
}
main().catch((e) => { console.error(e); process.exit(1) })

@ -60,7 +60,7 @@ import {
import { pullMonthlyCosts } from './aws-costs'
import { clientProfitability, duesAging, gstSummary, moduleRevenue, type DateRange } from './repos-reports'
import { listUsageRateCards, setUsageRateCard } from './repos-rate-card'
import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals'
import { renewalsDue, generateModuleRenewalQuote, generateBulkSmsPurchaseQuote } from './repos-renewals'
import { misCockpit } from './repos-mis'
import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms'
import { globalSearch } from './repos-search'
@ -70,7 +70,7 @@ import { assertSafeGatewayUrl } from './sms-gateway'
import { makeRateLimiter } from './rate-limit'
import {
addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard,
projectsPendingMilestone, setMilestone,
projectsPendingMilestone, setMilestone, stalledProjects,
} from './repos-milestones'
import { commitImport, stageCsv, verificationReport } from './import-apex'
import { documentHtml, documentHtmlSample } from './templates'
@ -731,7 +731,7 @@ export function apiRouter(
res.status(404).json({ ok: false, error: 'Client module not found' }); return
}
try {
const body = req.body as { key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown }
const body = req.body as { key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown; paymentId?: unknown }
if (typeof body.label === 'string') {
// Add a project-specific milestone.
res.json({ ok: true, milestones: await addProjectMilestone(db, staffId(res), id, body.label) })
@ -741,7 +741,8 @@ export function apiRouter(
throw new Error('Provide { key, done } to tick a milestone, or { label } to add one')
}
const milestones = await setMilestone(db, staffId(res), id, body.key, body.done,
typeof body.doneOn === 'string' ? body.doneOn : undefined)
typeof body.doneOn === 'string' ? body.doneOn : undefined,
typeof body.paymentId === 'string' ? body.paymentId : undefined)
res.json({ ok: true, milestones })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
@ -754,6 +755,14 @@ export function apiRouter(
r.get('/projects/pending/:key', requireAuth, async (req, res) => {
res.json({ ok: true, projects: await projectsPendingMilestone(db, String(req.params['key'] ?? '')) })
})
// Parked onboarding: active projects whose next step hasn't moved in a while (dashboard/MIS tile).
r.get('/projects/stalled', requireAuth, async (req, res) => {
const q = req.query['days']
const days = typeof q === 'string' && q !== '' && Number.isFinite(Number(q))
? Number(q) : await getNumberSetting(db, 'project.stall_days', 30)
const today = new Date().toISOString().slice(0, 10)
res.json({ ok: true, projects: await stalledProjects(db, days, today) })
})
// ---------- client branches (D20) ----------
r.get('/clients/:id/branches', requireAuth, async (req, res) => {
@ -1664,6 +1673,16 @@ export function apiRouter(
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// Ad-hoc bulk SMS credit purchase (top-up) — distinct from a subscription renewal.
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) })
}
})
// ---------- bulk operations (D26 — cleanup at scale, owner-only) ----------
r.post('/clients/bulk', requireAuth, requireOwner, async (req, res) => {

@ -516,6 +516,11 @@ function migrate(db: SqliteRaw): void {
if (!modCols2.some((c) => c.name === 'sort_order')) {
db.exec(`ALTER TABLE module ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 100`)
}
// Client Detail redesign task 4: link an advance/balance payment-step tick to the real payment.
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`)
}
rebuildStaffUserRoleCheck(db)
rebuildReminderRuleKindCheck(db)
}

@ -0,0 +1,81 @@
// apps/hq/src/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time
// migration swapping seeded modules (SMS/RTGS/MobileApp) off the old generic onboarding
// checklist onto their new per-module template (task 2), carrying mapped ticks
// (done + done_on + payment_id) so onboarding history is not lost.
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
* (a `project.milestone_template:<CODE>` setting task 2), rebuild its milestone rows
* from that template, carrying mapped ticks (done/done_on/payment_id) forward under their
* new key. Unmapped old ticks (e.g. `amc_start`, or `setup_done` for a module whose new
* template has no matching step) are dropped (and counted). Client_modules whose module
* has no per-module template are left completely 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 }
}

@ -352,4 +352,9 @@ UPDATE client SET category = CASE
END;
`,
},
{
// Client Detail redesign task 4: link an advance/balance payment-step tick to the real payment.
id: '015-milestone-payment-id',
sql: `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS payment_id text;`,
},
]

@ -152,6 +152,8 @@ export interface DraftInput {
terms?: string
/** INVOICE only (D18): payment due date; when absent, issue stamps doc_date + terms. */
dueDate?: string
/** Provenance tag for the frontend's Quote/Renewal/Bulk-top-up label; default: schema default ('hq'). */
source?: string
}
const todayIso = (): string => new Date().toISOString().slice(0, 10)
@ -240,18 +242,18 @@ async function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warn
/** Insert a draft row + created event + audit. Callers wrap in a transaction. */
async function insertDocRow(db: DB, userId: string, a: {
docType: DocType; clientId: string; refDocId: string | null; docDate: string
totals: BillTotals; payload: DocPayload; dueDate?: string | null
totals: BillTotals; payload: DocPayload; dueDate?: string | null; source?: string
}): Promise<Doc> {
const id = uuidv7()
const t = a.totals
await db.run(
`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, created_by, created_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
payload, source, created_by, created_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.dueDate ?? null, a.refDocId,
t.taxablePaise, t.cgstPaise, t.sgstPaise, t.igstPaise, t.roundOffPaise, t.payablePaise,
JSON.stringify(a.payload), userId, new Date().toISOString())
JSON.stringify(a.payload), a.source ?? 'hq', userId, new Date().toISOString())
await addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {})
const doc = (await getDocument(db, id))!
await writeAudit(db, userId, 'create', 'document', id, undefined, {
@ -334,7 +336,7 @@ export async function createDraft(db: DB, userId: string, input: DraftInput): Pr
return db.transaction(() => insertDocRow(db, userId, {
docType: prepared.docType, clientId: prepared.clientId, refDocId: null,
docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload,
dueDate: prepared.dueDate ?? null,
dueDate: prepared.dueDate ?? null, source: input.source,
}))
}

@ -9,11 +9,34 @@ import type { DB } from './db'
* across the whole book: "who's pending go-live", "who hasn't paid the advance".
*/
export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number }
interface MilestoneRow { key: string; label: string; done: number; done_on: string | null; sort: number }
export interface Milestone {
key: string; label: string; done: boolean; doneOn: string | null; sort: number; paymentId: string | null
}
interface MilestoneRow {
key: string; label: string; done: number; done_on: string | null; sort: number; payment_id: string | null
}
export interface TemplateEntry { key: string; label: string }
/** 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 })
}
export const FALLBACK_TEMPLATE: TemplateEntry[] = [
{ key: 'advance_paid', label: 'Advance payment received' },
{ key: 'setup_done', label: 'Setup / configuration done' },
@ -73,32 +96,53 @@ export async function ensureProjectMilestones(db: DB, clientModuleId: string): P
export async function listProjectMilestones(db: DB, clientModuleId: string): Promise<Milestone[]> {
const rows = await db.all<MilestoneRow>(
`SELECT key, label, done, done_on, sort FROM project_milestone
`SELECT key, label, done, done_on, sort, payment_id FROM project_milestone
WHERE client_module_id=? ORDER BY sort, label`,
clientModuleId,
)
return rows.map((r) => ({ key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort }))
return rows.map((r) => ({
key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort, paymentId: r.payment_id,
}))
}
/** Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears it. Audited. */
/**
* Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears
* it (and any linked payment). A payment-step tick (advance_payment / balance_payment) can
* pass `paymentId` to link a real payment it must belong to the same client as this
* project, and its `received_on` mirrors into `done_on` unless an explicit `doneOn` is
* also given. Audited.
*/
export async function setMilestone(
db: DB, userId: string, clientModuleId: string, key: string, done: boolean, doneOn?: string,
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 FROM project_milestone WHERE client_module_id=? AND key=?`,
`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')
}
const stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null
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=? WHERE client_module_id=? AND key=?`,
done ? 1 : 0, stamp, clientModuleId, key,
`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 })
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)
})
}
@ -192,3 +236,30 @@ export async function projectsPendingMilestone(db: DB, key: string): Promise<Pen
key,
)
}
/**
* Active projects with at least one pending step whose progress last moved more than
* `olderThanDays` ago (measured against `today`, passed in never `new Date()` here, so this
* stays testable). "Last moved" is the most recent `done_on` across the project's ticked
* steps; a project with nothing ticked yet counts as parked since creation (no lower bound).
* Non-aggregated columns are wrapped in MIN() grouping by `pm.client_module_id` alone isn't
* enough for Postgres to infer they're single-valued per group (D15 portability).
*/
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", MIN(cm.client_id) AS "clientId",
MIN(c.name) AS "clientName", MIN(m.code) AS "moduleCode", MIN(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 * 86_400_000).toISOString().slice(0, 10)
return rows
.filter((r) => (r.last_done ?? '0000-00-00') < cutoff)
.map(({ last_done: _last_done, ...rest }) => rest)
}

@ -231,7 +231,13 @@ export async function receiptForPayment(db: DB, userId: string, paymentId: strin
// ---------- read views ----------
export interface ClientLedger { documents: Doc[]; payments: Payment[]; advancePaise: number }
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[]
}
export async function clientLedger(db: DB, clientId: string): Promise<ClientLedger> {
const payments = await listPayments(db, clientId)
@ -249,10 +255,40 @@ export async function clientLedger(db: DB, clientId: string): Promise<ClientLedg
documents.push(...batch.documents)
if (page * batch.pageSize >= batch.total) break
}
// 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,
payments: ledgerPayments,
advancePaise: settling - row.total,
outstanding,
}
}

@ -72,7 +72,26 @@ export async function generateModuleRenewalQuote(db: DB, userId: string, clientM
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,
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 (top-up) a PROFORMA priced through the same
* createDraft path as any other document, so the usage rate card (D23) resolves the
* per-SMS rate for `smsQty` the same way a subscription line would. Tagged
* `source: 'bulk_sms_topup'` so it reads distinctly from a subscription renewal
* (`module_renewal`) on the client-detail document list.
*/
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 }],
})
}

@ -0,0 +1,74 @@
// apps/hq/test/bulk-sms-purchase.test.ts — Task 7: ad-hoc bulk-SMS top-up documents,
// tagged distinctly from subscription renewals via document.source.
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule, setPrice, updateClientModule } from '../src/repos-modules'
import { setUsageRateCard } from '../src/repos-rate-card'
import { generateModuleRenewalQuote, generateBulkSmsPurchaseQuote } from '../src/repos-renewals'
import { createDraft, getDocument } from '../src/repos-documents'
// The founder's real card: ₹0.40 / 0.37 / 0.34 / 0.30 per SMS = 40/37/34/30 paise.
const BANDS = [
{ minQty: 50_000, ratePaise: 40 },
{ minQty: 100_000, ratePaise: 37 },
{ minQty: 200_000, ratePaise: 34 },
{ minQty: 300_000, ratePaise: 30 },
]
async function world() {
const db = openDb(':memory:')
await seedIfEmpty(db) // company state 32, GST18
const c = await createClient(db, 'u1', { name: 'Karapuzha SCB', stateCode: '32' })
const sms = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS', allowedKinds: ['usage'] })
await setUsageRateCard(db, 'u1', sms.id, '2026-04-01', BANDS)
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: sms.id, kind: 'usage' })
return { db, c, sms, cm }
}
describe('generateBulkSmsPurchaseQuote', () => {
it('raises a proforma tagged bulk_sms_topup for the given SMS quantity', async () => {
const { db, cm } = await world()
const doc = await generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 100_000)
expect(doc.docType).toBe('PROFORMA')
expect(doc.source).toBe('bulk_sms_topup')
expect(doc.payload.lines[0]!.qty).toBe(100_000)
expect(doc.payablePaise).toBeGreaterThan(0)
// Persisted correctly — a re-fetch from the DB carries the same tag.
const full = (await getDocument(db, doc.id))!
expect(full.source).toBe('bulk_sms_topup')
})
it('rejects a non-positive or fractional quantity', async () => {
const { db, cm } = await world()
await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 0)).rejects.toThrow()
await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, -5)).rejects.toThrow()
await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 1.5)).rejects.toThrow()
})
it('rejects an unknown client module', async () => {
const { db } = await world()
await expect(generateBulkSmsPurchaseQuote(db, 'u1', 'nope', 100_000)).rejects.toThrow(/not found/)
})
})
describe('document.source tagging', () => {
it('tags a renewal proforma module_renewal, and a manual draft keeps the default', async () => {
const { db, c } = await world()
// A separate yearly-priced module for the renewal path (usage modules enforce a
// minimum-order qty that a qty:1 renewal line would fail).
const yearly = await createModule(db, 'u1', { code: 'CORE', name: 'Core App', allowedKinds: ['yearly'] })
await setPrice(db, 'u1', { moduleId: yearly.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
const cm2 = await assignModule(db, 'u1', { clientId: c.id, moduleId: yearly.id, kind: 'yearly' })
await updateClientModule(db, 'u1', cm2.id, { nextRenewal: '2026-08-15' })
const renewal = await generateModuleRenewalQuote(db, 'u1', cm2.id)
expect(renewal.source).toBe('module_renewal')
const manual = await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id,
lines: [{ moduleId: yearly.id, qty: 1, kind: 'yearly' }],
})
expect(manual.source).toBe('hq') // default preserved when source is omitted
})
})

@ -0,0 +1,86 @@
// apps/hq/test/ledger-outstanding.test.ts
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { createDraft, issueDocument, markStatus } from '../src/repos-documents'
import { recordPayment, clientLedger } from '../src/repos-payments'
/**
* Fixture: one client, one module priced 10,000/yr ( 11,800 with GST18).
* - INVOICE (qty 1): issued as INV/26-27-0001, payable 11,800.
* Partly paid 5,000 outstanding 6,800.
* - PROFORMA (qty 2): issued as PI/26-27-0001, payable 23,600, marked 'sent'
* (open nothing paid against a proforma).
*/
async function buildFixture(db: ReturnType<typeof openDb>) {
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})).id) // payable ₹11,800 (taxable ₹10,000 + GST18 ₹1,800)
const pf = await markStatus(db, 'u1', (await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }],
})).id)).id, 'sent') // payable ₹23,600 (taxable ₹20,000 + GST18 ₹3,600)
const { payment } = await recordPayment(db, 'u1', {
clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 5_000_00,
}) // ₹5,000 of ₹11,800 → outstanding ₹6,800
return { clientId: c.id, inv, pf, payment }
}
describe('clientLedger — outstanding + per-payment allocations', () => {
it('reports outstanding open documents oldest-first with correct due amounts', async () => {
const db = openDb(':memory:')
const { clientId, inv, pf } = await buildFixture(db)
const led = await clientLedger(db, clientId)
// INV/26-27-0001 sorts before PI/26-27-0001 (docNo lexical order, both oldest).
expect(led.outstanding.map((o) => o.docNo)).toEqual([inv.docNo, pf.docNo])
expect(led.outstanding[0]).toMatchObject({
docId: inv.id, docNo: inv.docNo, docType: 'INVOICE', label: 'POS',
outstandingPaise: 6_800_00, // 11,800 payable 5,000 allocated 0 credited
})
expect(led.outstanding[1]).toMatchObject({
docId: pf.id, docNo: pf.docNo, docType: 'PROFORMA', label: 'POS',
outstandingPaise: 23_600_00, // open proforma: full payable, nothing settled against it
})
})
it('excludes a fully-paid invoice and a cancelled/lost document from outstanding', async () => {
const db = openDb(':memory:')
const { clientId, inv } = await buildFixture(db)
// Settle the remaining 6,800 in full.
await recordPayment(db, 'u1', { clientId, receivedOn: '2026-07-11', mode: 'upi', amountPaise: 6_800_00 })
const led = await clientLedger(db, clientId)
expect(led.outstanding.find((o) => o.docId === inv.id)).toBeUndefined()
})
it('attaches allocations to each payment', async () => {
const db = openDb(':memory:')
const { clientId, inv, payment } = await buildFixture(db)
const led = await clientLedger(db, clientId)
const p = led.payments.find((x) => x.id === payment.id)!
expect(p.allocations).toEqual([{ docNo: inv.docNo, amountPaise: 5_000_00 }])
expect(p.allocations.reduce((s, a) => s + a.amountPaise, 0)).toBeLessThanOrEqual(p.amountPaise + p.tdsPaise)
})
it('a payment with no allocation (pure advance) reports an empty allocations array', async () => {
const db = openDb(':memory:')
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = await createClient(db, 'u1', { name: 'NoInvoiceCo', stateCode: '32' })
const { payment } = await recordPayment(db, 'u1', {
clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 1_000_00,
})
const led = await clientLedger(db, c.id)
expect(led.payments[0]!.id).toBe(payment.id)
expect(led.payments[0]!.allocations).toEqual([])
expect(led.outstanding).toEqual([])
})
})

@ -0,0 +1,182 @@
// apps/hq/test/migrate-onboarding-templates.test.ts — Client Detail redesign task 5: one-time
// migration swapping seeded modules (SMS/RTGS/MobileApp) from the old generic onboarding
// checklist onto their new per-module templates, carrying mapped ticks (done/done_on/payment_id).
import { describe, it, expect, beforeEach } from 'vitest'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule } from '../src/repos-modules'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
import { listProjectMilestones } from '../src/repos-milestones'
describe('onboarding template migration', () => {
let db: DB
beforeEach(async () => { db = openDb(':memory:'); await seedIfEmpty(db) })
async function makeSmsClientModule(): Promise<string> {
const client = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
const mod = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' })
// assignModule auto-seeds the NEW per-module template (task 2/4) — wipe it so the test
// can plant the OLD generic rows a legacy project would actually have on disk.
await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cm.id)
return cm.id
}
async function seedOldGenericRows(cmId: string): Promise<void> {
const rows: [string, number, string | null][] = [
['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],
]
for (const [i, r] of rows.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,
)
}
}
it('swaps an SMS project to the 9-step list carrying installed+go_live ticks', async () => {
const cmId = await makeSmsClientModule()
await seedOldGenericRows(cmId)
const res = await migrateOnboardingTemplates(db)
expect(res.projects).toBe(1)
expect(res.carried).toBe(2) // installed -> installation, go_live -> go_live
expect(res.dropped).toBe(0) // the two dropped keys (setup_done, amc_start) were both undone
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(installation.paymentId).toBeNull()
const goLive = ms.find((m) => m.key === 'go_live')!
expect(goLive.done).toBe(true)
expect(goLive.doneOn).toBe('2026-04-02')
// untouched (never ticked) steps stay pending
expect(ms.find((m) => m.key === 'document_collection')!.done).toBe(false)
expect(ms.find((m) => m.key === 'advance_payment')!.done).toBe(false)
expect(ms.find((m) => m.key === 'balance_payment')!.done).toBe(false)
// old setup_done / amc_start gone entirely
expect(ms.some((m) => m.key === 'setup_done' || m.key === 'amc_start')).toBe(false)
// audited
const audits = await db.all<{ entity_id: string; after_json: string | null }>(
`SELECT entity_id, after_json FROM audit_log WHERE action='migrate_onboarding'`,
)
expect(audits).toHaveLength(1)
expect(audits[0]!.entity_id).toBe(cmId)
expect(audits[0]!.after_json).toContain('installation')
})
it('carries a done payment_id along with the mapped tick', async () => {
const cmId = await makeSmsClientModule()
const client = await db.get<{ client_id: string }>(
`SELECT client_id FROM client_module WHERE id=?`, cmId,
)
const paymentId = 'pay-1'
await db.run(
`INSERT INTO payment (id, client_id, received_on, mode, amount_paise, created_by, created_at)
VALUES (?, ?, '2026-03-01', 'bank', 500000, 'u1', '2026-03-01T00:00:00.000Z')`,
paymentId, client!.client_id,
)
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort)
VALUES (?,?,?,?,?,?,?,?)`,
'm0', cmId, 'advance_paid', 'advance_paid', 1, '2026-03-01', paymentId, 0,
)
await migrateOnboardingTemplates(db)
const ms = await listProjectMilestones(db, cmId)
const advance = ms.find((m) => m.key === 'advance_payment')!
expect(advance.done).toBe(true)
expect(advance.doneOn).toBe('2026-03-01')
expect(advance.paymentId).toBe(paymentId)
})
it('is idempotent (second run changes nothing new)', async () => {
const cmId = await makeSmsClientModule()
await seedOldGenericRows(cmId)
const first = await migrateOnboardingTemplates(db)
expect(first.projects).toBe(1)
const afterFirst = await listProjectMilestones(db, cmId)
const second = await migrateOnboardingTemplates(db)
expect(second.projects).toBe(0)
expect(second.carried).toBe(0)
expect(second.dropped).toBe(0)
const afterSecond = await listProjectMilestones(db, cmId)
expect(afterSecond).toEqual(afterFirst)
// no extra audit rows written on the no-op second pass
const audits = await db.all<{ n: number }>(
`SELECT COUNT(*) AS n FROM audit_log WHERE action='migrate_onboarding'`,
)
expect((audits[0] as unknown as { n: number }).n).toBe(1)
})
it('counts ticked dropped keys (unmapped or not in new template)', async () => {
const cmId = await makeSmsClientModule()
// Insert old generic rows with two ticked keys that will be dropped:
// - amc_start: unmapped (not in KEY_MAP)
// - setup_done: maps to 'setup_configuration', but SMS template doesn't have it
// Plus one carried key (installed -> installation) to verify carried count too.
const rows: [string, number, string | null][] = [
['amc_start', 1, '2026-02-15'], // ticked, unmapped -> dropped
['setup_done', 1, '2026-03-01'], // ticked, maps to setup_configuration but SMS template lacks it -> dropped
['installed', 1, '2026-03-25'], // ticked, maps to installation which IS in SMS template -> carried
['advance_paid', 0, null],
['go_live', 0, null],
['balance_paid', 0, null],
]
for (const [i, r] of rows.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)
expect(res.carried).toBe(1) // only installed -> installation
expect(res.dropped).toBe(2) // amc_start (unmapped) + setup_done (not in SMS template)
// Verify the dropped keys are gone from the migrated template
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',
])
expect(ms.some((m) => m.key === 'amc_start' || m.key === 'setup_done')).toBe(false)
// Verify the carried installation tick is preserved
const installation = ms.find((m) => m.key === 'installation')!
expect(installation.done).toBe(true)
expect(installation.doneOn).toBe('2026-03-25')
})
it('leaves a module without a per-module template untouched', async () => {
const client = await createClient(db, 'u1', { name: 'Other Society', stateCode: '32' })
const mod = await createModule(db, 'u1', { code: 'NOTEMPLATE', name: 'No Template Module' })
const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' })
// This module has no project.milestone_template:NOTEMPLATE setting, so ensureProjectMilestones
// fell back to the global generic template — the OLD generic rows themselves.
const before = await listProjectMilestones(db, cm.id)
expect(before.map((m) => m.key)).toEqual([
'advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start',
])
const res = await migrateOnboardingTemplates(db)
expect(res.projects).toBe(0)
const after = await listProjectMilestones(db, cm.id)
expect(after).toEqual(before)
})
})

@ -0,0 +1,66 @@
// apps/hq/test/milestone-payment.test.ts — payment_id on milestone + link on payment-step tick.
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule } from '../src/repos-modules'
import { setMilestone, listProjectMilestones } from '../src/repos-milestones'
import { recordPayment } from '../src/repos-payments'
async function world() {
const db = openDb(':memory:')
await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
return { db, clientId: c.id, cmId: cm.id }
}
describe('milestone payment_id link (payment-step tick)', () => {
it('linking a payment to advance_payment stores payment_id and mirrors received_on', async () => {
const { db, clientId, cmId } = await world()
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)
})
it('rejects a payment that belongs to a different client', async () => {
const { db, cmId } = await world()
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
const pay = await recordPayment(db, 'u1', {
clientId: other.id, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 500_00,
})
await expect(
setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id),
).rejects.toThrow(/payment/i)
})
it('unticking clears payment_id along with done_on', async () => {
const { db, clientId, cmId } = await world()
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)
await setMilestone(db, 'u1', cmId, 'advance_payment', false)
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'advance_payment')!
expect(ms.done).toBe(false)
expect(ms.doneOn).toBeNull()
expect(ms.paymentId).toBeNull()
})
it('an explicit doneOn wins over the payment received_on', async () => {
const { db, clientId, cmId } = await world()
const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
})
await setMilestone(db, 'u1', cmId, 'balance_payment', true, '2026-05-10', pay.payment.id)
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'balance_payment')!
expect(ms.doneOn).toBe('2026-05-10')
expect(ms.paymentId).toBe(pay.payment.id)
})
})

@ -0,0 +1,45 @@
// apps/hq/test/milestone-status.test.ts — milestone→status auto-drive (never downgrade).
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule, getClientModule } from '../src/repos-modules'
import { setMilestone } from '../src/repos-milestones'
async function world() {
const db = openDb(':memory:')
await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
return { db, c, m, cmId: cm.id }
}
describe('milestone → status auto-drive (D22 status line)', () => {
it('ticking installation advances status to installed', async () => {
const { db, cmId } = await world()
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 () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'go_live', true)
expect((await getClientModule(db, cmId))!.status).toBe('live')
})
it('unticking never downgrades status', async () => {
const { db, cmId } = await world()
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 () => {
const { db, cmId } = await world()
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')
})
})

@ -0,0 +1,57 @@
// apps/hq/test/stalled-onboarding.test.ts — Task 8: stalled-onboarding query (dashboard/MIS tile).
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule } from '../src/repos-modules'
import { setMilestone, stalledProjects } from '../src/repos-milestones'
async function world() {
const db = openDb(':memory:')
await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
// A module code with no per-module milestone template seeded (D22 seed.ts only carries
// SMS/RTGS/MOBILEAPP overrides), so the global template applies: advance_paid, setup_done,
// installed, go_live, balance_paid, amc_start.
const m = await createModule(db, 'u1', { code: 'MISC', name: 'Misc Module' })
return { db, c, m }
}
describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
it('lists a project parked past the threshold', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
// Early steps done on an old date; later steps (incl. go_live) stay pending — project is
// still active (status stays 'quoted'/'installed', never 'live').
await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01')
await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01')
// last done_on = 2026-03-01, today = 2026-07-19 → ~140 days parked.
const stalled = await stalledProjects(db, 30, '2026-07-19')
expect(stalled.map((p) => p.clientModuleId)).toContain(cm.id)
const row = stalled.find((p) => p.clientModuleId === cm.id)!
expect(row.clientName).toBe('Kothavara SCB')
expect(row.moduleCode).toBe('MISC')
})
it('excludes a recently-progressed project', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01')
await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01')
const stalled = await stalledProjects(db, 365, '2026-07-19')
expect(stalled).toHaveLength(0)
})
it('excludes projects with no pending steps (fully ticked) and inactive/live projects', async () => {
const { db, c, m } = await world()
// Fully ticked project: nothing pending, so it should never appear regardless of age.
const done = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
for (const key of ['advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start']) {
await setMilestone(db, 'u1', done.id, key, true, '2026-01-01')
}
const stalled = await stalledProjects(db, 1, '2026-07-19')
expect(stalled.map((p) => p.clientModuleId)).not.toContain(done.id)
// 'live' status (from ticking go_live) also excludes it, which the loop above proves.
expect((await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, done.id))!.status).toBe('live')
})
})

@ -578,3 +578,41 @@ install. Restore: `pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL"
Priority rose with D32 — dumps now contain **plaintext credentials**, so the `backups/` dir
must stay access-controlled and off shared drives. Still pending in the ops slice: schedule
it (Task Scheduler / cron), off-box copy, and getting the DB password out of `server.ts`.
## D34 — Employee master import + invited/first-login flag (2026-07-19)
Imported the 11-row employee master (`empmaster.xlsx`) into `staff_user` via
`scripts/import-employees.ts`: username = first-name slug, default password `<username>@123`
(founder's explicit choice), role `staff`, active per `ACTIVE_STAT`. New `must_change_password`
column drives an "Invited · must change pw" badge on the Employees page and sends the user to
Profile to change it on first sign-in; the flag clears on that first self-change. A background
security review flagged the predictable default password — kept per the founder's explicit
instruction, with the invited flag + change-nudge as the mitigation (full first-login
enforcement offered, not yet enabled).
## D35 — Client type (PACS/Store) + directory-sourced LTD numbers (2026-07-19)
`client.client_type` = **PACS** (co-op society) vs **Store** (retail), backfilled by name
(ends in STORE → Store) and filterable on the Clients screen. **Data-correction session**
(founder-approved, review-sheet-first): 187 client names cleaned mechanically (co-op style →
`CO-OPERATIVE`, run-on splits, `ARP00KARA`→`ARPOOKARA`, spacing), applied after a backup and
audited. **LTD/registration numbers** looked up from the **Kerala govt co-op directory** (the
source of truth) via a multi-agent web-lookup workflow — 259 PACS across two runs (the
first-wave batches needed a smaller-batch re-run to clear a transient safety-classifier block);
**141 now carry their LTD number in the name** (` LTD NO. <n>`, guarded against duplicates).
The ~26 real banks not found online + non-bank societies were left untouched (no guessing).
## D36 — Login history (IP + device) + first-login nudge (2026-07-19)
Every `/auth/login` attempt is recorded in a `login_event` table (staff_id or null, username
tried, outcome success/failed/locked, IP, user-agent) — security telemetry, separate from the
domain audit, best-effort so it never breaks login. Owner-only **Login history** page (Admin):
who signed in / failed, when, from which IP + parsed device, outcome filter, paginated.
`login()` now returns `mustChangePassword` (D34) so an invited user lands on Profile to change
their default password.
## D37 — Society category (Kerala co-op directory taxonomy) (2026-07-20)
`client.category` classifies each client the way the official Kerala co-op directory does:
**Service Bank · Urban Bank · Vanitha · Housing · Employees · Agricultural · Marketing ·
Bank (other) · Society (other) · Store · Other**. Derived from the name (most-specific-first;
a "VANITHA … SOCIETY" is Vanitha, not the generic Society), backfilled by a portable CASE and
applied to new/edited clients via `categoryFromName`. Clients screen gains a Category filter +
column. Live backfill: 135 Service Bank, 38 Employees, 15 Vanitha, 14 Store, 12 Agricultural,
11 Urban, …

Loading…
Cancel
Save