feat(hq): payment receipts — own RCT/ series, zero-GST acknowledgment PDF

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 42cfa3d0b2
commit b7085ce56e

@ -17,7 +17,7 @@ import {
type DocumentFilter, type DraftInput, type DraftLineInput, type DocumentFilter, type DraftInput, type DraftLineInput,
} from './repos-documents' } from './repos-documents'
import { import {
clientLedger, modulePaidView, recordPayment, type RecordPaymentInput, clientLedger, modulePaidView, recordPayment, receiptForPayment, type RecordPaymentInput,
} from './repos-payments' } from './repos-payments'
import { import {
createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans, createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans,
@ -482,6 +482,14 @@ export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router {
} }
res.json({ ok: true, ...clientLedger(db, id), modulePaid: modulePaidView(db, id) }) res.json({ ok: true, ...clientLedger(db, id), modulePaid: modulePaidView(db, id) })
}) })
r.post('/payments/:id/receipt', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '')
try {
res.json({ ok: true, document: receiptForPayment(db, staffId(res), id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- aws usage & cost ---------- // ---------- aws usage & cost ----------
r.get('/aws/ranking', requireAuth, (req, res) => { r.get('/aws/ranking', requireAuth, (req, res) => {

@ -19,10 +19,18 @@ export type DocStatus =
/** HQ services are B2B, GST-exclusive, one flat class (seeded at install). */ /** HQ services are B2B, GST-exclusive, one flat class (seeded at install). */
const TAX_CLASS = 'GST18' const TAX_CLASS = 'GST18'
export interface ReceiptMeta {
paymentId: string; receivedOn: string; mode: string; reference: string
amountPaise: number; tdsPaise: number
allocations: { docNo: string; amountPaise: number }[]
}
export interface DocPayload { export interface DocPayload {
lines: BillLine[]; totals: BillTotals; terms?: string lines: BillLine[]; totals: BillTotals; terms?: string
/** Per-line "what's included" bullets, parallel to `lines` (empty array = nothing to print). */ /** Per-line "what's included" bullets, parallel to `lines` (empty array = nothing to print). */
lineContents?: string[][] lineContents?: string[][]
/** Present only on RECEIPT documents — payment acknowledgment metadata. */
receipt?: ReceiptMeta
} }
export interface Doc { export interface Doc {
@ -326,3 +334,39 @@ export function createCreditNote(db: DB, userId: string, invoiceId: string, line
return doc return doc
})() })()
} }
export interface GenerateReceiptInput {
clientId: string; paymentId: string; receivedOn: string; mode: string
reference: string; amountPaise: number; tdsPaise: number
allocations: { docNo: string; amountPaise: number }[]
}
/**
* Payment receipt a RECEIPT document with its own RCT/ series. Receipts
* acknowledge money, they do not levy tax: zero GST, no line items, payable =
* amount received. Issued (numbered) immediately and never edited thereafter.
*/
export function generateReceipt(db: DB, userId: string, input: GenerateReceiptInput): Doc {
const client = getClient(db, input.clientId)
if (client === null) throw new Error('Client not found')
const totals: BillTotals = {
grossPaise: input.amountPaise, discountPaise: 0, taxablePaise: 0,
cgstPaise: 0, sgstPaise: 0, igstPaise: 0, cessPaise: 0,
roundOffPaise: 0, payablePaise: input.amountPaise, savingsVsMrpPaise: 0,
}
const payload: DocPayload = {
lines: [], totals,
receipt: {
paymentId: input.paymentId, receivedOn: input.receivedOn, mode: input.mode,
reference: input.reference, amountPaise: input.amountPaise, tdsPaise: input.tdsPaise,
allocations: input.allocations,
},
}
return db.transaction(() => {
const draft = insertDocRow(db, userId, {
docType: 'RECEIPT', clientId: input.clientId, refDocId: null,
docDate: todayIso(), totals, payload,
})
return issueDocument(db, userId, draft.id) // assigns RCT/26-27-000N
})()
}

@ -2,7 +2,7 @@ import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit' import { writeAudit } from './audit'
import type { DB } from './db' import type { DB } from './db'
import { getClient } from './repos-clients' import { getClient } from './repos-clients'
import { getDocument, listDocuments, type Doc } from './repos-documents' import { generateReceipt, getDocument, listDocuments, type Doc } from './repos-documents'
/** /**
* Payments allocation, TDS, advances, settlement (D12 portable-repo pattern). * Payments allocation, TDS, advances, settlement (D12 portable-repo pattern).
@ -109,11 +109,13 @@ export interface AllocationInput { documentId: string; amountPaise: number }
export interface RecordPaymentInput { export interface RecordPaymentInput {
clientId: string; receivedOn: string; mode: PaymentMode; reference?: string clientId: string; receivedOn: string; mode: PaymentMode; reference?: string
amountPaise: number; tdsPaise?: number; allocations?: AllocationInput[] amountPaise: number; tdsPaise?: number; allocations?: AllocationInput[]
issueReceipt?: boolean
} }
export interface RecordPaymentResult { export interface RecordPaymentResult {
payment: Payment payment: Payment
allocated: { documentId: string; amountPaise: number }[] allocated: { documentId: string; amountPaise: number }[]
receipt?: Doc
} }
export function recordPayment(db: DB, userId: string, input: RecordPaymentInput): RecordPaymentResult { export function recordPayment(db: DB, userId: string, input: RecordPaymentInput): RecordPaymentResult {
@ -191,10 +193,26 @@ export function recordPayment(db: DB, userId: string, input: RecordPaymentInput)
} }
} }
return { payment: getPayment(db, id)!, allocated } const receipt = input.issueReceipt === true ? receiptForPayment(db, userId, id) : undefined
return { payment: getPayment(db, id)!, allocated, ...(receipt !== undefined ? { receipt } : {}) }
})() })()
} }
/** Build a RECEIPT for an already-recorded payment from its stored allocations. */
export function receiptForPayment(db: DB, userId: string, paymentId: string): Doc {
const p = getPayment(db, paymentId)
if (p === null) throw new Error('Payment not found')
const allocs = db.prepare(
`SELECT a.amount_paise, d.doc_no FROM payment_allocation a
JOIN document d ON d.id = a.document_id WHERE a.payment_id=?`,
).all(paymentId) as { amount_paise: number; doc_no: string | null }[]
return generateReceipt(db, userId, {
clientId: p.clientId, paymentId: p.id, receivedOn: p.receivedOn, mode: p.mode,
reference: p.reference, amountPaise: p.amountPaise, tdsPaise: p.tdsPaise,
allocations: allocs.map((a) => ({ docNo: a.doc_no ?? '—', amountPaise: a.amount_paise })),
})
}
// ---------- read views ---------- // ---------- read views ----------
export interface ClientLedger { documents: Doc[]; payments: Payment[]; advancePaise: number } export interface ClientLedger { documents: Doc[]; payments: Payment[]; advancePaise: number }

@ -61,6 +61,7 @@ function totalsRows(doc: Doc): string {
} }
export function documentHtml(doc: Doc, client: Client, company: Record<string, string>): string { 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 get = (key: string): string => company[`company.${key}`] ?? ''
// Bank details belong on documents payment is made against — tax and proforma invoices. // Bank details belong on documents payment is made against — tax and proforma invoices.
const showBank = (doc.docType === 'INVOICE' || doc.docType === 'PROFORMA') && get('bank') !== '' const showBank = (doc.docType === 'INVOICE' || doc.docType === 'PROFORMA') && get('bank') !== ''
@ -160,3 +161,85 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
</body> </body>
</html>` </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; }
* { 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>`
}

@ -0,0 +1,53 @@
// apps/hq/test/receipt.test.ts
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { createDraft, issueDocument, getDocument } from '../src/repos-documents'
import { recordPayment, receiptForPayment } from '../src/repos-payments'
import { documentHtml } from '../src/templates'
function setup() {
const db = openDb(':memory:'); seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id)
return { db, c, inv }
}
describe('payment receipts', () => {
it('issues a zero-GST RECEIPT with its own RCT/ series on recordPayment', () => {
const { db, c, inv } = setup()
const out = recordPayment(db, 'u1', {
clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 11_800_00,
allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }], issueReceipt: true,
})
expect(out.receipt).toBeDefined()
const rc = out.receipt!
expect(rc.docType).toBe('RECEIPT')
expect(rc.docNo?.startsWith('RCT/')).toBe(true)
expect(rc.payablePaise).toBe(11_800_00)
expect(rc.cgstPaise + rc.sgstPaise + rc.igstPaise).toBe(0) // acknowledgment, not a tax document
expect(rc.payload.receipt?.allocations[0]).toMatchObject({ docNo: inv.docNo, amountPaise: 11_800_00 })
})
it('generates a receipt after the fact from a stored payment', () => {
const { db, c, inv } = setup()
const out = recordPayment(db, 'u1', {
clientId: c.id, receivedOn: '2026-07-10', mode: 'upi', amountPaise: 5_000_00,
allocations: [{ documentId: inv.id, amountPaise: 5_000_00 }],
})
const rc = receiptForPayment(db, 'u1', out.payment.id)
expect(rc.docType).toBe('RECEIPT')
expect(getDocument(db, rc.id)!.docNo).toBe(rc.docNo)
})
it('renders a receipt acknowledgment (no SAC/GST table)', () => {
const { db, c, inv } = setup()
const out = recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }], issueReceipt: true })
const html = documentHtml(out.receipt!, c, { 'company.name': 'Tecnostac' })
expect(html).toContain('RECEIPT')
expect(html).toContain('Received with thanks')
expect(html).not.toContain('>SAC<') // the GST line table header is absent on receipts
})
})
Loading…
Cancel
Save