feat(hq): letterhead HTML templates and puppeteer PDF (HQ-1 task 10)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 63b0d9932a
commit 8c90b09595

@ -18,6 +18,8 @@ import {
import { import {
clientLedger, modulePaidView, recordPayment, type RecordPaymentInput, clientLedger, modulePaidView, recordPayment, type RecordPaymentInput,
} from './repos-payments' } from './repos-payments'
import { documentHtml } from './templates'
import { renderPdf } from './pdf'
const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id
@ -170,6 +172,30 @@ export function apiRouter(db: DB): Router {
} }
} }
r.get('/documents/:id/pdf', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '')
const document = getDocument(db, id)
if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
const client = getClient(db, document.clientId)
if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
const rows = db.prepare(
`SELECT key, value FROM setting WHERE key LIKE 'company.%'`,
).all() as { key: string; value: string }[]
const company = Object.fromEntries(rows.map((r2) => [r2.key, r2.value]))
void (async () => {
try {
const pdf = await renderPdf(documentHtml(document, client, company))
res.setHeader('Content-Type', 'application/pdf')
// docNo contains '/' (QT/26-27-0001) — not legal in a filename.
const filename = (document.docNo ?? 'draft').replaceAll('/', '-')
res.setHeader('Content-Disposition', `inline; filename="${filename}.pdf"`)
res.send(pdf)
} catch (err) {
res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})()
})
r.post('/documents/:id/issue', requireAuth, docAction((id, res) => r.post('/documents/:id/issue', requireAuth, docAction((id, res) =>
issueDocument(db, staffId(res), id))) issueDocument(db, staffId(res), id)))
r.post('/documents/:id/status', requireAuth, docAction((id, res, req) => { r.post('/documents/:id/status', requireAuth, docAction((id, res, req) => {

@ -0,0 +1,33 @@
import puppeteer, { type Browser } from 'puppeteer'
/**
* HTML PDF via puppeteer. The browser is a lazy module-level singleton:
* launch costs ~1s, so it starts on first render and is reused after.
* Guarding with a promise (not the Browser) makes concurrent first calls
* share one launch instead of racing two Chromes.
*/
let browserPromise: Promise<Browser> | null = null
function getBrowser(): Promise<Browser> {
browserPromise ??= puppeteer.launch()
return browserPromise
}
export async function renderPdf(html: string): Promise<Buffer> {
const page = await (await getBrowser()).newPage()
try {
await page.setContent(html, { waitUntil: 'load' })
const bytes = await page.pdf({ format: 'A4', printBackground: true })
return Buffer.from(bytes)
} finally {
await page.close()
}
}
/** For tests/scripts that must let the process exit; the server never calls it. */
export async function closePdfBrowser(): Promise<void> {
if (browserPromise === null) return
const b = await browserPromise
browserPromise = null
await b.close()
}

@ -0,0 +1,157 @@
import { amountInWordsINR, formatINR, type BillLine, type Paise } from '@sims/domain'
import type { Client } from './repos-clients'
import type { Doc, DocType } from './repos-documents'
/**
* Letterhead HTML per doc type self-contained (inline CSS, A4) so puppeteer
* renders it with zero external fetches. All amounts via formatINR; the HSN
* column is labelled "SAC" (our lines are services).
*/
const TITLE: Record<DocType, string> = {
QUOTATION: 'QUOTATION',
PROFORMA: 'PROFORMA INVOICE',
INVOICE: 'TAX INVOICE',
RECEIPT: 'RECEIPT',
CREDIT_NOTE: 'CREDIT NOTE',
}
/** Amount in words, Indian system (crore/lakh/thousand) — domain's GST-invoice helper. */
export function rupeesInWords(paise: Paise): string {
return amountInWordsINR(paise)
}
function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
/** 2026-07-10 → 10/07/2026 (display only; storage stays ISO). */
function displayDate(iso: string): string {
const [y, m, d] = iso.split('-')
return `${d ?? ''}/${m ?? ''}/${y ?? ''}`
}
function lineRow(l: BillLine, i: number): string {
return `<tr>
<td class="c">${i + 1}</td>
<td>${esc(l.name)}</td>
<td class="c">${esc(l.hsn)}</td>
<td class="r">${l.qty}</td>
<td class="r">${formatINR(l.unitPricePaise)}</td>
<td class="r">${formatINR(l.taxablePaise)}</td>
<td class="r">${l.taxRateBp / 100}%</td>
<td class="r">${formatINR(l.lineTotalPaise)}</td>
</tr>`
}
function totalsRows(doc: Doc): string {
const rows: [string, Paise][] = [['Taxable Value', doc.taxablePaise]]
if (doc.igstPaise > 0) {
rows.push(['IGST', doc.igstPaise])
} else {
rows.push(['CGST', doc.cgstPaise], ['SGST', doc.sgstPaise])
}
if (doc.roundOffPaise !== 0) rows.push(['Round-off', doc.roundOffPaise])
return rows.map(([label, paise]) =>
`<tr><td>${label}</td><td class="r">${formatINR(paise)}</td></tr>`).join('\n')
}
export function documentHtml(doc: Doc, client: Client, company: Record<string, string>): string {
const get = (key: string): string => company[`company.${key}`] ?? ''
// Bank details belong on documents payment is made against — tax and proforma invoices.
const showBank = (doc.docType === 'INVOICE' || doc.docType === 'PROFORMA') && get('bank') !== ''
const companyMeta = [
get('address'),
get('gstin') !== '' ? `GSTIN: ${get('gstin')}` : '',
[get('phone'), get('email')].filter((s) => s !== '').join(' · '),
].filter((s) => s !== '')
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>${esc(doc.docNo ?? 'Draft')}</title>
<style>
@page { size: A4; margin: 14mm; }
* { box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 11px; color: #1a1a1a; margin: 0; }
.letterhead { border-bottom: 2px solid #1a1a1a; padding-bottom: 8px; }
.letterhead h1 { font-size: 20px; margin: 0 0 2px; letter-spacing: 0.5px; }
.letterhead p { margin: 1px 0; color: #444; }
.doc-title { text-align: center; font-size: 13px; font-weight: 700; letter-spacing: 2px; margin: 12px 0 8px; }
.meta { display: flex; justify-content: space-between; margin-bottom: 10px; }
.meta .box { width: 48%; }
.meta h2 { font-size: 10px; text-transform: uppercase; color: #666; margin: 0 0 3px; }
.meta p { margin: 1px 0; }
table.lines { width: 100%; border-collapse: collapse; margin-bottom: 8px; }
table.lines th, table.lines td { border: 1px solid #999; padding: 4px 6px; }
table.lines th { background: #f0f0f0; font-size: 10px; text-transform: uppercase; }
td.r, th.r { text-align: right; } td.c, th.c { text-align: center; }
.bottom { display: flex; justify-content: space-between; align-items: flex-start; }
.words { width: 55%; }
table.totals { width: 40%; border-collapse: collapse; }
table.totals td { padding: 3px 6px; border-bottom: 1px solid #ddd; }
table.totals tr.grand td { border-top: 2px solid #1a1a1a; border-bottom: none; font-weight: 700; font-size: 13px; }
.bank, .terms { margin-top: 12px; border: 1px solid #ccc; padding: 6px 8px; }
.bank h3, .terms h3 { font-size: 10px; text-transform: uppercase; margin: 0 0 3px; color: #666; }
.sign { margin-top: 28px; text-align: right; }
</style>
</head>
<body>
<header class="letterhead">
<h1>${esc(get('name'))}</h1>
${companyMeta.map((line) => `<p>${esc(line)}</p>`).join('\n ')}
</header>
<div class="doc-title">${TITLE[doc.docType]}</div>
<div class="meta">
<div class="box">
<h2>${doc.docType === 'QUOTATION' ? 'To' : 'Bill To'}</h2>
<p><strong>${esc(client.name)}</strong> (${esc(client.code)})</p>
${client.address !== '' ? `<p>${esc(client.address)}</p>` : ''}
${client.gstin !== undefined ? `<p>GSTIN: ${esc(client.gstin)}</p>` : ''}
<p>State Code: ${esc(client.stateCode)}</p>
</div>
<div class="box">
<h2>Document</h2>
<p>No: <strong>${esc(doc.docNo ?? 'DRAFT')}</strong></p>
<p>Date: ${displayDate(doc.docDate)}</p>
<p>FY: ${esc(doc.fy)}</p>
</div>
</div>
<table class="lines">
<thead>
<tr>
<th class="c">#</th><th>Description</th><th class="c">SAC</th><th class="r">Qty</th>
<th class="r">Rate</th><th class="r">Taxable</th><th class="r">GST</th><th class="r">Amount</th>
</tr>
</thead>
<tbody>
${doc.payload.lines.map(lineRow).join('\n')}
</tbody>
</table>
<div class="bottom">
<div class="words">
<strong>Amount in words:</strong><br>${esc(rupeesInWords(doc.payablePaise))}
</div>
<table class="totals">
${totalsRows(doc)}
<tr class="grand"><td>Total</td><td class="r">${formatINR(doc.payablePaise)}</td></tr>
</table>
</div>
${showBank ? `<div class="bank"><h3>Bank Details</h3><p>${esc(get('bank'))}</p></div>` : ''}
${doc.payload.terms !== undefined && doc.payload.terms !== ''
? `<div class="terms"><h3>Terms</h3><p>${esc(doc.payload.terms)}</p></div>` : ''}
<div class="sign">
<p>For <strong>${esc(get('name'))}</strong></p>
<p style="margin-top: 36px;">Authorised Signatory</p>
</div>
</body>
</html>`
}

@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { documentHtml, rupeesInWords } 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')
})
})
describe('rupees in words', () => {
it('spells the payable in the Indian system', () => {
expect(rupeesInWords(11_800_00)).toBe('Rupees Eleven Thousand Eight Hundred Only')
expect(rupeesInWords(1_23_45_678_00)).toBe(
'Rupees One Crore Twenty Three Lakh Forty Five Thousand Six Hundred Seventy Eight Only')
expect(rupeesInWords(50)).toBe('Fifty Paise Only')
})
})
Loading…
Cancel
Save