diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index c0f5e0c..ac4e6fd 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -18,6 +18,8 @@ import { import { clientLedger, modulePaidView, recordPayment, type RecordPaymentInput, } from './repos-payments' +import { documentHtml } from './templates' +import { renderPdf } from './pdf' 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) => issueDocument(db, staffId(res), id))) r.post('/documents/:id/status', requireAuth, docAction((id, res, req) => { diff --git a/apps/hq/src/pdf.ts b/apps/hq/src/pdf.ts new file mode 100644 index 0000000..0692227 --- /dev/null +++ b/apps/hq/src/pdf.ts @@ -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 | null = null + +function getBrowser(): Promise { + browserPromise ??= puppeteer.launch() + return browserPromise +} + +export async function renderPdf(html: string): Promise { + 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 { + if (browserPromise === null) return + const b = await browserPromise + browserPromise = null + await b.close() +} diff --git a/apps/hq/src/templates.ts b/apps/hq/src/templates.ts new file mode 100644 index 0000000..2c54d4a --- /dev/null +++ b/apps/hq/src/templates.ts @@ -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 = { + 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, ''') +} + +/** 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 ` + ${i + 1} + ${esc(l.name)} + ${esc(l.hsn)} + ${l.qty} + ${formatINR(l.unitPricePaise)} + ${formatINR(l.taxablePaise)} + ${l.taxRateBp / 100}% + ${formatINR(l.lineTotalPaise)} + ` +} + +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]) => + `${label}${formatINR(paise)}`).join('\n') +} + +export function documentHtml(doc: Doc, client: Client, company: Record): 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 ` + + + +${esc(doc.docNo ?? 'Draft')} + + + +
+

${esc(get('name'))}

+ ${companyMeta.map((line) => `

${esc(line)}

`).join('\n ')} +
+ +
${TITLE[doc.docType]}
+ +
+
+

${doc.docType === 'QUOTATION' ? 'To' : 'Bill To'}

+

${esc(client.name)} (${esc(client.code)})

+ ${client.address !== '' ? `

${esc(client.address)}

` : ''} + ${client.gstin !== undefined ? `

GSTIN: ${esc(client.gstin)}

` : ''} +

State Code: ${esc(client.stateCode)}

+
+
+

Document

+

No: ${esc(doc.docNo ?? 'DRAFT')}

+

Date: ${displayDate(doc.docDate)}

+

FY: ${esc(doc.fy)}

+
+
+ + + + + + + + + + ${doc.payload.lines.map(lineRow).join('\n')} + +
#DescriptionSACQtyRateTaxableGSTAmount
+ +
+
+ Amount in words:
${esc(rupeesInWords(doc.payablePaise))} +
+ + ${totalsRows(doc)} + +
Total${formatINR(doc.payablePaise)}
+
+ + ${showBank ? `

Bank Details

${esc(get('bank'))}

` : ''} + + ${doc.payload.terms !== undefined && doc.payload.terms !== '' + ? `

Terms

${esc(doc.payload.terms)}

` : ''} + +
+

For ${esc(get('name'))}

+

Authorised Signatory

+
+ +` +} diff --git a/apps/hq/test/templates.test.ts b/apps/hq/test/templates.test.ts new file mode 100644 index 0000000..c53bd79 --- /dev/null +++ b/apps/hq/test/templates.test.ts @@ -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') + }) +})