You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/superpowers/plans/2026-07-10-hq1-console.md

1192 lines
65 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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; 808085 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 <noreply@anthropic.com>`.
## 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<DocType,string> = { 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<string, string> = {
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,string>): 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<Buffer>` (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="<docNo or 'draft'>.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('<h1>hi</h1>'); 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<string>` — 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 `"<TYPE> <docNo> — <company.name>"`); `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 <folder> [--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 `<iframe src={/api/documents/:id/pdf}>` preview, action bar by state: **Issue number** (draft, no number) · **Send by email** (issued; prompt-prefilled to/subject; on 409 `gmail-token-dead` show the banner error) · **Mark accepted / lost** (sent quotations) · **Convert to Proforma / Invoice** · **Record payment** (issued invoices; same form as ClientDetail) · **Cancel** (unpaid issued) · **Credit note** (invoices) — each a `POST` then refetch. Event timeline (document_event list) + email log at the bottom — the traceability view.
- [ ] **Step 1: Implement the five pages.**
- [ ] **Step 2: Verify end-to-end in the browser** (server on 5182, `npm run dev` in hq-web): create module + price → create client → compose quotation → issue → PDF preview shows letterhead + `QT/26-27-0001` → convert to invoice → issue → record payment → status flips to paid → timeline shows every step. `npm run build && npm run typecheck` clean in both new workspaces; full `npm test` green at root.
- [ ] **Step 3: Commit**`git commit -m "feat(hq-web): clients, modules, composer and document pages (HQ-1 task 16)"`
---
## Deferred out of HQ-1 (explicitly)
Recurring plans + reminder scheduler + AMC + dashboard money view (HQ-2); AWS cost pull (HQ-3); bounce detection (HQ-2, with the scheduler); receipt PDFs on payment (trivial follow-up); deploy scripts for the AWS instance (separate ops task — the app runs anywhere Node runs).
## Verification (whole plan)
1. `npm test` — every task's suite + the original 54 stay green.
2. `npm run typecheck` — clean.
3. Browser walk (Task 16 Step 2) — the founder-visible "quotation in minutes" flow works against real data.
4. `npm run import -- --dir <apex-export> ` on a real APEX CSV export — verification report shows sane counts before any `--commit`.