# Live Document Preview — Implementation Plan (HQ console) > **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. **Server tasks (1–7) are full TDD loops:** write the failing test verbatim → run it to watch it fail → write the implementation → run it to green → `npm run typecheck` → commit. **Frontend tasks (8–13) cannot be unit-tested** (they are iframe/layout/matchMedia/upload UI) — each ends with an explicit **browser-verification** step against the live app instead of a vitest step, still committing on completion. No task depends on code a later task writes. **Goal:** Ship the live document preview from [docs/superpowers/specs/2026-07-13-live-preview-design.md](../specs/2026-07-13-live-preview-design.md) on the completed HQ-1/HQ-2/HQ-3a `apps/hq` + `apps/hq-web` codebase. Staff drafting a quotation watch the letterhead build itself under ~400ms per edit, with GST (including the IGST split for out-of-state clients) always computed by the server; the on-screen paper is **byte-identical** to the PDF that later prints; there are no blank flashes; **save stays strict** so bad drafts never persist while **preview is permissive** so staff can keep typing. The same mechanism previews the company letterhead, module quote-content bullets, and reminder emails. **Architecture:** The one idea that makes this safe — the preview shows the output of the **same** `documentHtml()` template and the **same** `computeBill()` tax engine the PDF uses. There is no second renderer and **no client-side money math**. We extract a pure `prepareDraft(db, input)` from `createDraft` (compute-only, no persist); `createDraft` then calls `prepareDraft` + persists, so save and preview cannot diverge by construction. A new `POST /api/documents/preview` builds a synthetic **unsaved** `Doc`, runs `prepareDraft` + `documentHtml`, and returns `{ ok, html, totals, warnings }` — persisting nothing, writing no audit. The browser renders that HTML directly in a sandboxed `srcdoc` iframe; puppeteer only rasterizes the identical HTML for the actual PDF. Sibling surfaces reuse the one renderer: the letterhead and quote-content previews go through `documentHtmlSample()` + a single thin `POST /api/previews/sample` route (unsaved edits posted in the request body); the reminder-email preview goes through the pure `reminderEmail(kind, ctx)` via `GET /api/reminders/:id/preview`, with the ctx-builder extracted so the previewed email equals the sent one. **Editable template data (spec §4, founder addition):** all letterhead **text data** becomes DB-editable through settings — one fixed, well-designed layout, **not** a visual layout editor (that is the Store product's Print Templates surface — a separate product/repo — out of scope). GST split logic stays automatic (derived from `company.state_code` vs the client state). Beyond the existing `company.*` settings, `templates.ts` renders new optional `template.*` keys (`template.terms`, `template.declaration`, `template.jurisdiction`, `template.footer_note`, `template.signatory_label`, `template.logo` as a size-capped `data:` URI, and per-type `template.title_`) **each with a built-in default so an absent key renders exactly today's output** — nothing breaks pre-configuration. These live in the same `setting` table (`getSetting`/`setSetting`), so an edit is a DB write the next PDF render and the live preview reflect immediately, no release. A new owner-gated **Document Template** page (`DocumentTemplate.tsx`) groups the `company.*` block + the `template.*` fields + a logo upload with the live letterhead preview beside the form — it **absorbs** the earlier company-profile-letterhead idea into one page. New routes `GET/PUT /api/settings/template` (both groups, owner-gated, each field audited via `setSetting`) and `POST /api/settings/template/logo` (validates content-type + size cap, stores the data URI) model on the existing `GET/PUT /api/settings/company`. **Tech Stack:** unchanged — TypeScript (ESM, strict), express ^4.21, better-sqlite3 ^11.7, puppeteer ^23 (never launched in tests), vitest ^3, React 19 + Vite 6 + react-router 7, `@sims/ui`. **No new dependencies.** The preview iframe uses native `srcdoc` + `AbortController`; no rasterization anywhere in the test suite. ## Global Constraints Everything from the HQ-1/HQ-2/HQ-3a plans still holds; the load-bearing ones for the live preview: - **Money is integer paise**, server-computed. `computeBill` is the only tax authority. The client-side subtotal reducer at `NewDocument.tsx:91` is **DELETED** — its removal is the single biggest trust win (the IGST split now appears live for out-of-state clients). No arithmetic on amounts ever runs in `apps/hq-web`; the totals strip renders `response.totals` verbatim via `formatINR`. - **Preview persists nothing.** `POST /api/documents/preview` and `POST /api/previews/sample` write no `document`, no `audit_log`, no `document_event` row — they are reads. `prepareDraft` is pure (SQLite reads + string templating, single-digit ms); no puppeteer, no browser contention. - **Permissive preview vs strict save.** The preview path (`prepareDraft(..., { permissive: true })`) computes a line with no price-book row and no manual rate at **₹0** and reports it in `warnings[]`, never throwing where save would. `POST /api/documents` (save) and `createDraft` stay **strict** and still reject the same bad document with the unchanged error. Empty composer (zero lines) previews as a real letterhead with an empty table and ₹0 — never a mock. - **Fidelity is architectural, guarded by one golden test.** A string-identity test asserts the preview route's `html` equals the exact string `renderPdf` would receive for a fixture draft; a `prepareDraft`↔`createDraft` parity test asserts totals/payload deep-equal. **No puppeteer in either test — compare the HTML strings, do not rasterize.** - All ids remain **UUIDv7**; the preview synthetic `Doc` uses the throwaway `id: 'preview'` (never persisted). `docNo: null` → the template prints "DRAFT"; `docDate` = today; `fy` via `fyOf`. - SQL stays **portable** (D12). This feature adds **no schema, no migration, no new table** — it is compute + templating + routes + UI only. - **Screen/print deltas are template-owned.** The ~5-line `@media screen` paper styles live **inside `templates.ts`** (with a `page.pdf renders print media` comment) so the paper itself is template-owned and the PDF is unaffected (print media ignores `@media screen`); only preview *chrome* (shimmer, paused banner, totals pulse, "split pending client" tag) is client overlay. - Server port **5182**; hq-web Vite dev server **5183**, proxying `/api` → `http://localhost:5182` (see `apps/hq-web/vite.config.ts`). - **Tests are offline and deterministic** — vitest under `apps/hq/test/` (already in the vitest `include`); the express+listen+fetch harness from `settings-company.test.ts`; puppeteer never launches. No test performs a network call. - **Per-app typecheck is part of the root gate:** `npm run typecheck` runs `tsc -p tsconfig.json && npm run typecheck --workspaces --if-present`. Both `apps/hq` and `apps/hq-web` must typecheck clean at every commit, and `npm run build -w @sims/hq-web` (Vite) must stay clean. - Commit after every task. Each commit message ends with the trailer — use the two-`-m` form: ```bash git commit -m "feat(hq): " -m "Co-Authored-By: Claude Fable 5 " ``` ## Scope (locked — do not reopen) **In:** `prepareDraft` extraction + parity/permissive; `POST /api/documents/preview`; the string-identity fidelity test; `templates.ts` rendering the new `template.*` fields with default-fallback; `@media screen` paper styles + `documentHtmlSample` + `POST /api/previews/sample`; `GET/PUT /api/settings/template` + `POST /api/settings/template/logo`; `reminderContext` extraction + `GET /api/reminders/:id/preview`; `LivePreview.tsx` (debounced/immediate fetch + double-buffer iframe); `NewDocument.tsx` split layout + `buildDraftBody` + server-truth totals strip (reducer deleted) + responsive narrow layout; the owner-gated **Document Template** page (company block + `template.*` + logo upload + live letterhead preview, absorbing the company-profile letterhead surface); module quote-content live print preview; reminder-email preview in the queue; final browser walk. **Out (do not plan):** a **visual layout** editor (drag/reposition/restyle — that is the Store product's Print Templates surface, doc 09 in the Store repo); credit-note & receipt preview; the other Store-product surfaces (Label Printing, Price-list bulk revise, Schemes editor, WhatsApp/message-catalog, POS printer-settings receipt preview) — documented handoffs; `template.*` rendering on `receiptHtml` (receipts aren't previewed this round — a small follow-up); the "Exact PDF" on-demand puppeteer button and the approximate page-break line (deferred UX polish); generalizing `LivePreview` beyond the surfaces here (YAGNI). Do not re-litigate these. ## File Structure ``` apps/hq/ src/repos-documents.ts — MODIFY: extract prepareDraft (pure); createDraft calls it; buildLines gains permissive warnings src/templates.ts — MODIFY: render template.* fields (default-fallback); @media screen paper styles; documentHtmlSample src/send-reminder.ts — MODIFY: extract reminderContext; sendReminder calls it (behavior unchanged) src/api.ts — MODIFY: companySettings() → company.* + template.*; POST /documents/preview, POST /previews/sample, GET/PUT /settings/template, POST /settings/template/logo, GET /reminders/:id/preview test/prepare-draft.test.ts — NEW (Task 1) test/preview-route.test.ts — NEW (Task 2) test/preview-fidelity.test.ts — NEW (Task 3) test/template-render.test.ts — NEW (Task 4) test/preview-sample.test.ts — NEW (Task 5) test/settings-template.test.ts — NEW (Task 6) test/reminder-preview.test.ts — NEW (Task 7) apps/hq-web/ src/api.ts — MODIFY: preview fetchers + template-settings/logo calls + types src/components/LivePreview.tsx — NEW (Task 8): debounced/immediate fetch + double-buffer srcdoc iframe src/pages/NewDocument.tsx — MODIFY (Task 9): split layout, buildDraftBody, server-truth totals strip, reducer deleted, responsive src/pages/DocumentTemplate.tsx — NEW (Task 10): owner page — company block + template.* + logo upload + live letterhead preview src/pages/CompanyProfile.tsx — REMOVE (Task 10): folded into DocumentTemplate; drop its route + nav item + import src/main.tsx, src/Layout.tsx — MODIFY (Task 10): /settings/template route + "Document Template" owner nav (replaces Company) src/pages/Modules.tsx — MODIFY (Task 11): live quote-content print preview src/pages/Dashboard.tsx — MODIFY (Task 12): reminder-email preview in the queue ``` --- ### Task 1: Extract `prepareDraft` from `createDraft` (pure, no persist) + permissive warnings **Files:** - Modify: `apps/hq/src/repos-documents.ts` - Test: `apps/hq/test/prepare-draft.test.ts` - Regression: `apps/hq/test/documents.test.ts` must stay green (createDraft behaviour unchanged). **Interfaces:** - Produces: - `PreparedDraft = { docType: DocType; clientId: string; docDate: string; totals: BillTotals; payload: DocPayload; warnings: string[] }` - `prepareDraft(db: DB, input: DraftInput, opts?: { permissive?: boolean }): PreparedDraft` — the compute block of the old `createDraft`, persisting nothing. Strict by default (missing price throws the unchanged message; unknown client throws `Client not found`). Permissive: a missing price computes at ₹0 with a `warnings[]` entry; a null/unknown client defaults `placeOfSupply` to our own state and adds a pending-client warning; zero lines returns zero totals + empty payload (never calls `computeBill`, which throws on an empty bill). - Modifies: - `createDraft` becomes `prepareDraft` + `insertDocRow` inside the existing `db.transaction`. Byte-for-byte the same persisted result. - `buildLines` gains an optional `warnings?: string[]` param — when passed (permissive), a missing price pushes a warning and resolves to `0`; when omitted (strict), it throws the original message verbatim. `createCreditNote` still calls `buildLines` without warnings → unchanged strict behaviour. - Consumes: existing `computeBill`, `buildLines`, `supplyStateCode`, `taxRates`, `getClient`, `getModule`, `todayIso`, `fyOf` — all already in the file. - [ ] **Step 1: Write the failing test** — `apps/hq/test/prepare-draft.test.ts` ```ts // apps/hq/test/prepare-draft.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 { prepareDraft, createDraft, type DraftInput } 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 kar = createClient(db, 'u1', { name: 'BLR Co', stateCode: '29' }) const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing', quoteContent: ['Cloud POS'] }) setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) const noPrice = createModule(db, 'u1', { code: 'ANALYTICS', name: 'Analytics' }) // no price row return { db, c, kar, m, noPrice } } describe('prepareDraft ↔ createDraft parity', () => { it('createDraft persists exactly what prepareDraft computes (totals + payload deep-equal)', () => { const { db, c, m } = setup() const input: DraftInput = { docType: 'INVOICE', clientId: c.id, terms: 'Net 15', lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }] } const prepared = prepareDraft(db, input) const saved = createDraft(db, 'u1', input) expect(saved.payload).toEqual(prepared.payload) expect({ taxable: saved.taxablePaise, cgst: saved.cgstPaise, sgst: saved.sgstPaise, igst: saved.igstPaise, roundOff: saved.roundOffPaise, payable: saved.payablePaise, }).toEqual({ taxable: prepared.totals.taxablePaise, cgst: prepared.totals.cgstPaise, sgst: prepared.totals.sgstPaise, igst: prepared.totals.igstPaise, roundOff: prepared.totals.roundOffPaise, payable: prepared.totals.payablePaise, }) expect(prepared.warnings).toEqual([]) }) it('resolves IGST for an out-of-state client, exactly as createDraft would', () => { const { db, kar, m } = setup() const input: DraftInput = { docType: 'INVOICE', clientId: kar.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] } expect(prepareDraft(db, input).totals.igstPaise).toBe(1_800_00) }) }) describe('prepareDraft strict vs permissive', () => { it('STRICT: a line with no price throws (save must refuse the same document)', () => { const { db, c, noPrice } = setup() expect(() => prepareDraft(db, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: noPrice.id, qty: 1, kind: 'yearly' }] })) .toThrow(/No price for module ANALYTICS/) }) it('PERMISSIVE: the same line computes at ₹0 and is reported in warnings', () => { const { db, c, noPrice } = setup() const out = prepareDraft(db, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: noPrice.id, qty: 1, kind: 'yearly' }] }, { permissive: true }) expect(out.totals.payablePaise).toBe(0) expect(out.warnings.some((w) => /ANALYTICS/.test(w))).toBe(true) }) it('PERMISSIVE: zero lines → empty payload, ₹0, no computeBill throw', () => { const { db, c } = setup() const out = prepareDraft(db, { docType: 'QUOTATION', clientId: c.id, lines: [] }, { permissive: true }) expect(out.payload.lines).toEqual([]) expect(out.totals.payablePaise).toBe(0) }) it('PERMISSIVE: unknown client does not throw; split defaults intra-state with a pending warning', () => { const { db, m } = setup() const out = prepareDraft(db, { docType: 'QUOTATION', clientId: '', lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }, { permissive: true }) expect(out.totals.cgstPaise).toBe(900_00) // intra-state default (our state 32) expect(out.totals.igstPaise).toBe(0) expect(out.warnings.some((w) => /client/i.test(w))).toBe(true) }) }) ``` - [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/prepare-draft.test.ts` → FAIL (`prepareDraft` not exported). - [ ] **Step 3: Implement.** In `apps/hq/src/repos-documents.ts`: Change `buildLines` to accept optional `warnings`: ```ts function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?: string[]): LineInput[] { return inputs.map((line) => { const mod = getModule(db, line.moduleId) if (mod === null) throw new Error(`Module not found: ${line.moduleId}`) const edition = line.edition ?? 'standard' let unitPricePaise = line.unitPricePaise ?? priceOn(db, line.moduleId, line.kind, edition, onDate) if (unitPricePaise === null) { // Strict (no warnings sink): refuse — same message save has always thrown. if (warnings === undefined) { throw new Error(`No price for module ${mod.code} (${line.kind}/${edition}) on ${onDate}`) } // Permissive preview: keep the staffer typing — ₹0 now, reported so the cell can flag. warnings.push(`No price for ${mod.code} (${line.kind}/${edition}) — enter Unit ₹`) unitPricePaise = 0 } const packSuffix = edition !== 'standard' ? ` — ${edition}` : '' // pack name prints on the line return { itemId: mod.id, name: mod.name + (line.description !== undefined && line.description !== '' ? ` — ${line.description}` : '') + packSuffix, hsn: mod.sac, qty: line.qty, unitCode: 'NOS', unitPricePaise, priceIncludesTax: false, taxClassCode: TAX_CLASS, } }) } ``` Add `prepareDraft` (and a `zeroTotals` helper) directly above `createDraft`, and rewrite `createDraft` to call it: ```ts /** Zero totals for the empty-composer preview (computeBill throws on an empty bill). */ function zeroTotals(): BillTotals { return { grossPaise: 0, discountPaise: 0, taxablePaise: 0, cgstPaise: 0, sgstPaise: 0, igstPaise: 0, cessPaise: 0, roundOffPaise: 0, payablePaise: 0, savingsVsMrpPaise: 0, } } export interface PreparedDraft { docType: DocType; clientId: string; docDate: string totals: BillTotals; payload: DocPayload; warnings: string[] } /** * The compute half of a draft — pure, persists nothing. `createDraft` calls this * then inserts; `POST /documents/preview` calls it with { permissive: true }. So * save and preview run the ONE tax path and cannot drift. Permissive forgives a * price gap (₹0 + warning) and a not-yet-picked client (intra-state default); * strict (the default, used by save) throws exactly as before. */ export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boolean } = {}): PreparedDraft { const permissive = opts.permissive === true if (!['QUOTATION', 'PROFORMA', 'INVOICE'].includes(input.docType)) { throw new Error(`Cannot draft doc type: ${input.docType}`) } const client = getClient(db, input.clientId) if (client === null && !permissive) throw new Error('Client not found') const today = todayIso() const lineInputs = Array.isArray(input.lines) ? input.lines : [] const warnings: string[] = [] if (client === null && permissive) warnings.push('No client selected — GST split shown as pending') // Empty composer under preview: real letterhead, empty table, ₹0 (design §3). if (permissive && lineInputs.length === 0) { const zero = zeroTotals() return { docType: input.docType, clientId: input.clientId, docDate: today, totals: zero, payload: { lines: [], totals: zero, lineContents: [] }, warnings, } } const supply = supplyStateCode(db) const computed = computeBill(buildLines(db, lineInputs, today, permissive ? warnings : undefined), { businessDate: today, supplyStateCode: supply, placeOfSupplyStateCode: client?.stateCode ?? supply, // no client yet ⇒ intra-state default roundToRupee: true, }, taxRates(db)) // Parallel to lines: caller override, else the module's quoteContent (buildLines proved each module exists). const lineContents = lineInputs.map((line) => line.contentLines ?? getModule(db, line.moduleId)!.quoteContent) const payload: DocPayload = { lines: computed.lines, totals: computed.totals, lineContents, ...(input.terms !== undefined ? { terms: input.terms } : {}), } return { docType: input.docType, clientId: input.clientId, docDate: today, totals: computed.totals, payload, warnings, } } export function createDraft(db: DB, userId: string, input: DraftInput): Doc { const prepared = prepareDraft(db, input) // strict — save refuses a bad document return db.transaction(() => insertDocRow(db, userId, { docType: prepared.docType, clientId: prepared.clientId, refDocId: null, docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload, }))() } ``` (`BillTotals` and `fyOf` are already imported at the top of the file; no import change is needed for this task.) - [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/prepare-draft.test.ts apps/hq/test/documents.test.ts` → PASS (parity green **and** the existing document suite unchanged). `npm run typecheck` → clean. - [ ] **Step 5: Commit** ```bash git add apps/hq/src/repos-documents.ts apps/hq/test/prepare-draft.test.ts git commit -m "feat(hq): extract pure prepareDraft from createDraft (permissive preview path)" -m "Co-Authored-By: Claude Fable 5 " ``` --- ### Task 2: `POST /api/documents/preview` — permissive, persists nothing **Files:** - Modify: `apps/hq/src/api.ts` - Test: `apps/hq/test/preview-route.test.ts` **Interfaces:** - Route: `POST /api/documents/preview` (`requireAuth`). Body = the **exact** `DraftInput` shape `POST /documents` takes. Builds a synthetic unsaved `Doc` (`id: 'preview'`, `docNo: null` → template prints "DRAFT", `docDate: today`, `fy` via `fyOf`), runs `prepareDraft(..., { permissive: true })` + `documentHtml`, and returns `{ ok: true, html: string, totals: BillTotals, warnings: string[] }`. When no client is picked (`clientId` empty/unknown) it renders a placeholder "— pick a client —" letterhead at our own state (GST total still authoritative). Persists nothing; writes no audit; no puppeteer. - Imports added to `api.ts`: `fyOf` (from `@sims/domain`, join the existing `validateGstin` import), `prepareDraft` + `type Doc` (from `./repos-documents`), `type Client` (from `./repos-clients`). - [ ] **Step 1: Write the failing test** — `apps/hq/test/preview-route.test.ts` ```ts // apps/hq/test/preview-route.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 { createClient } from '../src/repos-clients' import { createModule, setPrice } from '../src/repos-modules' import { apiRouter } from '../src/api' function appWith() { const db = openDb(':memory:'); seedIfEmpty(db) // company.state_code=32, GST18 createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' }) const kar = createClient(db, 'u1', { name: 'BLR Co', code: 'BLR', stateCode: '29' }) 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' }) const noPrice = createModule(db, 'u1', { code: 'ANALYTICS', name: 'Analytics' }) 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` return { db, server, base, c, kar, m, noPrice } } describe('POST /documents/preview', () => { const ctx = appWith() afterAll(() => ctx.server.close()) const login = async () => (await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token const preview = async (token: string | null, body: unknown) => { const res = await fetch(`${ctx.base}/documents/preview`, { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body), }) return { status: res.status, json: await res.json() as any } } const save = async (token: string, body: unknown) => { const res = await fetch(`${ctx.base}/documents`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(body), }) return { status: res.status, json: await res.json() as any } } it('requires auth', async () => { expect((await preview(null, { docType: 'QUOTATION', clientId: ctx.c.id, lines: [] })).status).toBe(401) }) it('returns real letterhead HTML + server totals for a priced line', async () => { const token = await login() const out = await preview(token, { docType: 'INVOICE', clientId: ctx.c.id, lines: [{ moduleId: ctx.m.id, qty: 1, kind: 'yearly' }] }) expect(out.status).toBe(200) expect(out.json.ok).toBe(true) expect(out.json.html).toContain('TAX INVOICE') expect(out.json.html).toContain('DRAFT') // unsaved → docNo prints DRAFT expect(out.json.totals.payablePaise).toBe(11_800_00) expect(out.json.totals.cgstPaise).toBe(900_00) expect(out.json.warnings).toEqual([]) // Persisted nothing: expect((ctx.db.prepare(`SELECT COUNT(*) AS n FROM document`).get() as any).n).toBe(0) expect((ctx.db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='document'`).get() as any).n).toBe(0) }) it('shows the IGST split live for an out-of-state client', async () => { const token = await login() const out = await preview(token, { docType: 'INVOICE', clientId: ctx.kar.id, lines: [{ moduleId: ctx.m.id, qty: 1, kind: 'yearly' }] }) expect(out.json.totals.igstPaise).toBe(1_800_00) expect(out.json.totals.cgstPaise).toBe(0) }) it('PERMISSIVE preview vs STRICT save diverge on a price gap', async () => { const token = await login() const body = { docType: 'QUOTATION', clientId: ctx.c.id, lines: [{ moduleId: ctx.noPrice.id, qty: 1, kind: 'yearly' }] } const pv = await preview(token, body) expect(pv.status).toBe(200) expect(pv.json.totals.payablePaise).toBe(0) // forgiven at ₹0 expect(pv.json.warnings.some((w: string) => /ANALYTICS/.test(w))).toBe(true) const sv = await save(token, body) expect(sv.status).toBe(400) // save refuses expect(sv.json.error).toMatch(/No price for module ANALYTICS/) }) it('previews with no client picked — placeholder letterhead, GST total still authoritative', async () => { const token = await login() const out = await preview(token, { docType: 'QUOTATION', clientId: '', lines: [{ moduleId: ctx.m.id, qty: 1, kind: 'yearly' }] }) expect(out.status).toBe(200) expect(out.json.html).toContain('— pick a client —') expect(out.json.totals.payablePaise).toBe(11_800_00) // 18% invariant expect(out.json.warnings.some((w: string) => /client/i.test(w))).toBe(true) }) it('empty composer previews an empty table at ₹0', async () => { const token = await login() const out = await preview(token, { docType: 'QUOTATION', clientId: ctx.c.id, lines: [] }) expect(out.status).toBe(200) expect(out.json.totals.payablePaise).toBe(0) }) }) ``` - [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/preview-route.test.ts` → FAIL (route 404 / not found). - [ ] **Step 3: Implement.** In `apps/hq/src/api.ts`: Extend the domain import and the documents/clients imports: ```ts import { fyOf, validateGstin } from '@sims/domain' ``` ```ts import { cancelDocument, convertDocument, createCreditNote, createDraft, getDocument, issueDocument, listDocumentEvents, listDocuments, markStatus, prepareDraft, type Doc, type DocumentFilter, type DraftInput, type DraftLineInput, } from './repos-documents' ``` ```ts import { createClient, getClient, listClients, updateClient, type Client, type ClientInput, type ClientPatch, } from './repos-clients' ``` Add the route in the `// ---------- documents ----------` block, immediately after `POST /documents` (so it sits beside the route whose body it mirrors): ```ts // Live preview: the SAME prepareDraft + documentHtml the PDF uses — persists // nothing, writes no audit (it is a read). Permissive: a price gap computes at // ₹0 + a warning; no client picked renders a placeholder letterhead at our own // state. renderPdf(html) rasterizes this exact string for the actual PDF. r.post('/documents/preview', requireAuth, (req, res) => { try { const input = req.body as DraftInput const prepared = prepareDraft(db, input, { permissive: true }) const company = companySettings() const picked = typeof input.clientId === 'string' && input.clientId !== '' ? getClient(db, input.clientId) : null const client: Client = picked ?? { id: '', code: '—', name: '— pick a client —', stateCode: company['company.state_code'] ?? '32', address: '', contacts: [], status: 'lead', notes: '', } const t = prepared.totals const doc: Doc = { id: 'preview', docType: input.docType, docNo: null, fy: fyOf(prepared.docDate), clientId: client.id, docDate: prepared.docDate, status: 'draft', refDocId: null, taxablePaise: t.taxablePaise, cgstPaise: t.cgstPaise, sgstPaise: t.sgstPaise, igstPaise: t.igstPaise, roundOffPaise: t.roundOffPaise, payablePaise: t.payablePaise, payload: prepared.payload, source: 'preview', createdBy: staffId(res), createdAt: '', } res.json({ ok: true, html: documentHtml(doc, client, company), totals: prepared.totals, warnings: prepared.warnings }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) ``` - [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/preview-route.test.ts` → PASS. `npm run typecheck` → clean. - [ ] **Step 5: Commit** ```bash git add apps/hq/src/api.ts apps/hq/test/preview-route.test.ts git commit -m "feat(hq): POST /documents/preview — permissive live preview, persists nothing" -m "Co-Authored-By: Claude Fable 5 " ``` --- ### Task 3: Fidelity golden test — preview HTML is byte-identical to the PDF's HTML **Files:** - Test only: `apps/hq/test/preview-fidelity.test.ts` (no implementation — this guards the architecture Tasks 1–2 established; anyone who ever forks the template breaks it). **Interfaces:** none produced. The test constructs a fixture draft with `createDraft` (the persisted, un-issued draft the `/documents/:id/pdf` route would render), computes `expected = documentHtml(fixtureDoc, client, company)` — **the exact string** `renderPdf` receives in `api.ts` (`renderPdf(documentHtml(document, client, company))`) — and asserts the preview route's `html` equals it. No puppeteer; strings only. - [ ] **Step 1: Write the failing test** — `apps/hq/test/preview-fidelity.test.ts` ```ts // apps/hq/test/preview-fidelity.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 { createClient, getClient } from '../src/repos-clients' import { createModule, setPrice } from '../src/repos-modules' import { createDraft } from '../src/repos-documents' import { documentHtml } from '../src/templates' import { apiRouter } from '../src/api' function companyMap(db: any): Record { const rows = db.prepare(`SELECT key, value FROM setting WHERE key LIKE 'company.%'`).all() as { key: string; value: string }[] return Object.fromEntries(rows.map((r) => [r.key, r.value])) } describe('preview fidelity (string identity with the PDF path)', () => { const db = openDb(':memory:'); seedIfEmpty(db) createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', address: 'Kochi', gstin: '32ABCDE1234F1Z5' }) const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing', quoteContent: ['Cloud POS', 'GST filing'] }) setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) const app = express(); app.use(express.json()); app.use('/api', apiRouter(db)) const server = app.listen(0) const base = `http://localhost:${(server.address() as { port: number }).port}/api` afterAll(() => server.close()) it('preview.html === documentHtml the /pdf route would feed renderPdf', async () => { const input = { docType: 'INVOICE', clientId: c.id, terms: 'Net 15', lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }] } // The fixture draft: exactly what /documents/:id/pdf renders (un-issued draft, docNo=null). const fixture = createDraft(db, 'u1', input) const expected = documentHtml(fixture, getClient(db, c.id)!, companyMap(db)) const token = (await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token const res = await fetch(`${base}/documents/preview`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(input) }) const out = await res.json() as any expect(out.html).toBe(expected) // one renderer — no drift possible }) }) ``` - [ ] **Step 2: Run** — `npx vitest run apps/hq/test/preview-fidelity.test.ts`. It should PASS immediately on top of Tasks 1–2 (the whole point: fidelity is already structural). **If it fails**, the divergence is a real bug — diff `out.html` vs `expected` and fix the preview route's synthetic `Doc` (usually a `fy`/`docDate`/totals field) until identical; do **not** weaken the assertion. `npm run typecheck` → clean. - [ ] **Step 3: Commit** ```bash git add apps/hq/test/preview-fidelity.test.ts git commit -m "test(hq): golden fidelity — preview HTML byte-identical to the PDF's HTML" -m "Co-Authored-By: Claude Fable 5 " ``` --- ### Task 4: `templates.ts` — render the `template.*` fields with default-fallback **Files:** - Modify: `apps/hq/src/templates.ts` - Modify: `apps/hq/src/api.ts` (broaden `companySettings()` to carry `template.*`) - Test: `apps/hq/test/template-render.test.ts` - Regression: `apps/hq/test/templates.test.ts`, `preview-route.test.ts`, `preview-fidelity.test.ts` stay green (they assert `company.*`/structural substrings, not `template.*`). **Interfaces:** - `documentHtml` renders the new optional `template.*` keys from its settings map, **each defaulting to today's output when the key is absent** (spec §4 — nothing breaks pre-config). A local `tpl(key) = company['template.' + key] ?? ''` helper mirrors the existing `get`: - **Title:** `template.title_` overrides `TITLE[doc.docType]` (default = the built-in map). - **Logo:** `template.logo` (a `data:` URI) renders as `` above the company name (default = none — just the `

`). - **Terms:** the Terms box now shows `doc.payload.terms ?? tpl('terms')` (a per-doc terms still wins; `template.terms` is the editable default; default = today's behaviour of showing nothing when both are empty). - **Declaration / Jurisdiction / Footer note:** new blocks rendered only when the key is non-empty (default = absent, exactly today). - **Signatory label:** replaces the hardcoded `Authorised Signatory` with `tpl('signatory_label')` when set (default = `Authorised Signatory`). - This round touches **`documentHtml` only**; `receiptHtml` keeps today's letterhead (receipts aren't previewed — a documented small follow-up). - `api.ts`: `companySettings()` broadens its query to `WHERE key LIKE 'company.%' OR key LIKE 'template.%'` so the one settings map documentHtml reads (in the pdf/send/preview/sample routes) now carries `template.*`. The name stays `companySettings` to avoid touching call sites; a comment notes it is the full letterhead settings map. - [ ] **Step 1: Write the failing test** — `apps/hq/test/template-render.test.ts` ```ts // apps/hq/test/template-render.test.ts import { describe, it, expect } from 'vitest' import { documentHtml } from '../src/templates' const invoice = { id: 'd1', docType: 'INVOICE', docNo: 'INV/26-27-0001', fy: '2026-27', clientId: 'c1', docDate: '2026-07-13', 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', 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: 'CL1', name: 'Acme', stateCode: '32', address: 'Kochi', contacts: [], status: 'active', notes: '' } as never describe('template.* rendering (default-fallback)', () => { it('renders built-in defaults when template.* is unset (nothing breaks pre-config)', () => { const html = documentHtml(invoice, client, { 'company.name': 'Tecnostac' }) expect(html).toContain('TAX INVOICE') // built-in title expect(html).toContain('Authorised Signatory') // built-in signatory label expect(html).not.toContain('class="declaration"') expect(html).not.toContain('class="jurisdiction"') expect(html).not.toContain('class="footer-note"') expect(html).not.toContain('class="logo"') }) it('applies each template.* override where set', () => { const html = documentHtml(invoice, client, { 'company.name': 'Tecnostac', 'template.title_INVOICE': 'GST INVOICE', 'template.declaration': 'We declare the particulars are true.', 'template.jurisdiction': 'Subject to Kochi jurisdiction', 'template.footer_note': 'Thank you for your business.', 'template.signatory_label': 'Proprietor', 'template.logo': 'data:image/png;base64,AAAABBBB', }) expect(html).toContain('GST INVOICE') expect(html).not.toContain('TAX INVOICE') expect(html).toContain('We declare the particulars are true.') expect(html).toContain('Subject to Kochi jurisdiction') expect(html).toContain('Thank you for your business.') expect(html).toContain('Proprietor') expect(html).not.toContain('Authorised Signatory') expect(html).toContain('data:image/png;base64,AAAABBBB') }) it('template.terms is the editable default; a per-doc terms overrides it', () => { const def = documentHtml(invoice, client, { 'company.name': 'X', 'template.terms': 'Net 30 default' }) expect(def).toContain('Net 30 default') const perDoc = { ...(invoice as object), payload: { ...(invoice as never as { payload: object }).payload, terms: 'Per-doc terms' } } as never const html = documentHtml(perDoc, client, { 'company.name': 'X', 'template.terms': 'Net 30 default' }) expect(html).toContain('Per-doc terms') expect(html).not.toContain('Net 30 default') }) }) ``` - [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/template-render.test.ts` → FAIL. - [ ] **Step 3a: Implement `documentHtml` in `templates.ts`.** Add the `tpl` helper beside `get`, and resolve the title/logo/terms: ```ts const get = (key: string): string => company[`company.${key}`] ?? '' const tpl = (key: string): string => company[`template.${key}`] ?? '' // editable letterhead text const title = tpl(`title_${doc.docType}`) !== '' ? tpl(`title_${doc.docType}`) : TITLE[doc.docType] const logo = tpl('logo') const termsText = doc.payload.terms ?? tpl('terms') ``` In the `