65 KiB
HQ-1 — Internal Ops Console Implementation Plan
STATUS: DELIVERED (HQ-1, 2026-07). Kept as the design/task record; current state lives in STATUS.md.
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: 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: Follow the established server pattern: 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 (
Paisefrom@sims/domain); never floats. - All ids are UUIDv7 via
uuidv7()from@sims/domain. - Every mutation writes an
audit_logrow (append-only audit discipline). - 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
settingtable keycompany.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 (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 <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.Serverexported for tests. -
Step 1: Write the failing test
// 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:
test: {
include: ['packages/*/test/**/*.test.ts', 'apps/*/test/**/*.test.ts'],
},
apps/hq/package.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 — standard app tsconfig extending tsconfig.base.json (same compiler options as the other apps).
- 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
// 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
}
// 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
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 whendataDir === ':memory:');writeAudit(db, userId, action, entity, entityId, before?, after?): void;listAudit(db, limit?): AuditRow[]whereAuditRow = { 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
// 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
// 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
}
// 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
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 realopenDb()handle) - Test:
apps/hq/test/auth.test.ts
Interfaces:
-
Consumes:
hashPin/verifyPinfrom@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/requireOwnerexpress middlewares that setres.locals.staff = { id, role };apiRouter(db)gainsPOST /auth/login. -
Step 1: Write the failing test
// 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:
// 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)):
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-codeCL0001… when code omitted; GSTIN checksum viaassertGstinfrom@sims/domainwhen provided);updateClient(db, userId, id, patch): Client. -
Routes:
GET /api/clients?q=,POST /api/clients,GET /api/clients/:id,PATCH /api/clients/:id— all behindrequireAuth. -
Step 1: Failing test
// 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.tshouse pattern exactly (snake_case row interface → camelCase mapper).createClientcomputescodeas'CL' + String(count + 1).padStart(4, '0')unless provided, validates GSTIN withassertGstin(check the exact export name inpackages/domain/src/gstin.tsbefore importing — if it isvalidateGstinreturning boolean, throw on false), stampscreated_at = new Date().toISOString(), writes auditcreate.updateClientreads the before-row, applies only known keys (name,gstin,stateCode,address,contacts,status,notes), writes auditupdatewith before/after. Routes inapi.tsunderrequireAuth, 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(latesteffective_from <= onDatewins — 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
// 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.
priceOnSQL:
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 ifkindnot in the module'sallowed_kinds, or if an active row exists and the module hasmulti_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
// 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)+assertGstDocNofrom@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
// 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
// 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,TaxClassRowfrom@sims/billing-engine;nextDocNo;priceOn;fyOffrom@sims/domain; settingcompany.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 viacomputeBillwithpriceIncludesTax: false,roundToRupee: true, tax class'GST18'(seeded Task 13),supplyStateCodefrom settings,placeOfSupplyStateCodefrom the client row; module SAC goes into thehsnfield ofLineInput.issueDocument(db, userId, docId): Doc— assignsdoc_no = nextDocNo(...)inside one transaction, movesdraft → sentis NOT done here (send/mark separately): issue = number assignment, status staysdraftuntil 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 statusinvoicedwhen 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 adocument_eventrow (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)
// 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:
createDraftbuildsLineInput[]:{ 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' }; readstax_classrows intoTaxClassRow[];computeBill(lines, { businessDate: today, supplyStateCode, placeOfSupplyStateCode: client.state_code, roundToRupee: true }, rates); stores totals columns + fullpayloadJSON;doc_date = today,fy = fyOf(today).- All lifecycle functions run inside
db.transaction(...)and appenddocument_event. cancelDocumentthrows if anypayment_allocationreferences the doc.createCreditNoterecomputes viacomputeBillfrom 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
allocationsomitted: 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 isamount + 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 bylineTotalPaise(largest-remainder so the split sums exactly).
-
Routes:
POST /api/payments,GET /api/clients/:id/ledger. -
Step 1: Failing test
// 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, allocatemin(pool, outstanding), insertpayment_allocation, update status; remaining pool stays unallocated (the advance = payments Σ(amount+tds) − Σ allocations, computed on read — no extra table). Explicitallocationsbypass the loop after validating each ≤ outstanding.modulePaidViewspreads each invoice's settled total across payload lines pro-rata bylineTotalPaiseusing largest-remainder (same technique asallocateProRatainpackages/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 settingscompany.*: 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 })); routeGET /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)
// 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.tsusingformatINRfrom@sims/domainfor every amount. Title map: QUOTATION→QUOTATION, PROFORMA→PROFORMA INVOICE, INVOICE→TAX INVOICE, CREDIT_NOTE→CREDIT NOTE, RECEIPT→RECEIPT. Amount-in-words: implementrupeesInWords(paise: number): string(Indian system — crore/lakh/thousand; integer rupees + paise) intemplates.tswith its own test asserts (11_800_00→'Rupees Eleven Thousand Eight Hundred Only'). -
Step 3: Implement
pdf.ts+ route.renderPdfguards puppeteer launch behind a module-level promise; route 404s on missing doc, streams the buffer withContent-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.ciphertexthex-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>— throwsTokenDeadErrorwhen the token endpoint returnserror: '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 }>— onTokenDeadError:markAccountDead(db), logfailed, return{ ok: false, error: 'gmail-token-dead' }(the dashboard banner readsemailStatus). On success: logsent, appenddocument_eventkindsent,markStatus → sentif 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)
// 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.
getAccessTokenPOSTshttps://oauth2.googleapis.com/token(grant_type=refresh_token); send POSTshttps://gmail.googleapis.com/gmail/v1/users/me/messages/sendwith{ raw }.buildMimeuses a fixed multipart boundary string and base64url-encodes the final message.sendDocumentEmailorchestrates:getAccount→ decrypt → token → send → logs/events; every failure path logsfailedwith the error text. -
Step 3: Wire routes; add a guard so
/sendon 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) — scopehttps://www.googleapis.com/auth/gmail.send,access_type=offline,prompt=consent; CLI flow: prints the URL, listens once onhttp://localhost:5190/callback, exchanges the code (https://oauth2.googleapis.com/token), encrypts the refresh token withHQ_SECRET_KEY,saveAccount(db, address, enc)(address fromhttps://gmail.googleapis.com/gmail/v1/users/me/profile). Run vianpx tsx apps/hq/scripts/gmail-connect.tswithGOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/HQ_SECRET_KEYenv set. -
Step 1: Failing test
// 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(callseedIfEmpty),apps/hq/package.json(script"import": "tsx src/import-apex.ts") - Test:
apps/hq/test/import.test.ts, fixturesapps/hq/test/fixtures/clients.csv,apps/hq/test/fixtures/invoices.csv
Interfaces:
-
Produces:
seedIfEmpty(db): void— if nostaff_user: owneradmin@tecnostac.comwith a random 12-char password printed to console once; settingscompany.*placeholders +company.state_code='32';tax_classrow('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 intoproblemsJSON: 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', statuspaidwhenpaid=1elsesent, 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]— stagesclients.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,statusinvoices.csv:client_code,doc_no,doc_date,taxable,tax,total,paid(rupee decimals; converted viafromRupees)
-
Step 1: Failing test
// 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. (
seedIfEmptyoutput usesconsole.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(esbuild bundle; entrysrc/server.ts;better-sqlite3andpuppeteeras externals) - Modify:
apps/hq/src/server.ts— serve../hq-web/distat/(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):
// 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, thencurl 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(name@sims/hq-web; React 19 + Vite; deps@sims/domain+@sims/ui),apps/hq-web/tsconfig.json+vite.config.ts+index.html(titled "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?)— addsAuthorizationfromlocalStorage['hq.token'], redirects to/loginon 401;main.tsxroutes:/login, then insideLayout:/(Clients),/clients/:id,/modules,/documents/new,/documents/:id. -
Layout.tsx:@sims/uishell + left nav (Clients · Modules · New Document) + email-status banner: on mountGET /api/email/status; whendeador 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. Build Login + Layout in the shared
@sims/uistyle; 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 devwith 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 fromGET /api/clients/:id,/modules,/ledger: Modules (assign form: module select + kind select from the module'sallowedKinds; 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 withfromRupees). -
Modules.tsx— catalog table + create form; per-module price rows + "Add price" (kind, edition, ₹, effective from) — owner sees forms, staff read-only (render byrolefrom login). -
NewDocument.tsx— the quotation-in-minutes composer: client type-ahead (debouncedGET /api/clients?q=), doc-type radio (Quotation/Proforma/Invoice), line rows (module select → kind select → qty → unit ₹ prefilled fromGET /api/modules/:id/priceslatest 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 409gmail-token-deadshow 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 aPOSTthen 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 devin 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 typecheckclean in both new workspaces; fullnpm testgreen 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)
npm test— every task's suite + the original 54 stay green.npm run typecheck— clean.- Browser walk (Task 16 Step 2) — the founder-visible "quotation in minutes" flow works against real data.
npm run import -- --dir <apex-export>on a real APEX CSV export — verification report shows sane counts before any--commit.