From b7085ce56e178b156a9534f7c33b680c086150aa Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 21:32:27 +0530 Subject: [PATCH] =?UTF-8?q?feat(hq):=20payment=20receipts=20=E2=80=94=20ow?= =?UTF-8?q?n=20RCT/=20series,=20zero-GST=20acknowledgment=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 10 +++- apps/hq/src/repos-documents.ts | 44 ++++++++++++++++++ apps/hq/src/repos-payments.ts | 22 ++++++++- apps/hq/src/templates.ts | 83 ++++++++++++++++++++++++++++++++++ apps/hq/test/receipt.test.ts | 53 ++++++++++++++++++++++ 5 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 apps/hq/test/receipt.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index ab8dce9..64117c1 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -17,7 +17,7 @@ import { type DocumentFilter, type DraftInput, type DraftLineInput, } from './repos-documents' import { - clientLedger, modulePaidView, recordPayment, type RecordPaymentInput, + clientLedger, modulePaidView, recordPayment, receiptForPayment, type RecordPaymentInput, } from './repos-payments' import { createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans, @@ -482,6 +482,14 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { } 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 ---------- r.get('/aws/ranking', requireAuth, (req, res) => { diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts index 061f1ef..f71aebc 100644 --- a/apps/hq/src/repos-documents.ts +++ b/apps/hq/src/repos-documents.ts @@ -19,10 +19,18 @@ export type DocStatus = /** HQ services are B2B, GST-exclusive, one flat class (seeded at install). */ 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 { lines: BillLine[]; totals: BillTotals; terms?: string /** Per-line "what's included" bullets, parallel to `lines` (empty array = nothing to print). */ lineContents?: string[][] + /** Present only on RECEIPT documents — payment acknowledgment metadata. */ + receipt?: ReceiptMeta } export interface Doc { @@ -326,3 +334,39 @@ export function createCreditNote(db: DB, userId: string, invoiceId: string, line 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 + })() +} diff --git a/apps/hq/src/repos-payments.ts b/apps/hq/src/repos-payments.ts index 004e9c8..33e608f 100644 --- a/apps/hq/src/repos-payments.ts +++ b/apps/hq/src/repos-payments.ts @@ -2,7 +2,7 @@ import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import type { DB } from './db' 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). @@ -109,11 +109,13 @@ export interface AllocationInput { documentId: string; amountPaise: number } export interface RecordPaymentInput { clientId: string; receivedOn: string; mode: PaymentMode; reference?: string amountPaise: number; tdsPaise?: number; allocations?: AllocationInput[] + issueReceipt?: boolean } export interface RecordPaymentResult { payment: Payment allocated: { documentId: string; amountPaise: number }[] + receipt?: Doc } 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 ---------- export interface ClientLedger { documents: Doc[]; payments: Payment[]; advancePaise: number } diff --git a/apps/hq/src/templates.ts b/apps/hq/src/templates.ts index 778917a..0c1081f 100644 --- a/apps/hq/src/templates.ts +++ b/apps/hq/src/templates.ts @@ -61,6 +61,7 @@ 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}`] ?? '' // Bank details belong on documents payment is made against — tax and proforma invoices. const showBank = (doc.docType === 'INVOICE' || doc.docType === 'PROFORMA') && get('bank') !== '' @@ -160,3 +161,85 @@ export function documentHtml(doc: Doc, client: Client, company: Record ` } + +/** 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

+
+ +` +} diff --git a/apps/hq/test/receipt.test.ts b/apps/hq/test/receipt.test.ts new file mode 100644 index 0000000..bd56e2b --- /dev/null +++ b/apps/hq/test/receipt.test.ts @@ -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 + }) +})