From 5c3c13c9d3a9e490e14089ae737bfb52b61d9c22 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 00:35:38 +0530 Subject: [PATCH] docs: HQ-1 implementation plan (16 TDD tasks) Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-10-hq1-console.md | 1191 +++++++++++++++++ 1 file changed, 1191 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-hq1-console.md diff --git a/docs/superpowers/plans/2026-07-10-hq1-console.md b/docs/superpowers/plans/2026-07-10-hq1-console.md new file mode 100644 index 0000000..0a11649 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-hq1-console.md @@ -0,0 +1,1191 @@ +# HQ-1 — Internal Ops Console 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:** Ship HQ-1 of [docs/14-SPEC-HQ-CONSOLE.md](../../14-SPEC-HQ-CONSOLE.md): `apps/hq` (API server + SQLite) and `apps/hq-web` (React UI) — client registry with APEX CSV import, module catalog + dated prices, client-module tracking, quotation/proforma/invoice/credit-note generation with letterhead PDF, Gmail send with token-death handling, and payment recording with allocation/TDS/advances. + +**Architecture:** Mirror the store-server pattern exactly: express server + better-sqlite3 behind plain-function repositories (`repos-*.ts`), React 19 + Vite web app served from the server, pure logic reused from `@sims/domain` (money, UUIDv7, FY, `formatDocNo`) and `@sims/billing-engine` (`computeBill`). HQ keeps its **own** document/payment tables (retail `BillDoc`/`PaymentMode` types are goods-shaped; shared packages stay generic per D15). Doc series is HQ-local: table keyed `(doc_type, fy)`, numbers via the existing `formatDocNo(prefix, seq, 4)` → `QT/26-27-0001` (14 chars, passes `assertGstDocNo`). + +**Tech Stack:** TypeScript (ESM, strict), express ^4.21, better-sqlite3 ^11.7, puppeteer ^23 (PDF), vitest, React 19 + Vite 6 + react-router 7, `@sims/ui`. + +## Global Constraints + +- All money is **integer paise** (`Paise` from `@sims/domain`); never floats. +- All ids are **UUIDv7** via `uuidv7()` from `@sims/domain`. +- **Every mutation writes an `audit_log` row** (same discipline as store-server). +- SQL stays **portable** (D12 guardrail): standard SQL, quirks commented. +- HQ prices are **GST-exclusive** (`priceIncludesTax: false`) — B2B convention. +- Intra/inter-state: our state code comes from the `setting` table key `company.state_code` (seed `'32'`, Kerala — founder to confirm); client state ≠ ours ⇒ IGST. +- HSN column carries **SAC codes** for our service lines; PDFs label it "SAC". +- Issued invoices are **never edited or deleted** — corrections are credit notes; cancel keeps the number consumed. +- Server port **5182** (5181 = store-server; 8080–85 Windows-reserved). +- Secrets: `HQ_SECRET_KEY` (64 hex chars) encrypts the Gmail refresh token (AES-256-GCM). Never log tokens. +- Tests: vitest, colocated under `apps/hq/test/` (Task 1 extends the vitest include). +- Commit after every task; messages end with `Co-Authored-By: Claude Fable 5 `. + +## File Structure (locked in) + +``` +apps/hq/ + package.json, tsconfig.json, build-server.mjs + src/db.ts — schema + openDb (WAL, data dir) + src/audit.ts — writeAudit/listAudit + src/auth.ts — staff CRUD, sessions, express middleware + src/repos-clients.ts — client registry + src/repos-modules.ts — module catalog + module_price_book + client_module + src/series.ts — HQ doc series (seedable, per doc_type+fy) + src/repos-documents.ts — QT/PI/INV/CN lifecycle + document_event trace + src/repos-payments.ts — payments + allocation + TDS + advances + src/templates.ts — letterhead HTML per doc type + src/pdf.ts — puppeteer HTML→PDF + src/crypto.ts — AES-256-GCM helpers + src/gmail.ts — token refresh, MIME build, send, invalid_grant handling + src/repos-email.ts — email_account + email_log + src/import-apex.ts — CSV staging → verification report → commit + series seed + src/seed.ts — owner user, settings, GST18 tax class + src/api.ts — apiRouter(db, deps) + src/server.ts — express wiring, serves ../hq-web/dist + scripts/gmail-connect.ts — one-time OAuth loopback flow + test/*.test.ts — one file per task +apps/hq-web/ + package.json, tsconfig.json, vite.config.ts, index.html + src/main.tsx, src/api.ts, src/Layout.tsx, src/Login.tsx + src/pages/Clients.tsx, ClientDetail.tsx, Modules.tsx, NewDocument.tsx, DocumentView.tsx +``` + +--- + +### Task 1: `apps/hq` skeleton + health endpoint + +**Files:** +- Create: `apps/hq/package.json`, `apps/hq/tsconfig.json`, `apps/hq/src/server.ts`, `apps/hq/src/api.ts` +- Modify: `vitest.config.ts` (extend include) +- Test: `apps/hq/test/health.test.ts` + +**Interfaces:** +- Produces: `apiRouter(db: DB): express.Router` (grows every task); `startServer(port: number): http.Server` exported for tests. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/health.test.ts +import { describe, it, expect, afterAll } from 'vitest' +import { startServer } from '../src/server' + +const server = startServer(0) +const base = () => `http://localhost:${(server.address() as { port: number }).port}` + +describe('hq health', () => { + afterAll(() => server.close()) + it('answers /api/health', async () => { + const res = await fetch(`${base()}/api/health`) + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ ok: true, service: 'sims-hq' }) + }) +}) +``` + +- [ ] **Step 2: Extend vitest include and add workspace files** + +In `vitest.config.ts` change the `test` block to: + +```ts + test: { + include: ['packages/*/test/**/*.test.ts', 'apps/*/test/**/*.test.ts'], + }, +``` + +`apps/hq/package.json`: + +```json +{ + "name": "@sims/hq", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "node build-server.mjs", + "start": "npm run build && node dist/server.cjs", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@sims/auth": "*", + "@sims/billing-engine": "*", + "@sims/domain": "*", + "better-sqlite3": "^11.7.0", + "express": "^4.21.0", + "puppeteer": "^23.11.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.11", + "@types/express": "^4.17.21", + "esbuild": "^0.25.0", + "tsx": "^4.19.0" + } +} +``` + +`apps/hq/tsconfig.json` — copy `apps/store-server/tsconfig.json` verbatim (same compiler options; it extends `tsconfig.base.json`). + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run apps/hq/test/health.test.ts` +Expected: FAIL — cannot resolve `../src/server`. + +- [ ] **Step 4: Minimal implementation** + +```ts +// apps/hq/src/api.ts +import { Router } from 'express' +import type { DB } from './db' + +export function apiRouter(_db: DB | null): Router { + const r = Router() + r.get('/health', (_req, res) => { + res.json({ ok: true, service: 'sims-hq', version: '0.1.0' }) + }) + return r +} +``` + +```ts +// apps/hq/src/server.ts +import express from 'express' +import type http from 'node:http' +import { apiRouter } from './api' + +export function startServer(port: number): http.Server { + const app = express() + app.use(express.json({ limit: '2mb' })) + app.use('/api', apiRouter(null)) + return app.listen(port) +} + +// Direct launch (bundled dist/server.cjs); vitest imports startServer instead. +if (process.argv[1]?.endsWith('server.cjs') || process.argv[1]?.endsWith('server.ts')) { + const PORT = Number(process.env['HQ_PORT'] ?? 5182) + startServer(PORT) + console.log(`SiMS HQ console on http://localhost:${PORT}`) +} +``` + +Run `npm install` at repo root (adds the new workspace). + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run apps/hq/test/health.test.ts` → PASS. +Run: `npm run typecheck` → clean. + +- [ ] **Step 6: Commit** + +```bash +git add apps/hq vitest.config.ts package-lock.json +git commit -m "feat(hq): app skeleton with health endpoint (HQ-1 task 1)" +``` + +--- + +### Task 2: Database schema + audit log + +**Files:** +- Create: `apps/hq/src/db.ts`, `apps/hq/src/audit.ts` +- Test: `apps/hq/test/db.test.ts` + +**Interfaces:** +- Produces: `openDb(dataDir?: string): DB` (`':memory:'` path when `dataDir === ':memory:'`); `writeAudit(db, userId, action, entity, entityId, before?, after?): void`; `listAudit(db, limit?): AuditRow[]` where `AuditRow = { id: string; at_wall: string; user_id: string; action: string; entity: string; entity_id: string; before_json: string | null; after_json: string | null }`. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/db.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { writeAudit, listAudit } from '../src/audit' + +describe('hq db', () => { + it('creates every HQ-1 table', () => { + const db = openDb(':memory:') + const names = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]).map(r => r.name) + for (const t of ['staff_user','session','client','module','module_price_book','client_module', + 'tax_class','doc_series','document','payment','payment_allocation','document_event', + 'email_account','email_log','setting','audit_log','stg_client','stg_invoice']) + expect(names, `missing table ${t}`).toContain(t) + }) + it('audit writes and lists newest-first', () => { + const db = openDb(':memory:') + writeAudit(db, 'u1', 'create', 'client', 'c1', undefined, { name: 'Acme' }) + writeAudit(db, 'u1', 'update', 'client', 'c1', { name: 'Acme' }, { name: 'Acme Ltd' }) + const rows = listAudit(db) + expect(rows).toHaveLength(2) + expect(rows[0]!.action).toBe('update') + expect(JSON.parse(rows[0]!.after_json!)).toEqual({ name: 'Acme Ltd' }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** — `npx vitest run apps/hq/test/db.test.ts` → FAIL (module not found). + +- [ ] **Step 3: Implement** + +```ts +// apps/hq/src/db.ts +import Database from 'better-sqlite3' +import fs from 'node:fs' +import path from 'node:path' + +/** HQ console DB — SQLite behind portable repositories, S3 backup at deploy time. */ +export type DB = Database.Database + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS staff_user ( + id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner','staff')), + pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 +); +CREATE TABLE IF NOT EXISTS session ( + token TEXT PRIMARY KEY, staff_id TEXT NOT NULL, expires_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS client ( + id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + gstin TEXT, state_code TEXT NOT NULL DEFAULT '32', address TEXT NOT NULL DEFAULT '', + contacts TEXT NOT NULL DEFAULT '[]', -- JSON [{name,phone,email,role}] + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('lead','active','dormant','lost')), + notes TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'hq', created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS module ( + id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + sac TEXT NOT NULL DEFAULT '998313', -- IT services default; CA session confirms per module + allowed_kinds TEXT NOT NULL DEFAULT '["one_time","monthly","yearly","usage"]', + multi_subscription INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1 +); +CREATE TABLE IF NOT EXISTS module_price_book ( + id TEXT PRIMARY KEY, module_id TEXT NOT NULL, edition TEXT NOT NULL DEFAULT 'standard', + kind TEXT NOT NULL, price_paise INTEGER NOT NULL, effective_from TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS client_module ( + id TEXT PRIMARY KEY, client_id TEXT NOT NULL, module_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'quoted' CHECK (status IN + ('quoted','ordered','installing','installed','trained','live','expired','cancelled')), + kind TEXT NOT NULL, edition TEXT NOT NULL DEFAULT 'standard', + installed_on TEXT, completed_on TEXT, trained_on TEXT, + next_renewal TEXT, active INTEGER NOT NULL DEFAULT 1 +); +CREATE TABLE IF NOT EXISTS tax_class ( + class_code TEXT NOT NULL, rate_pct_bp INTEGER NOT NULL, + cess_pct_bp INTEGER NOT NULL DEFAULT 0, effective_from TEXT NOT NULL, effective_to TEXT +); +CREATE TABLE IF NOT EXISTS doc_series ( + doc_type TEXT NOT NULL, fy TEXT NOT NULL, prefix TEXT NOT NULL, next_seq INTEGER NOT NULL, + PRIMARY KEY (doc_type, fy) +); +CREATE TABLE IF NOT EXISTS document ( + id TEXT PRIMARY KEY, doc_type TEXT NOT NULL CHECK (doc_type IN + ('QUOTATION','PROFORMA','INVOICE','RECEIPT','CREDIT_NOTE')), + doc_no TEXT, fy TEXT NOT NULL, client_id TEXT NOT NULL, doc_date TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN + ('draft','sent','accepted','invoiced','part_paid','paid','lost','cancelled')), + ref_doc_id TEXT, -- QT→PI→INV chain; CN → the invoice it amends + taxable_paise INTEGER NOT NULL, cgst_paise INTEGER NOT NULL, sgst_paise INTEGER NOT NULL, + igst_paise INTEGER NOT NULL, round_off_paise INTEGER NOT NULL, payable_paise INTEGER NOT NULL, + payload TEXT NOT NULL, -- JSON { lines: BillLine[], totals: BillTotals, terms?: string } + source TEXT NOT NULL DEFAULT 'hq', -- 'apex' rows keep legacy numbers, exempt from series + created_by TEXT NOT NULL, created_at TEXT NOT NULL, + UNIQUE (doc_no) +); +CREATE TABLE IF NOT EXISTS document_event ( + id TEXT PRIMARY KEY, document_id TEXT NOT NULL, at_wall TEXT NOT NULL, + kind TEXT NOT NULL, meta TEXT NOT NULL DEFAULT '{}' +); +CREATE TABLE IF NOT EXISTS payment ( + id TEXT PRIMARY KEY, client_id TEXT NOT NULL, received_on TEXT NOT NULL, + mode TEXT NOT NULL CHECK (mode IN ('bank','upi','cheque','cash','other')), + reference TEXT NOT NULL DEFAULT '', amount_paise INTEGER NOT NULL, + tds_paise INTEGER NOT NULL DEFAULT 0, created_by TEXT NOT NULL, created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS payment_allocation ( + id TEXT PRIMARY KEY, payment_id TEXT NOT NULL, document_id TEXT NOT NULL, + amount_paise INTEGER NOT NULL -- includes this allocation's TDS share +); +CREATE TABLE IF NOT EXISTS email_account ( + id TEXT PRIMARY KEY, address TEXT NOT NULL, refresh_token_enc TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','dead')), updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS email_log ( + id TEXT PRIMARY KEY, document_id TEXT, to_addr TEXT NOT NULL, subject TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('sent','failed')), gmail_message_id TEXT, + error TEXT, at_wall TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS setting ( + key TEXT PRIMARY KEY, value TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, at_wall TEXT NOT NULL, user_id TEXT NOT NULL, action TEXT NOT NULL, + entity TEXT NOT NULL, entity_id TEXT NOT NULL, before_json TEXT, after_json TEXT +); +CREATE TABLE IF NOT EXISTS stg_client ( + row_no INTEGER PRIMARY KEY, code TEXT, name TEXT, gstin TEXT, state_code TEXT, + address TEXT, phone TEXT, email TEXT, status TEXT, problems TEXT NOT NULL DEFAULT '[]' +); +CREATE TABLE IF NOT EXISTS stg_invoice ( + row_no INTEGER PRIMARY KEY, client_code TEXT, doc_no TEXT, doc_date TEXT, + taxable_paise INTEGER, tax_paise INTEGER, total_paise INTEGER, paid INTEGER, + problems TEXT NOT NULL DEFAULT '[]' +); +` + +export function openDb(dataDir?: string): DB { + let db: DB + if (dataDir === ':memory:') { + db = new Database(':memory:') + } else { + const dir = dataDir ?? path.resolve(process.cwd(), 'data') + fs.mkdirSync(dir, { recursive: true }) + db = new Database(path.join(dir, 'hq.db')) + db.pragma('journal_mode = WAL') + } + db.exec(SCHEMA) + return db +} +``` + +```ts +// apps/hq/src/audit.ts +import { uuidv7 } from '@sims/domain' +import type { DB } from './db' + +export interface AuditRow { + id: string; at_wall: string; user_id: string; action: string + entity: string; entity_id: string; before_json: string | null; after_json: string | null +} + +export function writeAudit( + db: DB, userId: string, action: string, entity: string, entityId: string, + before?: unknown, after?: unknown, +): void { + db.prepare( + `INSERT INTO audit_log (id, at_wall, user_id, action, entity, entity_id, before_json, after_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + uuidv7(), new Date().toISOString(), userId, action, entity, entityId, + before === undefined ? null : JSON.stringify(before), + after === undefined ? null : JSON.stringify(after), + ) +} + +export function listAudit(db: DB, limit = 200): AuditRow[] { + return db.prepare(`SELECT * FROM audit_log ORDER BY id DESC LIMIT ?`).all(limit) as AuditRow[] +} +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/db.test.ts` → PASS. `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/db.ts apps/hq/src/audit.ts apps/hq/test/db.test.ts +git commit -m "feat(hq): sqlite schema + audit log (HQ-1 task 2)" +``` + +--- + +### Task 3: Staff auth — users, sessions, middleware, login route + +**Files:** +- Create: `apps/hq/src/auth.ts` +- Modify: `apps/hq/src/api.ts`, `apps/hq/src/server.ts` (pass a real `openDb()` handle) +- Test: `apps/hq/test/auth.test.ts` + +**Interfaces:** +- Consumes: `hashPin`/`verifyPin` from `@sims/auth` (generic scrypt over any string — used here for passwords; the PIN *policy* function is not applied), `writeAudit`. +- Produces: `createStaff(db, input: { email: string; displayName: string; role: 'owner'|'staff'; password: string }): { id: string }`; `login(db, email: string, password: string): { token: string; staff: { id: string; displayName: string; role: string } } | null` (14-day expiry); `requireAuth` / `requireOwner` express middlewares that set `res.locals.staff = { id, role }`; `apiRouter(db)` gains `POST /auth/login`. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/auth.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createStaff, login, verifySession } from '../src/auth' + +describe('hq auth', () => { + it('logs in with correct password, rejects wrong one', () => { + const db = openDb(':memory:') + createStaff(db, { email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9' }) + expect(login(db, 'admin@tecnostac.com', 'wrong')).toBeNull() + const ok = login(db, 'admin@tecnostac.com', 'let-me-in-9') + expect(ok).not.toBeNull() + const staff = verifySession(db, ok!.token) + expect(staff).toMatchObject({ role: 'owner' }) + }) + it('rejects passwords under 8 chars at creation', () => { + const db = openDb(':memory:') + expect(() => + createStaff(db, { email: 'a@b.c', displayName: 'X', role: 'staff', password: 'short' }), + ).toThrow(/8/) + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL**, then implement: + +```ts +// apps/hq/src/auth.ts +import { randomBytes } from 'node:crypto' +import type { RequestHandler } from 'express' +import { hashPin, verifyPin } from '@sims/auth' +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' + +const SESSION_DAYS = 14 + +export function createStaff(db: DB, input: { + email: string; displayName: string; role: 'owner' | 'staff'; password: string +}): { id: string } { + if (input.password.length < 8) throw new Error('Password must be at least 8 characters') + const id = uuidv7() + const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied + db.prepare( + `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, + ).run(id, input.email.toLowerCase(), input.displayName, input.role, salt, hash) + writeAudit(db, 'system', 'create', 'staff_user', id, undefined, { email: input.email, role: input.role }) + return { id } +} + +interface StaffRow { id: string; email: string; display_name: string; role: string; pw_salt: string; pw_hash: string; active: number } + +export function login(db: DB, email: string, password: string): + { token: string; staff: { id: string; displayName: string; role: string } } | null { + const row = db.prepare(`SELECT * FROM staff_user WHERE email=? AND active=1`).get(email.toLowerCase()) as StaffRow | undefined + if (!row || !verifyPin(password, { salt: row.pw_salt, hash: row.pw_hash })) return null + const token = randomBytes(32).toString('hex') + const expires = new Date(Date.now() + SESSION_DAYS * 86_400_000).toISOString() + db.prepare(`INSERT INTO session (token, staff_id, expires_at) VALUES (?, ?, ?)`).run(token, row.id, expires) + writeAudit(db, row.id, 'login', 'staff_user', row.id) + return { token, staff: { id: row.id, displayName: row.display_name, role: row.role } } +} + +export function verifySession(db: DB, token: string): { id: string; role: string } | null { + const row = db.prepare( + `SELECT s.staff_id AS id, u.role FROM session s JOIN staff_user u ON u.id = s.staff_id + WHERE s.token=? AND s.expires_at > ?`, + ).get(token, new Date().toISOString()) as { id: string; role: string } | undefined + return row ?? null +} + +export const requireAuth: RequestHandler = (req, res, next) => { + const db = req.app.locals['db'] as DB + const token = (req.headers.authorization ?? '').replace(/^Bearer /, '') + const staff = token ? verifySession(db, token) : null + if (!staff) { res.status(401).json({ ok: false, error: 'Not signed in' }); return } + res.locals['staff'] = staff + next() +} + +export const requireOwner: RequestHandler = (_req, res, next) => { + if ((res.locals['staff'] as { role: string }).role !== 'owner') { + res.status(403).json({ ok: false, error: 'Owner only' }); return + } + next() +} +``` + +In `api.ts` add (and change signature to `apiRouter(db: DB)`): + +```ts +r.post('/auth/login', (req, res) => { + const { email, password } = req.body as { email?: string; password?: string } + const out = typeof email === 'string' && typeof password === 'string' ? login(db, email, password) : null + if (!out) { res.status(401).json({ ok: false, error: 'Invalid email or password' }); return } + res.json({ ok: true, ...out }) +}) +``` + +In `server.ts`: `const db = openDb(process.env['HQ_DATA_DIR'])`, `app.locals['db'] = db`, pass `db` to `apiRouter`. + +- [ ] **Step 3: Run tests** — `npx vitest run apps/hq/test` → PASS (health + db + auth). Typecheck clean. + +- [ ] **Step 4: Commit** — `git commit -m "feat(hq): staff auth with sessions and owner gate (HQ-1 task 3)"` + +--- + +### Task 4: Client registry — repo + routes + +**Files:** +- Create: `apps/hq/src/repos-clients.ts` +- Modify: `apps/hq/src/api.ts` +- Test: `apps/hq/test/clients.test.ts` + +**Interfaces:** +- Produces: `Client = { id: string; code: string; name: string; gstin?: string; stateCode: string; address: string; contacts: { name: string; phone?: string; email?: string; role?: string }[]; status: 'lead'|'active'|'dormant'|'lost'; notes: string }`; `listClients(db, q?: string): Client[]`; `getClient(db, id): Client | null`; `createClient(db, userId, input): Client` (auto-code `CL0001`… when code omitted; GSTIN checksum via `assertGstin` from `@sims/domain` when provided); `updateClient(db, userId, id, patch): Client`. +- Routes: `GET /api/clients?q=`, `POST /api/clients`, `GET /api/clients/:id`, `PATCH /api/clients/:id` — all behind `requireAuth`. + +- [ ] **Step 1: Failing test** + +```ts +// apps/hq/test/clients.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createClient, listClients, updateClient } from '../src/repos-clients' + +describe('client registry', () => { + it('creates with auto code, searches, updates with audit', () => { + const db = openDb(':memory:') + const c = createClient(db, 'u1', { name: 'Malabar Stores', stateCode: '32', + contacts: [{ name: 'Ravi', email: 'ravi@malabar.in' }] }) + expect(c.code).toBe('CL0001') + expect(listClients(db, 'malabar')).toHaveLength(1) + const up = updateClient(db, 'u1', c.id, { status: 'active', notes: 'AMC due Oct' }) + expect(up.status).toBe('active') + const audits = db.prepare(`SELECT action FROM audit_log WHERE entity='client'`).all() + expect(audits.length).toBe(2) + }) + it('rejects a bad GSTIN checksum', () => { + const db = openDb(':memory:') + expect(() => createClient(db, 'u1', { name: 'X', stateCode: '32', gstin: '32AAAAA0000A1Z9' })).toThrow() + }) +}) +``` + +- [ ] **Step 2: Verify FAIL, implement** — follow the `repos.ts` house pattern exactly (snake_case row interface → camelCase mapper). `createClient` computes `code` as `'CL' + String(count + 1).padStart(4, '0')` unless provided, validates GSTIN with `assertGstin` (check the exact export name in `packages/domain/src/gstin.ts` before importing — if it is `validateGstin` returning boolean, throw on false), stamps `created_at = new Date().toISOString()`, writes audit `create`. `updateClient` reads the before-row, applies only known keys (`name,gstin,stateCode,address,contacts,status,notes`), writes audit `update` with before/after. Routes in `api.ts` under `requireAuth`, thin: parse → repo → `res.json({ ok: true, client })`, 404 on missing, 400 with the error message on throw. + +- [ ] **Step 3: Run tests → PASS. Typecheck clean.** + +- [ ] **Step 4: Commit** — `git commit -m "feat(hq): client registry with GSTIN validation (HQ-1 task 4)"` + +--- + +### Task 5: Module catalog + dated prices + +**Files:** +- Create: `apps/hq/src/repos-modules.ts` +- Modify: `apps/hq/src/api.ts` +- Test: `apps/hq/test/modules.test.ts` + +**Interfaces:** +- Produces: `Kind = 'one_time'|'monthly'|'yearly'|'usage'`; `createModule(db, userId, { code, name, sac?, allowedKinds?, multiSubscription? }): Module`; `listModules(db): Module[]`; `setPrice(db, userId, { moduleId, edition?, kind, pricePaise, effectiveFrom }): void`; `priceOn(db, moduleId, kind, edition, onDate: string): number | null` (latest `effective_from <= onDate` wins — the D5 dated-rows philosophy). +- Routes: `GET/POST /api/modules` (POST owner-only), `POST /api/modules/:id/prices` (owner-only), `GET /api/modules/:id/prices`. + +- [ ] **Step 1: Failing test** + +```ts +// apps/hq/test/modules.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createModule, setPrice, priceOn } from '../src/repos-modules' + +describe('module catalog', () => { + it('resolves the dated price row', () => { + const db = openDb(':memory:') + const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 1_20_000_00, effectiveFrom: '2026-04-01' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 1_50_000_00, effectiveFrom: '2026-08-01' }) + expect(priceOn(db, m.id, 'yearly', 'standard', '2026-07-10')).toBe(1_20_000_00) + expect(priceOn(db, m.id, 'yearly', 'standard', '2026-09-01')).toBe(1_50_000_00) + expect(priceOn(db, m.id, 'monthly', 'standard', '2026-09-01')).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Verify FAIL, implement.** `priceOn` SQL: + +```sql +SELECT price_paise FROM module_price_book +WHERE module_id=? AND kind=? AND edition=? AND effective_from <= ? +ORDER BY effective_from DESC LIMIT 1 +``` + +`createModule` stores `allowed_kinds` as JSON (default all four), `multi_subscription` 0/1. Audit rows on both mutations. Routes: writes behind `requireAuth, requireOwner`. + +- [ ] **Step 3: Run tests → PASS. Commit** — `git commit -m "feat(hq): module catalog with dated price book (HQ-1 task 5)"` + +--- + +### Task 6: Client-module tracking (lifecycle + multi-subscription rule) + +**Files:** +- Modify: `apps/hq/src/repos-modules.ts`, `apps/hq/src/api.ts` +- Test: `apps/hq/test/client-modules.test.ts` + +**Interfaces:** +- Produces: `assignModule(db, userId, { clientId, moduleId, kind, edition?, status? }): ClientModule` — throws if `kind` not in the module's `allowed_kinds`, or if an active row exists and the module has `multi_subscription = 0`; `updateClientModule(db, userId, id, patch: { status?, installedOn?, completedOn?, trainedOn?, nextRenewal?, active? }): ClientModule` — status must be one of the CHECK list; `listClientModules(db, clientId): ClientModule[]`. +- Routes: `POST /api/clients/:id/modules`, `PATCH /api/client-modules/:id`, `GET /api/clients/:id/modules`. + +- [ ] **Step 1: Failing test** + +```ts +// apps/hq/test/client-modules.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createClient } from '../src/repos-clients' +import { createModule, assignModule, updateClientModule } from '../src/repos-modules' + +describe('client modules', () => { + it('enforces allowed kinds and single-subscription', () => { + const db = openDb(':memory:') + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['one_time','yearly'] }) + expect(() => assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })).toThrow(/allow/) + const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + expect(() => assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })).toThrow(/already/) + const up = updateClientModule(db, 'u1', cm.id, { status: 'installed', installedOn: '2026-07-01' }) + expect(up.status).toBe('installed') + }) + it('allows concurrent rows when multi_subscription = 1', () => { + const db = openDb(':memory:') + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', multiSubscription: true }) + assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'usage' }) + expect(() => assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'usage' })).not.toThrow() + }) +}) +``` + +- [ ] **Step 2: Verify FAIL, implement, run → PASS, typecheck, commit** — `git commit -m "feat(hq): client-module lifecycle tracking (HQ-1 task 6)"` + +--- + +### Task 7: HQ document series — seedable, per (doc_type, fy) + +**Files:** +- Create: `apps/hq/src/series.ts` +- Test: `apps/hq/test/series.test.ts` + +**Interfaces:** +- Consumes: `formatDocNo(prefix, seq, width)` + `assertGstDocNo` from `@sims/domain` (unchanged — HQ scope lives in its own table, which *is* the spec's "scope becomes an opaque key"). +- Produces: `TYPE_PREFIX: Record = { QUOTATION:'QT', PROFORMA:'PI', INVOICE:'INV', RECEIPT:'RCT', CREDIT_NOTE:'CN' }`; `nextDocNo(db, docType: string, fy: string): string` — creates the series row on first use with prefix `` `${TYPE_PREFIX[docType]}/${fy.slice(2)}` `` (fy `"2026-27"` → `QT/26-27-0001`, 14 chars) and increments atomically inside the caller's transaction; `seedSeries(db, docType, fy, lastUsedSeq: number): void` — cutover seeding per spec §4. + +- [ ] **Step 1: Failing test** + +```ts +// apps/hq/test/series.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { nextDocNo, seedSeries } from '../src/series' + +describe('hq doc series', () => { + it('starts at 0001 per type+fy and increments', () => { + const db = openDb(':memory:') + expect(nextDocNo(db, 'QUOTATION', '2026-27')).toBe('QT/26-27-0001') + expect(nextDocNo(db, 'QUOTATION', '2026-27')).toBe('QT/26-27-0002') + expect(nextDocNo(db, 'INVOICE', '2026-27')).toBe('INV/26-27-0001') + expect(nextDocNo(db, 'QUOTATION', '2027-28')).toBe('QT/27-28-0001') + }) + it('seeds from the last APEX number at cutover', () => { + const db = openDb(':memory:') + seedSeries(db, 'INVOICE', '2026-27', 412) + expect(nextDocNo(db, 'INVOICE', '2026-27')).toBe('INV/26-27-0413') + }) +}) +``` + +- [ ] **Step 2: Verify FAIL, implement** + +```ts +// apps/hq/src/series.ts +import { formatDocNo } from '@sims/domain' +import type { DB } from './db' + +export const TYPE_PREFIX: Record = { + QUOTATION: 'QT', PROFORMA: 'PI', INVOICE: 'INV', RECEIPT: 'RCT', CREDIT_NOTE: 'CN', +} + +export function nextDocNo(db: DB, docType: string, fy: string): string { + const prefix = `${TYPE_PREFIX[docType]}/${fy.slice(2)}` + db.prepare( + `INSERT INTO doc_series (doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, 1) + ON CONFLICT (doc_type, fy) DO NOTHING`, + ).run(docType, fy, prefix) + const row = db.prepare(`SELECT prefix, next_seq FROM doc_series WHERE doc_type=? AND fy=?`) + .get(docType, fy) as { prefix: string; next_seq: number } + db.prepare(`UPDATE doc_series SET next_seq = next_seq + 1 WHERE doc_type=? AND fy=?`).run(docType, fy) + return formatDocNo(row.prefix, row.next_seq, 4) +} + +/** Mid-FY cutover (spec §4): continue after the last APEX-issued number. */ +export function seedSeries(db: DB, docType: string, fy: string, lastUsedSeq: number): void { + const prefix = `${TYPE_PREFIX[docType]}/${fy.slice(2)}` + db.prepare( + `INSERT INTO doc_series (doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, ?) + ON CONFLICT (doc_type, fy) DO UPDATE SET next_seq = excluded.next_seq`, + ).run(docType, fy, prefix, lastUsedSeq + 1) +} +``` + +- [ ] **Step 3: Run → PASS. Commit** — `git commit -m "feat(hq): seedable per-FY document series (HQ-1 task 7)"` + +--- + +### Task 8: Documents — draft, issue, convert, cancel, credit note + +**Files:** +- Create: `apps/hq/src/repos-documents.ts` +- Modify: `apps/hq/src/api.ts` +- Test: `apps/hq/test/documents.test.ts` + +**Interfaces:** +- Consumes: `computeBill`, `TaxClassRow` from `@sims/billing-engine`; `nextDocNo`; `priceOn`; `fyOf` from `@sims/domain`; setting `company.state_code`. +- Produces: + - `DraftLineInput = { moduleId: string; description?: string; qty: number; unitPricePaise?: number /* default: priceOn today */; kind: Kind; edition?: string }` + - `createDraft(db, userId, { docType: 'QUOTATION'|'PROFORMA'|'INVOICE', clientId, lines: DraftLineInput[], terms? }): Doc` — computes via `computeBill` with `priceIncludesTax: false`, `roundToRupee: true`, tax class `'GST18'` (seeded Task 13), `supplyStateCode` from settings, `placeOfSupplyStateCode` from the client row; module SAC goes into the `hsn` field of `LineInput`. + - `issueDocument(db, userId, docId): Doc` — assigns `doc_no = nextDocNo(...)` inside one transaction, moves `draft → sent` is NOT done here (send/mark separately): issue = number assignment, status stays `draft` until sent/marked. + - `markStatus(db, userId, docId, status: 'sent'|'accepted'|'lost'): Doc` (guard legal transitions: draft→sent, sent→accepted/lost). + - `convertDocument(db, userId, docId, to: 'PROFORMA'|'INVOICE'): Doc` — new draft carrying lines + `ref_doc_id`; source doc gets status `invoiced` when converting to INVOICE. + - `cancelDocument(db, userId, docId): Doc` — only for issued, unpaid documents; number stays consumed. + - `createCreditNote(db, userId, invoiceId, lines?: DraftLineInput[]): Doc` — defaults to full-value CN against the invoice, `ref_doc_id = invoiceId`. + - `getDocument(db, id): Doc | null`; `listDocuments(db, { type?, status?, clientId? }): Doc[]`; every transition writes a `document_event` row (`kind`: created/issued/sent/accepted/converted/cancelled/credit_note) and an audit row. +- Routes: `POST /api/documents`, `GET /api/documents`, `GET /api/documents/:id` (includes events + email log), `POST /api/documents/:id/issue`, `POST /api/documents/:id/status`, `POST /api/documents/:id/convert`, `POST /api/documents/:id/cancel`, `POST /api/documents/:id/credit-note`. + +- [ ] **Step 1: Failing test** (golden numbers, exclusive 18% GST) + +```ts +// apps/hq/test/documents.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, convertDocument, createCreditNote, cancelDocument } from '../src/repos-documents' + +function setup() { + const db = openDb(':memory:') + db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() + db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) + return { db, c, m } +} + +describe('documents', () => { + it('quotation: ₹10,000 + 18% intra-state = CGST 900 + SGST 900, payable ₹11,800', () => { + const { db, c, m } = setup() + const d = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) + expect(d.taxablePaise).toBe(10_000_00) + expect(d.cgstPaise).toBe(900_00) + expect(d.sgstPaise).toBe(900_00) + expect(d.igstPaise).toBe(0) + expect(d.payablePaise).toBe(11_800_00) + expect(d.status).toBe('draft') + expect(d.docNo).toBeNull() + }) + it('inter-state client gets IGST', () => { + const { db, m } = setup() + const db2 = db + const kar = createClient(db2, 'u1', { name: 'BLR Co', stateCode: '29' }) + const d = createDraft(db2, 'u1', { docType: 'INVOICE', clientId: kar.id, + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) + expect(d.igstPaise).toBe(1_800_00) + expect(d.cgstPaise).toBe(0) + }) + it('issue assigns a series number; convert QT→INV carries lines and links back', () => { + const { db, c, m } = setup() + const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) + const issued = issueDocument(db, 'u1', qt.id) + expect(issued.docNo).toMatch(/^QT\/\d{2}-\d{2}-\d{4}$/) + const inv = convertDocument(db, 'u1', qt.id, 'INVOICE') + expect(inv.refDocId).toBe(qt.id) + expect(inv.payablePaise).toBe(qt.payablePaise) + }) + it('credit note defaults to full value against the invoice; cancel keeps the number', () => { + const { db, c, m } = setup() + const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) + const cn = createCreditNote(db, 'u1', inv.id) + expect(cn.docType).toBe('CREDIT_NOTE') + expect(cn.payablePaise).toBe(inv.payablePaise) + const cancelled = cancelDocument(db, 'u1', inv.id) + expect(cancelled.status).toBe('cancelled') + expect(cancelled.docNo).toBe(inv.docNo) // number consumed, never reused + }) +}) +``` + +- [ ] **Step 2: Verify FAIL, implement.** Key points: + - `createDraft` builds `LineInput[]`: `{ itemId: moduleId, name: module.name + (description ? ' — ' + description : ''), hsn: module.sac, qty, unitCode: 'NOS', unitPricePaise: line.unitPricePaise ?? priceOn(db, moduleId, kind, edition ?? 'standard', today) ?? throw, priceIncludesTax: false, taxClassCode: 'GST18' }`; reads `tax_class` rows into `TaxClassRow[]`; `computeBill(lines, { businessDate: today, supplyStateCode, placeOfSupplyStateCode: client.state_code, roundToRupee: true }, rates)`; stores totals columns + full `payload` JSON; `doc_date = today`, `fy = fyOf(today)`. + - All lifecycle functions run inside `db.transaction(...)` and append `document_event`. + - `cancelDocument` throws if any `payment_allocation` references the doc. + - `createCreditNote` recomputes via `computeBill` from the invoice's payload lines (positive amounts; CN semantics are "negative" only in settlement math, Task 9). + +- [ ] **Step 3: Run → PASS (verify the golden paisa numbers exactly). Typecheck. Commit** — `git commit -m "feat(hq): document lifecycle with GST compute and credit notes (HQ-1 task 8)"` + +---### Task 9: Payments — allocation, TDS, advances, settlement + +**Files:** +- Create: `apps/hq/src/repos-payments.ts` +- Modify: `apps/hq/src/api.ts` +- Test: `apps/hq/test/payments.test.ts` + +**Interfaces:** +- Produces: + - `recordPayment(db, userId, { clientId, receivedOn, mode, reference?, amountPaise, tdsPaise?, allocations?: { documentId: string; amountPaise: number }[] }): { payment: Payment; allocated: { documentId: string; amountPaise: number }[] }` + - Default allocation when `allocations` omitted: **oldest-invoice-first** over the client's issued, unsettled invoices (`ORDER BY doc_date, doc_no`), each up to its outstanding; leftover stays **unallocated (advance)**. + - Settlement per invoice: `outstanding = payable − Σ allocations − Σ credit-note payable (CNs whose ref_doc_id = invoice)`. TDS is spread with the payment: the effective settling power of a payment is `amount + tds`, allocated as one pool. Status flips: any allocation > 0 → `part_paid`; outstanding ≤ 0 → `paid`. + - `clientLedger(db, clientId): { documents: Doc[]; payments: Payment[]; advancePaise: number }` + - `modulePaidView(db, clientId): { moduleId: string; billedPaise: number; settledPaise: number }[]` — invoice-level settlement spread **pro-rata across the invoice's lines** by `lineTotalPaise` (largest-remainder so the split sums exactly). +- Routes: `POST /api/payments`, `GET /api/clients/:id/ledger`. + +- [ ] **Step 1: Failing test** + +```ts +// apps/hq/test/payments.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, getDocument } from '../src/repos-documents' +import { recordPayment, clientLedger, modulePaidView } from '../src/repos-payments' + +function invoiceFor(db: any, clientId: string, moduleId: string) { + return issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId, + lines: [{ moduleId, qty: 1, kind: 'yearly' }] }).id) +} + +describe('payments', () => { + it('oldest-first default allocation; part_paid then paid; leftover is an advance', () => { + const db = openDb(':memory:') + db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() + db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) + const inv1 = invoiceFor(db, c.id, m.id) // ₹11,800 + const inv2 = invoiceFor(db, c.id, m.id) // ₹11,800 + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 15_000_00 }) + expect(getDocument(db, inv1.id)!.status).toBe('paid') // 11,800 settled + expect(getDocument(db, inv2.id)!.status).toBe('part_paid') // 3,200 of 11,800 + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-11', mode: 'upi', amountPaise: 10_000_00 }) + expect(getDocument(db, inv2.id)!.status).toBe('paid') + expect(clientLedger(db, c.id).advancePaise).toBe(1_400_00) // 25,000 − 23,600 + }) + it('invoice-minus-TDS settles in full', () => { + const db = openDb(':memory:') + db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() + db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) + const inv = invoiceFor(db, c.id, m.id) // ₹11,800; client pays minus 10% TDS on ₹10,000 + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', + amountPaise: 10_800_00, tdsPaise: 1_000_00 }) + expect(getDocument(db, inv.id)!.status).toBe('paid') + const view = modulePaidView(db, c.id) + expect(view[0]).toMatchObject({ moduleId: m.id, billedPaise: 11_800_00, settledPaise: 11_800_00 }) + }) +}) +``` + +- [ ] **Step 2: Verify FAIL, implement.** Allocation loop inside one transaction: pool = `amountPaise + tdsPaise`; iterate unsettled invoices oldest-first, allocate `min(pool, outstanding)`, insert `payment_allocation`, update status; remaining pool stays unallocated (the advance = payments Σ(amount+tds) − Σ allocations, computed on read — no extra table). Explicit `allocations` bypass the loop after validating each ≤ outstanding. `modulePaidView` spreads each invoice's settled total across payload lines pro-rata by `lineTotalPaise` using largest-remainder (same technique as `allocateProRata` in `packages/billing-engine/src/compute.ts` — reimplement locally, 10 lines, don't export engine internals). + +- [ ] **Step 3: Run → PASS (exact paisa asserts). Commit** — `git commit -m "feat(hq): payments with allocation, TDS and advances (HQ-1 task 9)"` + +--- + +### Task 10: Letterhead PDF + +**Files:** +- Create: `apps/hq/src/templates.ts`, `apps/hq/src/pdf.ts` +- Modify: `apps/hq/src/api.ts` +- Test: `apps/hq/test/templates.test.ts` + +**Interfaces:** +- Produces: `documentHtml(doc: Doc, client: Client, company: Record): string` — self-contained HTML (inline CSS, A4, letterhead block from settings `company.*`: name, address, gstin, phone, email, bank details block for invoices, doc-type title, lines table with SAC column, totals with CGST/SGST/IGST + round-off, amount in words, terms); `renderPdf(html: string): Promise` (lazy singleton puppeteer browser, `page.pdf({ format: 'A4', printBackground: true })`); route `GET /api/documents/:id/pdf` → `application/pdf`. +- Company settings keys (seeded Task 13, editable later): `company.name, company.address, company.gstin, company.state_code, company.phone, company.email, company.bank`. + +- [ ] **Step 1: Failing test** (HTML is unit-tested; puppeteer smoke-tested) + +```ts +// apps/hq/test/templates.test.ts +import { describe, it, expect } from 'vitest' +import { documentHtml } from '../src/templates' + +const doc = { + id: 'd1', docType: 'INVOICE', docNo: 'INV/26-27-0001', fy: '2026-27', clientId: 'c1', + docDate: '2026-07-10', status: 'draft', refDocId: null, taxablePaise: 10_000_00, + cgstPaise: 900_00, sgstPaise: 900_00, igstPaise: 0, roundOffPaise: 0, payablePaise: 11_800_00, + payload: { lines: [{ itemId: 'm1', name: 'POS Billing — yearly', hsn: '998313', qty: 1, + unitCode: 'NOS', unitPricePaise: 10_000_00, grossPaise: 10_000_00, discountPaise: 0, + taxablePaise: 10_000_00, taxRateBp: 1800, cgstPaise: 900_00, sgstPaise: 900_00, + igstPaise: 0, cessPaise: 0, lineTotalPaise: 11_800_00 }], totals: {} }, +} as never +const client = { id: 'c1', code: 'CL0001', name: 'Acme', stateCode: '32', address: 'Kochi', + contacts: [], status: 'active', notes: '' } as never +const company = { 'company.name': 'Tecnostac', 'company.gstin': '', 'company.address': '', + 'company.phone': '', 'company.email': '', 'company.bank': 'HDFC ****1234' } + +describe('document html', () => { + it('renders number, SAC, Indian-grouped totals and TAX INVOICE title', () => { + const html = documentHtml(doc, client, company) + expect(html).toContain('INV/26-27-0001') + expect(html).toContain('TAX INVOICE') + expect(html).toContain('998313') // SAC column + expect(html).toContain('₹11,800.00') // formatINR grouping + expect(html).toContain('HDFC ****1234') // bank block on invoices + }) + it('titles a quotation QUOTATION and omits the bank block', () => { + const html = documentHtml({ ...(doc as object), docType: 'QUOTATION', docNo: 'QT/26-27-0001' } as never, client, company) + expect(html).toContain('QUOTATION') + expect(html).not.toContain('HDFC') + }) +}) +``` + +- [ ] **Step 2: Verify FAIL, implement `templates.ts`** using `formatINR` from `@sims/domain` for every amount. Title map: QUOTATION→`QUOTATION`, PROFORMA→`PROFORMA INVOICE`, INVOICE→`TAX INVOICE`, CREDIT_NOTE→`CREDIT NOTE`, RECEIPT→`RECEIPT`. Amount-in-words: implement `rupeesInWords(paise: number): string` (Indian system — crore/lakh/thousand; integer rupees + paise) in `templates.ts` with its own test asserts (`11_800_00` → `'Rupees Eleven Thousand Eight Hundred Only'`). + +- [ ] **Step 3: Implement `pdf.ts` + route.** `renderPdf` guards puppeteer launch behind a module-level promise; route 404s on missing doc, streams the buffer with `Content-Disposition: inline; filename=".pdf"`. Add a **manual smoke step** (puppeteer in CI-less env): `npx tsx -e "import('./apps/hq/src/pdf.ts').then(async m => { const b = await m.renderPdf('

hi

'); console.log(b.subarray(0,5).toString()) })"` → prints `%PDF-`. + +- [ ] **Step 4: Run tests → PASS; smoke prints `%PDF-`. Commit** — `git commit -m "feat(hq): letterhead HTML templates and puppeteer PDF (HQ-1 task 10)"` + +--- + +### Task 11: Gmail sending + token-death handling + email log + +**Files:** +- Create: `apps/hq/src/crypto.ts`, `apps/hq/src/gmail.ts`, `apps/hq/src/repos-email.ts` +- Modify: `apps/hq/src/api.ts` +- Test: `apps/hq/test/gmail.test.ts` + +**Interfaces:** +- Produces: + - `crypto.ts`: `encrypt(plain: string, keyHex: string): string` / `decrypt(payload: string, keyHex: string): string` — AES-256-GCM, payload = `iv.tag.ciphertext` hex-joined by `.`. + - `repos-email.ts`: `saveAccount(db, address, refreshTokenEnc)`; `getAccount(db)`; `markAccountDead(db)`; `logEmail(db, { documentId?, to, subject, status, gmailMessageId?, error? })`; `emailStatus(db): { connected: boolean; address?: string; dead: boolean }`. + - `gmail.ts`: `type Fetcher = typeof fetch`; `getAccessToken(refreshToken, clientId, clientSecret, f: Fetcher): Promise` — throws `TokenDeadError` when the token endpoint returns `error: 'invalid_grant'`; `buildMime({ from, to, subject, bodyText, attachment?: { filename, contentType, data: Buffer } }): string` (base64url raw); `sendDocumentEmail(db, deps: { f: Fetcher; clientId: string; clientSecret: string; keyHex: string }, { documentId, to, subject, bodyText, pdf: Buffer }): Promise<{ ok: true } | { ok: false; error: string }>` — on `TokenDeadError`: `markAccountDead(db)`, log `failed`, return `{ ok: false, error: 'gmail-token-dead' }` (the dashboard banner reads `emailStatus`). On success: log `sent`, append `document_event` kind `sent`, `markStatus → sent` if draft… issued check: only issued docs can be sent (no number = throw). +- Routes: `POST /api/documents/:id/send { to?, subject?, body? }` (defaults: first client contact email; subject `" "`); `GET /api/email/status`. + +- [ ] **Step 1: Failing test** (stubbed `fetch` — no network) + +```ts +// apps/hq/test/gmail.test.ts +import { describe, it, expect } from 'vitest' +import { encrypt, decrypt } from '../src/crypto' +import { getAccessToken, buildMime, TokenDeadError } from '../src/gmail' + +const KEY = '11'.repeat(32) + +describe('gmail plumbing', () => { + it('crypto round-trips', () => { + expect(decrypt(encrypt('refresh-token-x', KEY), KEY)).toBe('refresh-token-x') + }) + it('exchanges refresh token; surfaces invalid_grant as TokenDeadError', async () => { + const okFetch = (async () => new Response(JSON.stringify({ access_token: 'at-1' }), { status: 200 })) as typeof fetch + expect(await getAccessToken('rt', 'cid', 'sec', okFetch)).toBe('at-1') + const deadFetch = (async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })) as typeof fetch + await expect(getAccessToken('rt', 'cid', 'sec', deadFetch)).rejects.toBeInstanceOf(TokenDeadError) + }) + it('builds a MIME message with PDF attachment', () => { + const raw = buildMime({ from: 'a@b.c', to: 'x@y.z', subject: 'INV INV/26-27-0001', + bodyText: 'Please find attached.', attachment: { filename: 'inv.pdf', contentType: 'application/pdf', data: Buffer.from('%PDF-') } }) + const decoded = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString() + expect(decoded).toContain('Subject: INV INV/26-27-0001') + expect(decoded).toContain('application/pdf') + }) +}) +``` + +- [ ] **Step 2: Verify FAIL, implement.** `getAccessToken` POSTs `https://oauth2.googleapis.com/token` (`grant_type=refresh_token`); send POSTs `https://gmail.googleapis.com/gmail/v1/users/me/messages/send` with `{ raw }`. `buildMime` uses a fixed multipart boundary string and base64url-encodes the final message. `sendDocumentEmail` orchestrates: `getAccount` → decrypt → token → send → logs/events; every failure path logs `failed` with the error text. + +- [ ] **Step 3: Wire routes; add a guard so `/send` on a dead account returns 409 `{ error: 'gmail-token-dead' }` without attempting.** Run tests → PASS. Commit — `git commit -m "feat(hq): gmail send with token-death handling and email log (HQ-1 task 11)"` + +--- + +### Task 12: One-time Gmail connect script + +**Files:** +- Create: `apps/hq/scripts/gmail-connect.ts` +- Test: `apps/hq/test/gmail-connect.test.ts` (URL construction only) + +**Interfaces:** +- Produces: `authUrl(clientId: string, redirectUri: string): string` (exported for test) — scope `https://www.googleapis.com/auth/gmail.send`, `access_type=offline`, `prompt=consent`; CLI flow: prints the URL, listens once on `http://localhost:5190/callback`, exchanges the code (`https://oauth2.googleapis.com/token`), encrypts the refresh token with `HQ_SECRET_KEY`, `saveAccount(db, address, enc)` (address from `https://gmail.googleapis.com/gmail/v1/users/me/profile`). Run via `npx tsx apps/hq/scripts/gmail-connect.ts` with `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`/`HQ_SECRET_KEY` env set. + +- [ ] **Step 1: Failing test** + +```ts +// apps/hq/test/gmail-connect.test.ts +import { describe, it, expect } from 'vitest' +import { authUrl } from '../scripts/gmail-connect' + +describe('gmail connect', () => { + it('builds the consent URL with offline access and gmail.send scope', () => { + const u = new URL(authUrl('cid-1', 'http://localhost:5190/callback')) + expect(u.origin + u.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth') + expect(u.searchParams.get('client_id')).toBe('cid-1') + expect(u.searchParams.get('access_type')).toBe('offline') + expect(u.searchParams.get('prompt')).toBe('consent') + expect(u.searchParams.get('scope')).toContain('gmail.send') + }) +}) +``` + +- [ ] **Step 2: Verify FAIL, implement, run → PASS. Commit** — `git commit -m "feat(hq): one-time gmail oauth connect script (HQ-1 task 12)"` + +--- + +### Task 13: Seed + APEX CSV importer with verification report + +**Files:** +- Create: `apps/hq/src/seed.ts`, `apps/hq/src/import-apex.ts` +- Modify: `apps/hq/src/server.ts` (call `seedIfEmpty`), `apps/hq/package.json` (script `"import": "tsx src/import-apex.ts"`) +- Test: `apps/hq/test/import.test.ts`, fixtures `apps/hq/test/fixtures/clients.csv`, `apps/hq/test/fixtures/invoices.csv` + +**Interfaces:** +- Produces: + - `seedIfEmpty(db): void` — if no `staff_user`: owner `admin@tecnostac.com` with a random 12-char password **printed to console once**; settings `company.*` placeholders + `company.state_code='32'`; `tax_class` row `('GST18', 1800, '2017-07-01')`. + - `stageCsv(db, kind: 'clients'|'invoices', csvText: string): { staged: number }` — tiny CSV parser (header row, comma, double-quote escaping — ~20 lines, no dependency); per-row problems collected into `problems` JSON: missing name, GSTIN checksum failure, bad date, duplicate code/doc_no. + - `verificationReport(db): { clients: { staged: number; problems: number }; invoices: { staged: number; problems: number; totalPaise: number }; samples: { firstClients: string[]; lastInvoices: string[] } }` + - `commitImport(db, userId): { clients: number; invoices: number; seeded: { fy: string; lastSeq: number } | null }` — inserts clients (`source='apex'`), invoices as issued documents (`source='apex'`, status `paid` when `paid=1` else `sent`, payload lines empty — history-only), then **seeds the INVOICE series**: parse the max numeric tail of current-FY staged doc numbers → `seedSeries(db,'INVOICE', fy, lastSeq)`. Refuses to run when any staged row has problems. + - CLI: `npm run import -- --dir [--commit]` — stages `clients.csv` + `invoices.csv`, prints the report, applies only with `--commit`. +- CSV columns (the APEX export contract — schema walk-through, spec Open item 5, fills real column mapping): + - `clients.csv`: `code,name,gstin,state_code,address,phone,email,status` + - `invoices.csv`: `client_code,doc_no,doc_date,taxable,tax,total,paid` (rupee decimals; converted via `fromRupees`) + +- [ ] **Step 1: Failing test** + +```ts +// apps/hq/test/import.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { stageCsv, verificationReport, commitImport } from '../src/import-apex' + +const CLIENTS = `code,name,gstin,state_code,address,phone,email,status +AC001,Acme Traders,,32,Kochi,9744000001,acme@x.in,active +,No Code Shop,,32,,,,active` +const INVOICES = `client_code,doc_no,doc_date,taxable,tax,total,paid +AC001,TS/26-27/0411,2026-05-02,10000.00,1800.00,11800.00,1 +AC001,TS/26-27/0412,2026-06-15,5000.00,900.00,5900.00,0` + +describe('apex import', () => { + it('stages, reports problems, refuses commit until clean', () => { + const db = openDb(':memory:'); seedIfEmpty(db) + stageCsv(db, 'clients', CLIENTS) + stageCsv(db, 'invoices', INVOICES) + const rep = verificationReport(db) + expect(rep.clients.staged).toBe(2) + expect(rep.clients.problems).toBe(1) // missing code + expect(() => commitImport(db, 'u1')).toThrow(/problem/) + }) + it('commits clean data and seeds the invoice series from the last APEX number', () => { + const db = openDb(':memory:'); seedIfEmpty(db) + stageCsv(db, 'clients', CLIENTS.split('\n').slice(0, 2).join('\n')) + stageCsv(db, 'invoices', INVOICES) + const out = commitImport(db, 'u1') + expect(out).toMatchObject({ clients: 1, invoices: 2, seeded: { fy: '2026-27', lastSeq: 412 } }) + const paid = db.prepare(`SELECT status FROM document WHERE doc_no='TS/26-27/0411'`).get() as { status: string } + expect(paid.status).toBe('paid') + }) +}) +``` + +- [ ] **Step 2: Verify FAIL, implement, run → PASS.** (`seedIfEmpty` output uses `console.log` — assert owner row exists rather than capturing stdout.) + +- [ ] **Step 3: Commit** — `git commit -m "feat(hq): seed + apex csv importer with verification and series seeding (HQ-1 task 13)"` + +--- + +### Task 14: Server assembly + build script + end-to-end API smoke + +**Files:** +- Create: `apps/hq/build-server.mjs` (copy `apps/store-server/build-server.mjs`, change entry to `src/server.ts`, add `puppeteer` to externals alongside `better-sqlite3`) +- Modify: `apps/hq/src/server.ts` — serve `../hq-web/dist` at `/` (static, when the folder exists), wire seed +- Test: `apps/hq/test/e2e.test.ts` + +**Interfaces:** none new — this task proves the whole API chain. + +- [ ] **Step 1: Write the end-to-end test** (in-memory DB injected; covers login → client → module+price → quotation → issue → convert → payment → paid → ledger): + +```ts +// apps/hq/test/e2e.test.ts +import { describe, it, expect, afterAll } from 'vitest' +import express from 'express' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createStaff } from '../src/auth' +import { apiRouter } from '../src/api' + +const db = openDb(':memory:') +seedIfEmpty(db) +createStaff(db, { email: 'e2e@test.in', displayName: 'E2E', role: 'owner', password: 'e2e-password' }) +const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) +const server = app.listen(0) +const base = `http://localhost:${(server.address() as { port: number }).port}/api` +let token = '' +const call = async (method: string, path: string, body?: unknown) => { + const res = await fetch(base + path, { method, + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + ...(body ? { body: JSON.stringify(body) } : {}) }) + return { status: res.status, json: await res.json() as any } +} + +describe('hq end to end', () => { + afterAll(() => server.close()) + it('walks quotation → invoice → payment → paid', async () => { + token = (await call('POST', '/auth/login', { email: 'e2e@test.in', password: 'e2e-password' })).json.token + const client = (await call('POST', '/clients', { name: 'Flow Mart', stateCode: '32' })).json.client + const mod = (await call('POST', '/modules', { code: 'POS', name: 'POS Billing' })).json.module + await call('POST', `/modules/${mod.id}/prices`, { kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) + const qt = (await call('POST', '/documents', { docType: 'QUOTATION', clientId: client.id, + lines: [{ moduleId: mod.id, qty: 1, kind: 'yearly' }] })).json.document + await call('POST', `/documents/${qt.id}/issue`) + const inv = (await call('POST', `/documents/${qt.id}/convert`, { to: 'INVOICE' })).json.document + await call('POST', `/documents/${inv.id}/issue`) + await call('POST', '/payments', { clientId: client.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 11_800_00 }) + const after = (await call('GET', `/documents/${inv.id}`)).json.document + expect(after.status).toBe('paid') + const ledger = (await call('GET', `/clients/${client.id}/ledger`)).json + expect(ledger.advancePaise).toBe(0) + }) +}) +``` + +- [ ] **Step 2: Fix anything the walk exposes; run the FULL suite** — `npm test` → all green (existing 54 + all hq tests). `npm run typecheck` → clean. + +- [ ] **Step 3: Manual boot check** — `cd apps/hq && npm start`, then `curl http://localhost:5182/api/health` → `{"ok":true,...}`. Console prints the seeded owner password on first boot. + +- [ ] **Step 4: Commit** — `git commit -m "feat(hq): server assembly, build script and e2e smoke (HQ-1 task 14)"` + +--- + +### Task 15: `apps/hq-web` — scaffold, login, layout, API client + +**Files:** +- Create: `apps/hq-web/package.json` (copy `apps/backoffice/package.json`, name `@sims/hq-web`, drop `@sims/scanning`/`@sims/billing-engine`), `apps/hq-web/tsconfig.json` + `vite.config.ts` + `index.html` (copy from `apps/backoffice`, retitle "SiMS HQ", dev proxy `/api → http://localhost:5182`), `apps/hq-web/src/main.tsx`, `src/api.ts`, `src/Login.tsx`, `src/Layout.tsx` + +**Interfaces:** +- Produces: `api.ts`: `apiFetch(path, opts?)` — adds `Authorization` from `localStorage['hq.token']`, redirects to `/login` on 401; `main.tsx` routes: `/login`, then inside `Layout`: `/` (Clients), `/clients/:id`, `/modules`, `/documents/new`, `/documents/:id`. +- `Layout.tsx`: `@sims/ui` shell + left nav (Clients · Modules · New Document) + **email-status banner**: on mount `GET /api/email/status`; when `dead` or not connected show a persistent amber bar "Gmail disconnected — sends are queued to manual. Run gmail-connect on the server." (the spec's dashboard banner). + +- [ ] **Step 1: Scaffold + implement the four files.** Follow `apps/backoffice/src/Login.tsx` and `Layout.tsx` as the style/pattern source; Login posts `/api/auth/login`, stores token + display name, navigates `/`. +- [ ] **Step 2: Verify** — `cd apps/hq-web && npm run typecheck && npm run build` → clean build; `npm run dev` with the server running: login with the seeded owner works, banner shows "Gmail disconnected". +- [ ] **Step 3: Commit** — `git commit -m "feat(hq-web): react shell with login and email-status banner (HQ-1 task 15)"` + +--- + +### Task 16: `apps/hq-web` — the five working pages + +**Files:** +- Create: `apps/hq-web/src/pages/Clients.tsx`, `ClientDetail.tsx`, `Modules.tsx`, `NewDocument.tsx`, `DocumentView.tsx` + +**Page contracts (each is a plain fetch-render-act React page using `@sims/ui` components):** +- `Clients.tsx` — search box (`GET /api/clients?q=`), table (code, name, status, state), "New client" inline form (name, state code, GSTIN, contact email/phone). Row click → `/clients/:id`. +- `ClientDetail.tsx` — the Client 360° skeleton: header (name, code, status select PATCHing), three sections fetched from `GET /api/clients/:id`, `/modules`, `/ledger`: **Modules** (assign form: module select + kind select from the module's `allowedKinds`; per-row status select + date inputs for installed/completed/trained), **Documents** (table: no, type, date, payable, status; link to `/documents/:id`), **Payments & dues** (ledger table + advance balance + "Record payment" form: amount ₹, TDS ₹, mode, reference — posts `/api/payments`, amounts converted with `fromRupees`). +- `Modules.tsx` — catalog table + create form; per-module price rows + "Add price" (kind, edition, ₹, effective from) — owner sees forms, staff read-only (render by `role` from login). +- `NewDocument.tsx` — the **quotation-in-minutes composer**: client type-ahead (debounced `GET /api/clients?q=`), doc-type radio (Quotation/Proforma/Invoice), line rows (module select → kind select → qty → unit ₹ prefilled from `GET /api/modules/:id/prices` latest for kind, editable), live client-side total preview (sum only — the server's compute is authoritative), Save Draft → `POST /api/documents` → navigate to `/documents/:id`. +- `DocumentView.tsx` — header (type, no or "draft", client, status chip), embedded `