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>
This commit is contained in:
Hagernesh
2026-08-15 06:44:53 +00:00
parent d66cfe2328
commit 8e70352407
6 changed files with 239 additions and 5 deletions

View File

@@ -18,6 +18,15 @@ import {
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
/**
* MoR returns `signedQR`/`qr` as a base64 PNG already rendered server-side — confirmed against the
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), not a
* payload we encode ourselves. Wrap, don't encode. Shared by `Invoice.eimsSignedQr`
* (`BillingService`) and `EimsReceipt.qr` (`eims-receipt-document.mapper.ts`) — same convention,
* same gateway.
*/
export const pngDataUrl = (base64: string): string => `data:image/png;base64,${base64}`;
/** One billed line on the document (charge type / fee type agnostic). */ /** One billed line on the document (charge type / fee type agnostic). */
export interface InvoiceDocumentLine { export interface InvoiceDocumentLine {
description: string | null; description: string | null;

View File

@@ -1,8 +1,10 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common"; import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { BookingStaff } from "../../common/booking-guards"; import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { sendPdf } from "../billing/billing.controller";
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto"; import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
@@ -110,4 +112,16 @@ export class EimsInvoiceController {
listReceipts(@Param("id", ParseUUIDPipe) id: string) { listReceipts(@Param("id", ParseUUIDPipe) id: string) {
return this.receipts.listReceipts(id); return this.receipts.listReceipts(id);
} }
@Get(":id/eims/receipts/:receiptId/document")
@BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed receipt PDF (RRN + QR) for a filed EIMS receipt" })
async receiptDocument(
@Param("id", ParseUUIDPipe) id: string,
@Param("receiptId", ParseUUIDPipe) receiptId: string,
@Res() res: Response,
) {
const { filename, buffer } = await this.receipts.document(id, receiptId);
sendPdf(res, filename, buffer);
}
} }

View File

@@ -0,0 +1,107 @@
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,
};
}

View File

@@ -4,6 +4,7 @@ import { DataSource } from "typeorm";
import { EimsConfig } from "../../config/eims.config"; import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity"; import { Invoice } from "../billing/entities/invoice.entity";
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
import { NotificationsService } from "../notifications/notifications.service"; import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service"; import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service"; import { EimsClientService } from "./eims-client.service";
@@ -40,10 +41,16 @@ class FakeDb {
} }
private manager = { private manager = {
findOne: async (entity: unknown, options: { where: { id: string } }) => findOne: async (
entity === Invoice entity: unknown,
? (this.invoices.get(options.where.id) ?? null) options: { where: { id?: string; invoiceId?: string } },
: (this.receipts.get(options.where.id) ?? null), ) => {
if (entity === Invoice) return this.invoices.get(options.where.id!) ?? null;
const receipt = options.where.id ? this.receipts.get(options.where.id) : undefined;
if (!receipt) return null;
if (options.where.invoiceId && receipt.invoiceId !== options.where.invoiceId) return null;
return receipt;
},
find: async (_entity: unknown, options: { where: { invoiceId: string } }) => find: async (_entity: unknown, options: { where: { invoiceId: string } }) =>
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId), [...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
save: async (_entity: unknown, data: Record<string, unknown>) => { save: async (_entity: unknown, data: Record<string, unknown>) => {
@@ -71,6 +78,7 @@ const build = (
db: FakeDb, db: FakeDb,
postBearer: jest.Mock, postBearer: jest.Mock,
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined), directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
documents: { render: jest.Mock } = { render: jest.fn() },
) => ) =>
new EimsReceiptService( new EimsReceiptService(
db.asDataSource(), db.asDataSource(),
@@ -78,6 +86,7 @@ const build = (
{ postBearer } as unknown as EimsClientService, { postBearer } as unknown as EimsClientService,
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService, { getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
{ directSend } as unknown as NotificationsService, { directSend } as unknown as NotificationsService,
documents as unknown as InvoiceDocumentService,
); );
const okResponse = (over: Record<string, unknown> = {}) => ({ const okResponse = (over: Record<string, unknown> = {}) => ({
@@ -229,3 +238,66 @@ describe("EimsReceiptService.listReceipts", () => {
expect(list).toHaveLength(2); expect(list).toHaveLength(2);
}); });
}); });
describe("EimsReceiptService.document", () => {
it("renders a sealed PDF for a registered sales receipt, with RRN and QR in the model", async () => {
const db = new FakeDb([invoiceRow()]);
const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) };
const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents);
const receipt = await service.registerSalesReceipt(INVOICE_ID, {
modeOfPayment: "CASH",
collectedAmount: 500,
} as never);
await service.document(INVOICE_ID, receipt.id);
expect(documents.render).toHaveBeenCalledTimes(1);
const model = documents.render.mock.calls[0][0];
expect(model.kind).toBe("RECEIPT");
expect(model.qrImageUrl).toBe("data:image/png;base64,iVBORw0KGgo...");
expect(model.summary).toContainEqual({ label: "RRN", value: "rrn-value" });
expect(model.lines[0].amount).toBe(500);
expect(model.sealText).toBe("EDR PAID");
});
it("renders a withholding receipt with the withheld amount and a non-PAID seal", async () => {
const db = new FakeDb([invoiceRow()]);
const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) };
const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents);
const receipt = await service.registerWithholdingReceipt(INVOICE_ID, {
type: "TWHT",
preTaxAmount: 1000,
withholdingAmount: 20,
} as never);
await service.document(INVOICE_ID, receipt.id);
const model = documents.render.mock.calls[0][0];
expect(model.lines[0].amount).toBe(20);
expect(model.sealText).toBe("EDR");
expect(model.sealText).not.toContain("PAID");
});
it("refuses to render a receipt that was never acknowledged by MoR", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "EIMS receipt timed out"));
const documents = { render: jest.fn() };
const service = build(db, postBearer, undefined, documents);
await expect(
service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
).rejects.toBeInstanceOf(EimsApiException);
const [receipt] = [...db.receipts.values()];
await expect(service.document(INVOICE_ID, receipt.id as string)).rejects.toBeInstanceOf(BadRequestException);
expect(documents.render).not.toHaveBeenCalled();
});
it("scopes the lookup to the given invoice — a receipt from another invoice is not found", async () => {
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
const service = build(db, jest.fn().mockResolvedValue(okResponse()));
const receipt = await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never);
await expect(service.document(OTHER_INVOICE_ID, receipt.id)).rejects.toThrow(/not found/);
});
});

View File

@@ -6,11 +6,13 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
import { EimsConfig } from "../../config/eims.config"; import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity"; import { Invoice } from "../billing/entities/invoice.entity";
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
import { NotificationsService } from "../notifications/notifications.service"; import { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util"; import { sendCompanyChannels } from "../notifications/notify-company.util";
import { EimsAuthService } from "./eims-auth.service"; import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service"; import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors"; import { EimsApiException } from "./eims.errors";
import { toReceiptDocumentModel } from "./eims-receipt-document.mapper";
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity"; import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
@@ -52,6 +54,7 @@ export class EimsReceiptService {
private readonly client: EimsClientService, private readonly client: EimsClientService,
private readonly auth: EimsAuthService, private readonly auth: EimsAuthService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly documents: InvoiceDocumentService,
) {} ) {}
private get cfg(): EimsConfig { private get cfg(): EimsConfig {
@@ -154,6 +157,31 @@ export class EimsReceiptService {
}); });
} }
/**
* Sealed PDF for one filed receipt (RRN + QR), scoped to the invoice it belongs to. Not on
* `loadRegisteredInvoice` — a receipt refused/never-acknowledged by MoR must not render as a
* sealed tax document, and `toReceiptDocumentModel` is the one place that guards it.
*/
async document(invoiceId: string, receiptId: string): Promise<{ filename: string; buffer: Buffer }> {
const receipt = await this.dataSource.manager.findOne(EimsReceipt, {
where: { id: receiptId, invoiceId },
});
if (!receipt) throw new NotFoundException(`Receipt ${receiptId} not found on invoice ${invoiceId}`);
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
let model: ReturnType<typeof toReceiptDocumentModel>;
try {
model = toReceiptDocumentModel(receipt, invoice);
} catch (err) {
// Only the mapper's own refusals (not-yet-registered, missing request body) become a 400 —
// a genuine PDF-render failure below is left to surface as whatever InvoiceDocumentService
// itself throws.
throw new BadRequestException((err as Error).message);
}
return this.documents.render(model);
}
// ── internals ──────────────────────────────────────────────────────────────────────────────── // ── internals ────────────────────────────────────────────────────────────────────────────────
private async submit( private async submit(

View File

@@ -3,6 +3,7 @@ import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm"; import { TypeOrmModule } from "@nestjs/typeorm";
import { Invoice } from "../billing/entities/invoice.entity"; import { Invoice } from "../billing/entities/invoice.entity";
import { DocumentsModule } from "../billing/documents/documents.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { NotificationsModule } from "../notifications/notifications.module"; import { NotificationsModule } from "../notifications/notifications.module";
import { EimsAuthService } from "./eims-auth.service"; import { EimsAuthService } from "./eims-auth.service";
@@ -29,6 +30,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]), TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
NotificationInboxModule, NotificationInboxModule,
NotificationsModule, NotificationsModule,
// For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain
// deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle.
DocumentsModule,
], ],
controllers: [EimsInvoiceController], controllers: [EimsInvoiceController],
providers: [ providers: [