import { Invoice } from "../billing/entities/invoice.entity"; import { InvoiceDocumentModel, pngDataUrl, } from "../billing/documents/invoice-document.service"; import { EimsReceipt, EimsReceiptStatus } from "./entities/eims-receipt.entity"; import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-receipt.types"; /** * Maps a filed `EimsReceipt` onto the shared invoice/receipt document layout — mirrors * `eims-invoice.mapper.ts`'s role for `/v1/register`: a pure function, no I/O. * * The amounts (collected amount, mode of payment, withholding amount) live only in * `receipt.request` — the exact body this app sent, typed and written in exactly one place * (`EimsReceiptService`). Reading it back is a cast, not a new source of truth; real columns * would mean a migration + backfill for data already present in a stable shape. * * Throws rather than returning a model for anything not actually filed: a sealed, stamped PDF * for a receipt MoR rejected, never acknowledged, or whose request was somehow never recorded * would read as a genuine tax document. Callers (`EimsReceiptService.document`) let this throw * surface as a 400 — there is nothing sensible to render instead. */ export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): InvoiceDocumentModel { if (receipt.status !== EimsReceiptStatus.Registered) { throw new Error( `Receipt ${receipt.receiptNumber} is ${receipt.status}, not REGISTERED — refusing to print an unfiled receipt.`, ); } if (!receipt.request) { throw new Error(`Receipt ${receipt.receiptNumber} has no stored request body — cannot render its amounts.`); } const isSales = receipt.kind === "SALES"; if (isSales) { const req = receipt.request as unknown as EimsSalesReceiptRequest; return build(receipt, invoice, { title: "Sales Receipt", currency: req.ReceiptCurrency, amountLabel: "Collected", lineDescription: `Payment received against invoice ${invoice.invoiceNumber}`, amount: req.CollectedAmount, // A sales receipt is a real payment — this is the one case the shared layout's own default // ("EDR PAID" for kind RECEIPT) is already correct, but set it explicitly so it never drifts // if that default changes for an unrelated reason. sealText: "EDR PAID", extraSummary: [{ label: "Mode of payment", value: req.TransactionDetails.ModeOfPayment }], }); } const req = receipt.request as unknown as EimsWithholdReceiptRequest; return build(receipt, invoice, { title: "Withholding Receipt", currency: req.InvoiceDetail.Currency, amountLabel: "Withheld", lineDescription: `Withholding (${req.WithholdDetail.Type}) against invoice ${invoice.invoiceNumber}`, amount: req.WithholdDetail.WithholdingAmount, // A withholding receipt is not a payment — the shared layout's "EDR PAID" default would be // wrong here, so this is the one case that MUST override it. sealText: "EDR", extraSummary: [{ label: "Withholding type", value: req.WithholdDetail.Type }], }); } function build( receipt: EimsReceipt, invoice: Invoice, opts: { title: string; currency: string; amountLabel: string; lineDescription: string; amount: number; sealText: string; extraSummary: Array<{ label: string; value: string | null }>; }, ): InvoiceDocumentModel { return { kind: "RECEIPT", title: opts.title, documentNumber: receipt.receiptNumber, issuedAt: receipt.submittedAt ?? null, status: receipt.status, currency: opts.currency, summary: [ { label: "Invoice", value: invoice.invoiceNumber }, { label: "Invoice IRN", value: invoice.eimsIrn ?? null }, { label: "RRN", value: receipt.rrn ?? null }, { label: "Ack status", value: receipt.ackStatus ?? null }, ...opts.extraSummary, ], // No line items on a receipt — one synthetic line, since buildHtml renders the line table // unconditionally and an empty `lines: []` would print a header-only empty table. lines: [ { description: opts.lineDescription, quantity: 1, unitRate: opts.amount, amount: opts.amount, currency: opts.currency, }, ], totals: [{ label: opts.amountLabel, amount: opts.amount, grand: true }], sealText: opts.sealText, qrImageUrl: receipt.qr ? pngDataUrl(receipt.qr) : null, }; }