Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts
Hagernesh 8e70352407 feat(eims): printable receipt PDF with RRN and QR
eims-receipt-document.mapper.ts maps an EimsReceipt onto the shared
InvoiceDocumentModel layout, reading amounts back out of the stored request
body. Refuses to render anything not REGISTERED. GET
invoices/:id/eims/receipts/:receiptId/document, scoped to the invoice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 08:38:35 +00:00

108 lines
4.4 KiB
TypeScript

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,
};
}