From 7afd769a14c52dd4aedac57ab51232b884381289 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 13 Jul 2026 14:44:27 +0530 Subject: [PATCH] docs: live preview plan (13 tasks) + editable template-data scope in spec Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-13-live-preview.md | 2157 +++++++++++++++++ .../specs/2026-07-13-live-preview-design.md | 61 +- 2 files changed, 2205 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-13-live-preview.md diff --git a/docs/superpowers/plans/2026-07-13-live-preview.md b/docs/superpowers/plans/2026-07-13-live-preview.md new file mode 100644 index 0000000..ef2c4af --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-live-preview.md @@ -0,0 +1,2157 @@ +# 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, 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); 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 `