diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index f6b264636..2d5e97e92 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts index c02c7870e..7d417551f 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -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); + } } diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts new file mode 100644 index 000000000..841bfcc54 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts @@ -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, + }; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts index b9b59cc27..93f50e827 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts @@ -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) => { @@ -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 = {}) => ({ @@ -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/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts index dfbbd688b..2bdb6cbe2 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts @@ -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; + 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( diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 7aa59b3ef..0ee22ec88 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -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: [