diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index f224bb0..ccfdcd2 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -53,8 +53,9 @@ const staffId = (res: Response): string => (res.locals['staff'] as { id: string export function apiRouter(db: DB, gmailDeps?: Partial): Router { const r = Router() const companySettings = (): Record => { + // The full letterhead settings map documentHtml reads — company.* identity + template.* text. const rows = db.prepare( - `SELECT key, value FROM setting WHERE key LIKE 'company.%'`, + `SELECT key, value FROM setting WHERE key LIKE 'company.%' OR key LIKE 'template.%'`, ).all() as { key: string; value: string }[] return Object.fromEntries(rows.map((row) => [row.key, row.value])) } diff --git a/apps/hq/src/templates.ts b/apps/hq/src/templates.ts index 0c1081f..0f58c76 100644 --- a/apps/hq/src/templates.ts +++ b/apps/hq/src/templates.ts @@ -63,6 +63,10 @@ function totalsRows(doc: Doc): string { export function documentHtml(doc: Doc, client: Client, company: Record): string { if (doc.docType === 'RECEIPT') return receiptHtml(doc, client, company) 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') // 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 = [ @@ -100,16 +104,21 @@ export function documentHtml(doc: Doc, client: Client, company: Record
+ ${logo !== '' ? `` : ''}

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

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

${esc(line)}

`).join('\n ')}
-
${TITLE[doc.docType]}
+
${esc(title)}
@@ -151,13 +160,17 @@ export function documentHtml(doc: Doc, client: Client, company: Record

Bank Details

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

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

Terms

${esc(doc.payload.terms)}

` : ''} + ${termsText !== '' ? `

Terms

${esc(termsText)}

` : ''} + + ${tpl('declaration') !== '' ? `
${esc(tpl('declaration'))}
` : ''} + ${tpl('jurisdiction') !== '' ? `

${esc(tpl('jurisdiction'))}

` : ''}

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

-

Authorised Signatory

+

${esc(tpl('signatory_label') !== '' ? tpl('signatory_label') : 'Authorised Signatory')}

+ + ${tpl('footer_note') !== '' ? `` : ''} ` } diff --git a/apps/hq/test/template-render.test.ts b/apps/hq/test/template-render.test.ts new file mode 100644 index 0000000..e237139 --- /dev/null +++ b/apps/hq/test/template-render.test.ts @@ -0,0 +1,52 @@ +// 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') + }) +})