mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -18,6 +18,15 @@ import {
|
||||
|
||||
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). */
|
||||
export interface InvoiceDocumentLine {
|
||||
description: string | null;
|
||||
|
||||
@@ -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 type { Response } from "express";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { sendPdf } from "../billing/billing.controller";
|
||||
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||
@@ -110,4 +112,16 @@ export class EimsInvoiceController {
|
||||
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { DataSource } from "typeorm";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
@@ -40,10 +41,16 @@ class FakeDb {
|
||||
}
|
||||
|
||||
private manager = {
|
||||
findOne: async (entity: unknown, options: { where: { id: string } }) =>
|
||||
entity === Invoice
|
||||
? (this.invoices.get(options.where.id) ?? null)
|
||||
: (this.receipts.get(options.where.id) ?? null),
|
||||
findOne: async (
|
||||
entity: unknown,
|
||||
options: { where: { id?: string; invoiceId?: string } },
|
||||
) => {
|
||||
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 } }) =>
|
||||
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
|
||||
save: async (_entity: unknown, data: Record<string, unknown>) => {
|
||||
@@ -71,6 +78,7 @@ const build = (
|
||||
db: FakeDb,
|
||||
postBearer: jest.Mock,
|
||||
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||
documents: { render: jest.Mock } = { render: jest.fn() },
|
||||
) =>
|
||||
new EimsReceiptService(
|
||||
db.asDataSource(),
|
||||
@@ -78,6 +86,7 @@ const build = (
|
||||
{ postBearer } as unknown as EimsClientService,
|
||||
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
|
||||
{ directSend } as unknown as NotificationsService,
|
||||
documents as unknown as InvoiceDocumentService,
|
||||
);
|
||||
|
||||
const okResponse = (over: Record<string, unknown> = {}) => ({
|
||||
@@ -229,3 +238,66 @@ describe("EimsReceiptService.listReceipts", () => {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,11 +6,13 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { InvoiceDocumentService } from "../billing/documents/invoice-document.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsApiException } from "./eims.errors";
|
||||
import { toReceiptDocumentModel } from "./eims-receipt-document.mapper";
|
||||
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
|
||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||
@@ -52,6 +54,7 @@ export class EimsReceiptService {
|
||||
private readonly client: EimsClientService,
|
||||
private readonly auth: EimsAuthService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly documents: InvoiceDocumentService,
|
||||
) {}
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async submit(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { DocumentsModule } from "../billing/documents/documents.module";
|
||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
@@ -29,6 +30,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
|
||||
NotificationInboxModule,
|
||||
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],
|
||||
providers: [
|
||||
|
||||
Reference in New Issue
Block a user