From 268a7469d356cfe6effc8a287898cdff9fcac0dd Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 13 Jul 2026 14:53:14 +0530 Subject: [PATCH] =?UTF-8?q?feat(hq):=20POST=20/documents/preview=20?= =?UTF-8?q?=E2=80=94=20permissive=20live=20preview,=20persists=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 37 ++++++++++-- apps/hq/test/preview-route.test.ts | 97 ++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 apps/hq/test/preview-route.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 4fea6d4..f224bb0 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -1,10 +1,10 @@ import { Router, type Request, type RequestHandler, type Response } from 'express' -import { validateGstin } from '@sims/domain' +import { fyOf, validateGstin } from '@sims/domain' import { login, requireAuth, requireOwner } from './auth' import type { DB } from './db' import { createClient, getClient, listClients, updateClient, - type ClientInput, type ClientPatch, + type Client, type ClientInput, type ClientPatch, } from './repos-clients' import { assignModule, createModule, getClientModule, getModule, listClientModules, @@ -14,8 +14,8 @@ import { } from './repos-modules' import { cancelDocument, convertDocument, createCreditNote, createDraft, getDocument, - issueDocument, listDocumentEvents, listDocuments, markStatus, - type DocumentFilter, type DraftInput, type DraftLineInput, + issueDocument, listDocumentEvents, listDocuments, markStatus, prepareDraft, + type Doc, type DocumentFilter, type DraftInput, type DraftLineInput, } from './repos-documents' import { clientLedger, modulePaidView, recordPayment, receiptForPayment, type RecordPaymentInput, @@ -194,6 +194,35 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) + // 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) }) + } + }) r.get('/documents', requireAuth, (req, res) => { const filter: DocumentFilter = {} if (typeof req.query['type'] === 'string') filter.type = req.query['type'] diff --git a/apps/hq/test/preview-route.test.ts b/apps/hq/test/preview-route.test.ts new file mode 100644 index 0000000..9b65143 --- /dev/null +++ b/apps/hq/test/preview-route.test.ts @@ -0,0 +1,97 @@ +// 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) + }) +})