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

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 (Paise from @sims/domain); never floats.
  • All ids are UUIDv7 via uuidv7() from @sims/domain.
  • Every mutation writes an audit_log row (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 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 (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

// 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 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

// 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 failsnpx 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 testsnpx 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 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

// 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 testsnpx vitest run apps/hq/test → PASS (health + db + auth). Typecheck clean.

  • Step 4: Commitgit 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

// 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: Commitgit 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

// 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:
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. Commitgit 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

// 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, commitgit 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

// 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. Commitgit 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)

// 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. Commitgit 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

// 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). Commitgit 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/pdfapplication/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.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-. Commitgit 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)

// 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

// 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. Commitgit 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

// 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: Commitgit 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; entry src/server.ts; better-sqlite3 and puppeteer as externals)
  • 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):
// 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 suitenpm test → all green (existing 54 + all hq tests). npm run typecheck → clean.

  • Step 3: Manual boot checkcd apps/hq && npm start, then curl http://localhost:5182/api/health{"ok":true,...}. Console prints the seeded owner password on first boot.

  • Step 4: Commitgit 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?) — 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. Build Login + Layout in the shared @sims/ui style; Login posts /api/auth/login, stores token + display name, navigates /.

  • Step 2: Verifycd 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: Commitgit 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: Commitgit 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.