You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/templates.ts

368 lines
18 KiB
TypeScript

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<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, contents: string[]): string {
const bullets = contents.length > 0
? `<ul class="line-content">${contents.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
: ''
return `<tr>
<td class="c">${i + 1}</td>
<td>${esc(l.name)}${bullets}</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 {
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 `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>${esc(doc.docNo ?? 'Draft')}</title>
<style>
:root{
--accent:${accent};
--ink:#1f2937; --ink-strong:#111827; --muted:#6b7280; --faint:#9ca3af;
--line:#e5e7eb; --panel:#f8fafc;
}
@page{ size:A4; margin:14mm; }
/* On screen (live preview) puppeteer is not involved, so @page margins do not
apply — mimic the 14mm print margin on a white A4 card over a desk-grey
backdrop. page.pdf renders the print media, so these screen-only rules never
affect the actual PDF. */
@media screen{
html{ background:#eceef1; }
body{ padding:14mm; background:#fff; max-width:210mm; margin:0 auto; box-shadow:0 1px 4px rgba(0,0,0,.18); }
}
*{ box-sizing:border-box; }
body{
font-family:'Segoe UI','Inter','Helvetica Neue',Arial,sans-serif;
font-size:10.5px; line-height:1.45; color:var(--ink); margin:0;
-webkit-print-color-adjust:exact; print-color-adjust:exact;
}
strong{ font-weight:600; }
.eyebrow{ font-size:8.5px; font-weight:600; letter-spacing:.9px; text-transform:uppercase; color:var(--muted); margin:0 0 3px; }
/* Letterhead band */
.letterhead{ display:flex; justify-content:space-between; align-items:flex-end; gap:16px; padding-bottom:10px; border-bottom:2px solid var(--accent); }
.letterhead .lh-brand{ flex:0 0 auto; }
.letterhead .logo{ max-height:60px; max-width:200px; display:block; }
.letterhead .lh-company{ text-align:right; }
.letterhead.no-logo .lh-brand{ display:none; }
.letterhead.no-logo .lh-company{ text-align:left; }
.lh-company h1{ font-size:21px; font-weight:600; letter-spacing:.2px; color:var(--ink-strong); margin:0 0 3px; line-height:1.15; }
.lh-company p{ margin:1px 0; color:var(--muted); font-size:10px; }
.lh-company .gstin{ color:var(--ink-strong); font-weight:600; }
/* Document title */
.doc-title{ text-align:center; margin:14px 0 12px; }
.doc-title span{ display:inline-block; font-size:14px; font-weight:700; letter-spacing:3px; text-transform:uppercase; color:var(--accent); padding-bottom:4px; border-bottom:2px solid var(--accent); }
/* Bill-to / meta */
.meta{ display:flex; justify-content:space-between; gap:16px; margin-bottom:14px; }
.meta .party{ flex:1 1 55%; }
.party .party-name{ font-size:12px; font-weight:600; color:var(--ink-strong); margin:0 0 2px; }
.party p{ margin:1px 0; }
.meta .docmeta{ flex:0 0 38%; align-self:flex-start; border:1px solid var(--line); border-left:3px solid var(--accent); border-radius:2px; padding:8px 10px; background:var(--panel); }
.docmeta .row{ display:flex; justify-content:space-between; gap:10px; margin:2px 0; }
.docmeta .k{ color:var(--muted); }
.docmeta .v{ font-weight:600; color:var(--ink-strong); font-variant-numeric:tabular-nums; }
.chip-draft{ display:inline-block; font-size:8.5px; font-weight:700; letter-spacing:1px; color:#fff; background:var(--accent); padding:1px 6px; border-radius:2px; }
/* Line table */
table.lines{ width:100%; border-collapse:collapse; margin-bottom:14px; }
table.lines thead{ display:table-header-group; }
table.lines th{ font-size:9px; font-weight:600; letter-spacing:.5px; text-transform:uppercase; color:var(--ink-strong); text-align:left; padding:7px 8px; background:#f1f5f9; border-bottom:2px solid var(--accent); }
table.lines td{ padding:7px 8px; border-bottom:1px solid var(--line); vertical-align:top; }
table.lines tbody tr{ break-inside:avoid; page-break-inside:avoid; }
table.lines td.c, table.lines th.c{ text-align:center; }
table.lines td.r, table.lines th.r{ text-align:right; font-variant-numeric:tabular-nums; font-feature-settings:"tnum" 1; }
table.lines td:first-child, table.lines th:first-child{ padding-left:2px; }
table.lines td:last-child, table.lines th:last-child{ padding-right:2px; }
table.lines td:last-child{ font-weight:600; }
ul.line-content{ margin:4px 0 0; padding-left:15px; color:var(--muted); font-size:9.5px; }
ul.line-content li{ margin:1px 0; }
@supports (background: color-mix(in srgb, red, blue)){
table.lines th{ background: color-mix(in srgb, var(--accent) 7%, #ffffff); }
}
/* Totals + amount in words */
.bottom{ display:flex; justify-content:space-between; align-items:flex-start; gap:20px; margin-bottom:6px; break-inside:avoid; }
.words{ flex:1 1 55%; border-left:2px solid var(--accent); background:var(--panel); padding:8px 12px; border-radius:0 2px 2px 0; }
.words .amt-words{ font-style:italic; font-weight:500; color:var(--ink-strong); }
table.totals{ flex:0 0 40%; border-collapse:collapse; }
table.totals td{ padding:5px 10px; border-bottom:1px solid var(--line); font-variant-numeric:tabular-nums; }
table.totals td:first-child{ color:var(--muted); }
table.totals td.r{ text-align:right; color:var(--ink-strong); font-weight:500; }
table.totals tr.grand td{ background:var(--accent); color:#fff; border:none; font-size:14px; font-weight:700; padding:8px 10px; letter-spacing:.3px; }
/* Supporting blocks */
.panels{ display:flex; gap:14px; margin-top:14px; break-inside:avoid; }
.panels>*{ flex:1; }
.bank, .terms{ border:1px solid var(--line); border-radius:2px; padding:8px 10px; }
.bank p, .terms p{ margin:2px 0 0; }
.declaration{ margin-top:12px; font-size:9.5px; color:var(--muted); }
.jurisdiction{ margin-top:4px; font-size:9.5px; color:var(--faint); }
.sign{ margin-top:26px; text-align:right; break-inside:avoid; }
.sign .for{ color:var(--ink-strong); }
.sign .label{ margin-top:34px; font-weight:600; color:var(--ink-strong); }
.footer-note{ margin-top:18px; padding-top:8px; border-top:1px solid var(--line); text-align:center; font-size:9px; color:var(--faint); }
/* OPTIONAL draft watermark (only un-issued) */
.watermark{ position:fixed; top:50%; left:50%; transform:translate(-50%,-50%) rotate(-24deg); font-size:120px; font-weight:800; letter-spacing:8px; color:var(--accent); opacity:.05; z-index:-1; pointer-events:none; }
</style>
</head>
<body>
${isDraft ? `<div class="watermark">DRAFT</div>` : ''}
<header class="letterhead ${logo !== '' ? 'has-logo' : 'no-logo'}">
<div class="lh-brand">${logo !== '' ? `<img class="logo" src="${esc(logo)}" alt="">` : ''}</div>
<div class="lh-company">
<h1>${esc(get('name'))}</h1>
${get('address') !== '' ? `<p>${esc(get('address'))}</p>` : ''}
${get('gstin') !== '' ? `<p class="gstin">GSTIN: ${esc(get('gstin'))}</p>` : ''}
${phoneEmail !== '' ? `<p>${esc(phoneEmail)}</p>` : ''}
</div>
</header>
<div class="doc-title"><span>${esc(title)}</span></div>
<section class="meta">
<div class="party">
<div class="eyebrow">${doc.docType === 'QUOTATION' ? 'To' : 'Bill To'}</div>
<p class="party-name">${esc(client.name)} <span style="color:var(--muted);font-weight:400">(${esc(client.code)})</span></p>
${client.address !== '' ? `<p>${esc(client.address)}</p>` : ''}
${client.gstin !== undefined ? `<p><strong>GSTIN:</strong> ${esc(client.gstin)}</p>` : ''}
<p><strong>State Code:</strong> ${esc(client.stateCode)}</p>
</div>
<div class="docmeta">
<div class="row"><span class="k">Document No</span><span class="v">${doc.docNo !== null ? esc(doc.docNo) : '<span class="chip-draft">DRAFT</span>'}</span></div>
<div class="row"><span class="k">Date</span><span class="v">${displayDate(doc.docDate)}</span></div>
${doc.docType === 'INVOICE' && typeof doc.dueDate === 'string' ? `<div class="row"><span class="k">Due Date</span><span class="v">${displayDate(doc.dueDate)}</span></div>` : ''}
<div class="row"><span class="k">FY</span><span class="v">${esc(doc.fy)}</span></div>
</div>
</section>
<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((l, i) => lineRow(l, i, doc.payload.lineContents?.[i] ?? [])).join('\n')}
</tbody>
</table>
<div class="bottom">
<div class="words">
<div class="eyebrow">Amount in Words</div>
<div class="amt-words">${esc(rupeesInWords(doc.payablePaise))}</div>
</div>
<table class="totals">
${totalsRows(doc)}
<tr class="grand"><td>Grand Total</td><td class="r">${formatINR(doc.payablePaise)}</td></tr>
</table>
</div>
${(showBank || termsText !== '') ? `<div class="panels">
${showBank ? `<div class="bank"><div class="eyebrow">Bank Details</div><p>${esc(get('bank'))}</p></div>` : ''}
${termsText !== '' ? `<div class="terms"><div class="eyebrow">Terms &amp; Conditions</div><p>${esc(termsText)}</p></div>` : ''}
</div>` : ''}
${tpl('declaration') !== '' ? `<div class="declaration">${esc(tpl('declaration'))}</div>` : ''}
${tpl('jurisdiction') !== '' ? `<p class="jurisdiction">${esc(tpl('jurisdiction'))}</p>` : ''}
<div class="sign">
<p class="for">For <strong>${esc(get('name'))}</strong></p>
<p class="label">${esc(tpl('signatory_label') !== '' ? tpl('signatory_label') : 'Authorised Signatory')}</p>
</div>
${tpl('footer_note') !== '' ? `<div class="footer-note">${esc(tpl('footer_note'))}</div>` : ''}
</body>
</html>`
}
/** Payment acknowledgment — no SAC/GST table; states the sum received in words. */
function receiptHtml(doc: Doc, client: Client, company: Record<string, string>): 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
? `<table class="lines"><thead><tr><th>Towards invoice</th><th class="r">Amount</th></tr></thead><tbody>`
+ allocRows.map((a) => `<tr><td>${esc(a.docNo)}</td><td class="r">${formatINR(a.amountPaise)}</td></tr>`).join('')
+ `</tbody></table>`
: `<p>Received on account (unallocated advance).</p>`
const tdsLine = rc !== undefined && rc.tdsPaise > 0
? `<p>TDS deducted at source: <strong>${formatINR(rc.tdsPaise)}</strong> (treated as settlement).</p>` : ''
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>${esc(doc.docNo ?? 'Receipt')}</title>
<style>
@page { size: A4; margin: 14mm; }
/* On screen (live preview) puppeteer is not involved, so @page margins do not
apply — mimic the 14mm print margin and a white page. page.pdf renders the
print media, so these screen-only rules never affect the actual PDF. */
@media screen { body { padding: 14mm; background: #fff; } }
* { 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; }
.ack { border: 1px solid #ccc; padding: 10px 12px; margin: 10px 0; font-size: 12px; }
.ack .amount { font-size: 15px; font-weight: 700; }
table.lines { width: 100%; border-collapse: collapse; margin: 8px 0; }
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; }
.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">RECEIPT</div>
<div class="meta">
<div class="box">
<h2>Received From</h2>
<p><strong>${esc(client.name)}</strong> (${esc(client.code)})</p>
${client.address !== '' ? `<p>${esc(client.address)}</p>` : ''}
</div>
<div class="box">
<h2>Receipt</h2>
<p>No: <strong>${esc(doc.docNo ?? 'DRAFT')}</strong></p>
<p>Date: ${displayDate(doc.docDate)}</p>
${rc !== undefined ? `<p>Mode: ${esc(rc.mode)}${rc.reference !== '' ? ` · Ref ${esc(rc.reference)}` : ''}</p>` : ''}
</div>
</div>
<div class="ack">
<p>Received with thanks from <strong>${esc(client.name)}</strong> the sum of
<span class="amount">${formatINR(amountPaise)}</span></p>
<p>(${esc(rupeesInWords(amountPaise))})</p>
${tdsLine}
</div>
${against}
<div class="sign">
<p>For <strong>${esc(get('name'))}</strong></p>
<p style="margin-top: 36px;">Authorised Signatory</p>
</div>
</body>
</html>`
}
/**
* 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<string, string>, 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)
}