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
parent
63b0d9932a
commit
8c90b09595
@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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…
Reference in New Issue