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.
368 lines
18 KiB
TypeScript
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, '&').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
|
|
? `<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 & 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)
|
|
}
|