import { amountInWordsINR, formatINR, type BillLine, type BillTotals, 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, contents: string[]): string { const bullets = contents.length > 0 ? `` : '' return ` ${i + 1} ${esc(l.name)}${bullets} ${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 { 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') !== '' // Accent = the single chromatic voice. Validate-or-fallback BEFORE interpolation so a // raw setting string can never be injected into the CSS (`#334155` is professional slate-indigo). const accentRaw = tpl('accent') const accent = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(accentRaw) ? accentRaw : '#334155' const isDraft = (doc.docNo ?? '') === '' const phoneEmail = [get('phone'), get('email')].filter((s) => s !== '').join(' · ') return ` ${esc(doc.docNo ?? 'Draft')} ${isDraft ? `
DRAFT
` : ''}
${esc(title)}
${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${doc.docNo !== null ? esc(doc.docNo) : 'DRAFT'}
Date${displayDate(doc.docDate)}
${doc.docType === 'INVOICE' && typeof doc.dueDate === 'string' ? `
Due Date${displayDate(doc.dueDate)}
` : ''}
FY${esc(doc.fy)}
${doc.payload.lines.map((l, i) => lineRow(l, i, doc.payload.lineContents?.[i] ?? [])).join('\n')}
#DescriptionSACQty RateTaxableGSTAmount
Amount in Words
${esc(rupeesInWords(doc.payablePaise))}
${totalsRows(doc)}
Grand Total${formatINR(doc.payablePaise)}
${(showBank || termsText !== '') ? `
${showBank ? `
Bank Details

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

` : ''} ${termsText !== '' ? `
Terms & Conditions

${esc(termsText)}

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

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

` : ''}

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

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

${tpl('footer_note') !== '' ? `` : ''} ` } /** Payment acknowledgment — no SAC/GST table; states the sum received in words. */ function receiptHtml(doc: Doc, client: Client, company: Record): string { const get = (key: string): string => company[`company.${key}`] ?? '' const rc = doc.payload.receipt const amountPaise = rc?.amountPaise ?? doc.payablePaise const companyMeta = [ get('address'), get('gstin') !== '' ? `GSTIN: ${get('gstin')}` : '', [get('phone'), get('email')].filter((s) => s !== '').join(' · '), ].filter((s) => s !== '') const allocRows = (rc?.allocations ?? []).filter((a) => a.docNo !== '' && a.docNo !== '—') const against = allocRows.length > 0 ? `` + allocRows.map((a) => ``).join('') + `
Towards invoiceAmount
${esc(a.docNo)}${formatINR(a.amountPaise)}
` : `

Received on account (unallocated advance).

` const tdsLine = rc !== undefined && rc.tdsPaise > 0 ? `

TDS deducted at source: ${formatINR(rc.tdsPaise)} (treated as settlement).

` : '' return ` ${esc(doc.docNo ?? 'Receipt')}

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

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

${esc(line)}

`).join('\n ')}
RECEIPT

Received From

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

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

${esc(client.address)}

` : ''}

Receipt

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

Date: ${displayDate(doc.docDate)}

${rc !== undefined ? `

Mode: ${esc(rc.mode)}${rc.reference !== '' ? ` · Ref ${esc(rc.reference)}` : ''}

` : ''}

Received with thanks from ${esc(client.name)} the sum of ${formatINR(amountPaise)}

(${esc(rupeesInWords(amountPaise))})

${tdsLine}
${against}

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

Authorised Signatory

` } /** * Canned sample doc for previewing letterhead + quote-content edits (design §4). * Reuses documentHtml — no second renderer — so what the owner sees is exactly * how a real bill prints. A TAX INVOICE so the header, GSTIN and bank box all * render; the state code follows the company so the split is intra-state. */ export function documentHtmlSample(company: Record, opts: { contentLines?: string[] } = {}): string { const stateCode = company['company.state_code'] ?? '32' const client: Client = { id: 'sample', code: 'SAMPLE', name: 'Sample Client Pvt Ltd', gstin: '32ABCDE1234F1Z5', stateCode, address: '1st Floor, MG Road, Kochi', contacts: [], status: 'active', notes: '', } const line: BillLine = { itemId: 'sample', 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, } const totals: BillTotals = { grossPaise: 10_000_00, discountPaise: 0, taxablePaise: 10_000_00, cgstPaise: 900_00, sgstPaise: 900_00, igstPaise: 0, cessPaise: 0, roundOffPaise: 0, payablePaise: 11_800_00, savingsVsMrpPaise: 0, } const doc: Doc = { id: 'sample', docType: 'INVOICE', docNo: null, fy: '2026-27', clientId: 'sample', docDate: new Date().toISOString().slice(0, 10), dueDate: null, status: 'draft', refDocId: null, taxablePaise: 10_000_00, cgstPaise: 900_00, sgstPaise: 900_00, igstPaise: 0, roundOffPaise: 0, payablePaise: 11_800_00, payload: { lines: [line], totals, lineContents: [opts.contentLines ?? ['Cloud POS billing', 'GST invoicing', 'Priority support']], }, source: 'sample', createdBy: 'system', createdAt: '', } return documentHtml(doc, client, company) }