# 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 `