diff --git a/apps/edr-freight-api/src/migrations/3550000000000-EimsDebitCreditNotes.ts b/apps/edr-freight-api/src/migrations/3550000000000-EimsDebitCreditNotes.ts new file mode 100644 index 000000000..cf36df757 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3550000000000-EimsDebitCreditNotes.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Debit/credit note filing — confirmed directly by MoR support: same `/v1/register` endpoint, + * distinguished by `DocumentDetails.Type` ("DEB"/"CRE") + a `Reason`, linked to the original + * invoice via `ReferenceDetails.RelatedDocument`. See `Invoice.eimsDocumentType`. + */ +export class EimsDebitCreditNotes3550000000000 implements MigrationInterface { + name = "EimsDebitCreditNotes3550000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_document_type varchar(8) NOT NULL DEFAULT 'INV', + ADD COLUMN IF NOT EXISTS eims_reason text, + ADD COLUMN IF NOT EXISTS related_invoice_id uuid REFERENCES freight.invoices(id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_document_type, + DROP COLUMN IF EXISTS eims_reason, + DROP COLUMN IF EXISTS related_invoice_id + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 5bf1448fd..02da40a4a 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Get, @@ -29,6 +30,7 @@ import { actorLabel } from "../warehouses/current-actor.util"; import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { BillingService } from "./billing.service"; import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; +import { IssueMemoDto } from "./dto/issue-memo.dto"; @ApiTags("billing") @Controller("billing") @@ -38,6 +40,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, FREIGHT_PERMS.invoices.confirmOffline, + FREIGHT_PERMS.invoices.memoIssue, ]) @ApiBearerAuth() export class BillingController { @@ -99,11 +102,31 @@ export class BillingController { }); } + @Post("invoices/:id/memo") + @BookingStaff(FREIGHT_PERMS.invoices.memoIssue) + @ApiOperation({ + summary: + "Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.", + }) + issueMemo(@Param("id", ParseUUIDPipe) id: string, @Body() dto: IssueMemoDto) { + return this.billingService.issueMemo(id, dto); + } + @Get("invoices/:id/document") @BookingStaff(FREIGHT_PERMS.invoices.export) - @ApiOperation({ summary: "Download the sealed invoice PDF" }) - async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { - const { filename, buffer } = await this.billingService.document(id); + @ApiOperation({ + summary: + 'Download the sealed invoice PDF. ?format=a4 (default) or ?format=thermal for the 80mm thermal layout (ADD-P001).', + }) + async document( + @Param("id", ParseUUIDPipe) id: string, + @Query("format") format: string | undefined, + @Res() res: Response, + ) { + if (format !== undefined && format !== "a4" && format !== "thermal") { + throw new BadRequestException(`Unsupported format "${format}" — use "a4" or "thermal".`); + } + const { filename, buffer } = await this.billingService.document(id, format === "thermal" ? "thermal" : "a4"); sendPdf(res, filename, buffer); } diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index d9eef5a43..243b1ee4d 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -119,6 +119,159 @@ describe("BillingService.generateInvoice", () => { }); }); +describe("BillingService.issueMemo", () => { + const ORIGINAL_ID = "original-invoice-1"; + + function originalInvoice(overrides: Record = {}) { + return { + id: ORIGINAL_ID, + invoiceNumber: "INV-20260807-00042", + eimsIrn: "irn-value", + eimsDocumentType: "INV", + eimsStatus: "REGISTERED", + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + companyId: "company-1", + companyProfileId: "profile-1", + shippingLineCompanyId: null, + currency: "ETB", + totalAmount: 1500, + lines: [ + { chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000, currency: "ETB", metadata: null }, + { chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500, currency: "ETB", metadata: null }, + ], + ...overrides, + }; + } + + function build(original: ReturnType) { + const savedLines: unknown[] = []; + const manager = makeManager(savedLines); + const dataSource = { + transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)), + manager, + }; + const invoices = { findById: jest.fn().mockResolvedValue(original) }; + const invoiceLines = { findAll: jest.fn().mockResolvedValue(original.lines) }; + const service = new BillingService( + dataSource as never, + invoices as never, + invoiceLines as never, + makeEvents() as never, + {} as never, + {} as never, + {} as never, + {} as never, + { get: () => undefined } as never, + ); + return { service, manager, savedLines }; + } + + it("creates a settled credit memo copying the original's lines, linked via relatedInvoiceId", async () => { + const { service, savedLines } = build(originalInvoice()); + + const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "Overbilled freight charge" }); + + expect(memo.invoiceNumber).toMatch(/^CRE-\d{8}-00001$/); + expect(memo.totalAmount).toBe(1500); + expect(memo.status).toBe(Freight.InvoiceStatus.Paid); + expect((memo as unknown as Record).eimsDocumentType).toBe("CRE"); + expect((memo as unknown as Record).eimsReason).toBe("Overbilled freight charge"); + expect((memo as unknown as Record).relatedInvoiceId).toBe(ORIGINAL_ID); + expect((memo as unknown as Record).paidAmount).toBe(1500); + expect((memo as unknown as Record).balanceAmount).toBe(0); + expect(savedLines).toHaveLength(2); + }); + + it("creates an open, unpaid debit memo — a genuine new receivable, not force-settled", async () => { + const { service } = build(originalInvoice()); + + const memo = await service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "Additional handling fee" }); + + expect(memo.invoiceNumber).toMatch(/^DEB-\d{8}-00001$/); + expect(memo.status).toBe(Freight.InvoiceStatus.Pending); + expect(memo.balanceAmount).toBe(1500); + expect(memo.paidAmount).toBe(0); + }); + + it("keys the memo's sourceId to the original invoice's own id, not the original's sourceId", async () => { + const { service } = build(originalInvoice()); + + const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "test" }); + + expect(memo.sourceId).toBe(ORIGINAL_ID); + expect(memo.sourceId).not.toBe("booking-1"); + }); + + it("allows a partial memo with explicit lines instead of copying the original", async () => { + const { service } = build(originalInvoice()); + + const memo = await service.issueMemo(ORIGINAL_ID, { + type: "CRE", + reason: "Partial credit", + lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 200, amount: 200 }], + }); + + expect(memo.totalAmount).toBe(200); + }); + + it("refuses a memo against an invoice never registered with EIMS", async () => { + const { service } = build(originalInvoice({ eimsIrn: null })); + + await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toMatchObject({ + response: expect.objectContaining({ code: "EIMS_RELATED_INVOICE_NOT_REGISTERED" }), + }); + }); + + it("refuses a memo against a memo", async () => { + const { service } = build(originalInvoice({ eimsDocumentType: "CRE" })); + + await expect(service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "x" })).rejects.toThrow( + "cannot issue a memo against a memo", + ); + }); + + it("refuses a memo against an EIMS-cancelled invoice", async () => { + const { service } = build(originalInvoice({ eimsStatus: "CANCELLED" })); + + await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toThrow( + "cancelled with EIMS", + ); + }); + + it("refuses a credit memo whose total exceeds the original", async () => { + const { service } = build(originalInvoice({ totalAmount: 1500 })); + + await expect( + service.issueMemo(ORIGINAL_ID, { + type: "CRE", + reason: "too much", + lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 2000, amount: 2000 }], + }), + ).rejects.toThrow(/exceeds/); + }); + + it("does NOT bound a debit memo by the original's total — it is a new charge, not a refund", async () => { + const { service } = build(originalInvoice({ totalAmount: 1500 })); + + const memo = await service.issueMemo(ORIGINAL_ID, { + type: "DEB", + reason: "additional charge", + lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 5000, amount: 5000 }], + }); + + expect(memo.totalAmount).toBe(5000); + }); + + it("refuses a blank reason", async () => { + const { service } = build(originalInvoice()); + + await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: " " })).rejects.toThrow( + "requires a reason", + ); + }); +}); + describe("BillingService.markInvoiceAsPaid", () => { it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => { const open = { @@ -774,6 +927,7 @@ describe("BillingService.document", () => { const build = (invoice: Record) => { const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }); + const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") }); const service = new BillingService( {} as never, { findById: jest.fn().mockResolvedValue(invoice) } as never, @@ -781,7 +935,7 @@ describe("BillingService.document", () => { {} as never, {} as never, {} as never, - { render } as never, + { render, renderThermal } as never, {} as never, { get: (key: string) => @@ -790,7 +944,7 @@ describe("BillingService.document", () => { : undefined, } as never, // config ); - return { service, render }; + return { service, render, renderThermal }; }; it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => { @@ -847,4 +1001,24 @@ describe("BillingService.document", () => { expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" }); expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload"); }); + + it("calls render (not renderThermal) for the default format", async () => { + const { service, render, renderThermal } = build(invoiceRow()); + jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never); + + await service.document("inv-1"); + + expect(render).toHaveBeenCalledTimes(1); + expect(renderThermal).not.toHaveBeenCalled(); + }); + + it("calls renderThermal (not render) for format 'thermal'", async () => { + const { service, render, renderThermal } = build(invoiceRow()); + jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never); + + await service.document("inv-1", "thermal"); + + expect(renderThermal).toHaveBeenCalledTimes(1); + expect(render).not.toHaveBeenCalled(); + }); }); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 5afa30128..49227efc9 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -18,6 +18,7 @@ import { Booking } from "../bookings/entities/booking.entity"; import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; +import { EimsInvoiceStatus } from "../eims/eims-registration.types"; import { FilesService } from "../files/files.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentService } from "../payment/payment.service"; @@ -25,6 +26,7 @@ import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; import { InvoiceDocumentModel, InvoiceDocumentService, + pngDataUrl, } from "./documents/invoice-document.service"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { Invoice, InvoicePayment } from "./entities/invoice.entity"; @@ -145,6 +147,18 @@ export interface GenerateInvoiceInput { status?: Freight.InvoiceStatus; } +/** MoR `DocumentDetails.Type` for a memo — see `EIMS_DOCUMENT_TYPES` in `eims-invoice.mapper.ts`. */ +export type MemoType = "CRE" | "DEB"; + +/** Everything needed to issue a credit or debit memo against an already-registered invoice. */ +export interface IssueMemoInput { + type: MemoType; + /** Why the memo was issued — required by MoR as `DocumentDetails.Reason`. */ + reason: string; + /** Omit to copy every line of the original verbatim (a full reversal/charge, the common case). */ + lines?: InvoiceLineInput[]; +} + /** Payload broadcast on `${source}.invoice.`. */ export interface InvoiceEventPayload { invoiceId: string; @@ -397,12 +411,20 @@ export class BillingService { // ── Documents (central PDF) ────────────────────────────────────────────────── - /** Sealed PDF invoice for any source, rendered by the shared document service. */ - async document(id: string): Promise<{ filename: string; buffer: Buffer }> { + /** + * Sealed PDF invoice for any source, rendered by the shared document service. `format` + * validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input + * boundary check, not a business rule. + */ + async document( + id: string, + format: "a4" | "thermal" = "a4", + ): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - return this.invoiceDocuments.render( - await this.toDocumentModel(invoice, "INVOICE"), - ); + const model = await this.toDocumentModel(invoice, "INVOICE"); + return format === "thermal" + ? this.invoiceDocuments.renderThermal(model) + : this.invoiceDocuments.render(model); } /** Sealed PDF receipt; available once any payment has been recorded. */ @@ -418,15 +440,6 @@ export class BillingService { ); } - /** - * `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the - * Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), - * not a payload we encode ourselves. Wrapped in a data URL, nothing more. - */ - private renderEimsQr(signedQr: string): string { - return `data:image/png;base64,${signedQr}`; - } - /** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */ private async bookingSummaryRows( invoice: Invoice, @@ -542,7 +555,7 @@ export class BillingService { currency: l.currency, })), totals, - qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null, + qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null, }; } @@ -709,11 +722,16 @@ export class BillingService { // ── Generation ─────────────────────────────────────────────────────────────── - /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ - private nextInvoiceNumber(mg: EntityManager): Promise { + /** + * `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. `code` + * defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent + * daily sequence (different prefix hashes to a different advisory lock, see + * `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers. + */ + private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise { return nextDailyInvoiceNumber(mg, { table: "freight.invoices", - code: "INV", + code, }); } @@ -736,9 +754,123 @@ export class BillingService { return manager ? run(manager) : this.dataSource.transaction(run); } + /** + * Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed + * DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`, + * `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice` + * unchanged: it has no side effects (no events, no notifications, no payment records — every + * event in this service fires from `runTransition` on a *transition*, not on create), so a memo + * is just an ordinary invoice with three extra columns set. + * + * `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId` + * (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by + * `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the + * newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own + * `id` is never a value those lookups are ever queried with, so this isolates a memo from all + * of them regardless of its status — no `type`-based exclusion needed anywhere else. + * + * A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so + * leaving it payable would only add a phantom receivable that no payment flow will ever close. + * A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary + * invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is + * findable and collectible through the normal invoice list/detail/payment tooling, safe from + * the CBE/booking-linked lookups above for the `sourceId` reason just given. + */ + async issueMemo( + originalId: string, + input: IssueMemoInput, + ): Promise { + const reason = input.reason?.trim(); + if (!reason) { + throw new BadRequestException("A memo requires a reason."); + } + + const original = await this.findById(originalId); + if (!original.eimsIrn) { + throw new BadRequestException({ + code: "EIMS_RELATED_INVOICE_NOT_REGISTERED", + message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`, + }); + } + if (original.eimsDocumentType && original.eimsDocumentType !== "INV") { + throw new BadRequestException( + `Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`, + ); + } + if (original.eimsStatus === EimsInvoiceStatus.Cancelled) { + throw new BadRequestException( + `Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`, + ); + } + + const sourceLines = input.lines?.length ? input.lines : original.lines; + const lines: InvoiceLineInput[] = sourceLines.map((l) => ({ + chargeType: l.chargeType, + description: l.description, + quantity: Number(l.quantity), + unitRate: Number(l.unitRate), + amount: Number(l.amount), + currency: l.currency, + metadata: l.metadata ?? null, + })); + + const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0)); + if (!(total > 0)) { + throw new BadRequestException("A memo must have a positive total."); + } + // Only a credit note is bounded by the original — it can only give back what was charged. A + // debit note is an additional charge, not a refund, so no such ceiling applies to it (do not + // assume the credit-note ceiling is correct for DEB). + if (input.type === "CRE" && total > Number(original.totalAmount)) { + throw new BadRequestException( + `Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`, + ); + } + + const code = input.type === "CRE" ? "CRE" : "DEB"; + const settled = input.type === "CRE"; + + return this.dataSource.transaction(async (mg) => { + const memo = await this.createInvoice( + { + source: original.source as Freight.InvoiceSource, + sourceId: original.id, + type: input.type === "CRE" ? "credit_note" : "debit_note", + companyId: original.companyId, + companyProfileId: original.companyProfileId, + shippingLineCompanyId: original.shippingLineCompanyId, + lines, + currency: original.currency, + subtotalAmount: total, + taxAmount: 0, + totalAmount: total, + ...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}), + }, + mg, + code, + ); + + const patch: Record = { + eimsDocumentType: input.type, + eimsReason: reason, + relatedInvoiceId: original.id, + ...(settled + ? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() } + : {}), + }; + await mg.update(Invoice, memo.id, patch); + + this.logger.log( + `Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`, + ); + return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] }; + }); + } + private async createInvoice( input: GenerateInvoiceInput, mg: EntityManager, + code = "INV", ): Promise { const currency = input.currency ?? "ETB"; const status = input.status ?? Freight.InvoiceStatus.Pending; @@ -786,7 +918,7 @@ export class BillingService { (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, ); - const invoiceNumber = await this.nextInvoiceNumber(mg); + const invoiceNumber = await this.nextInvoiceNumber(mg, code); const invoice = await mg.save( mg.create(Invoice, { diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts index bc5250888..ebdf51be1 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -44,3 +44,46 @@ describe("InvoiceDocumentService.buildHtml — EIMS QR", () => { ); }); }); + +describe("InvoiceDocumentService.buildThermalHtml", () => { + const service = new InvoiceDocumentService({} as never, {} as never, {} as never); + + it("renders no seal markup at all — dropped for thermal, not shrunk", () => { + const html = service.buildThermalHtml(model()); + expect(html).not.toContain('class="seal"'); + expect(html).not.toContain("seal-image"); + }); + + it("renders the QR image when qrImageUrl is set, centered rather than absolutely positioned", () => { + const html = service.buildThermalHtml(model({ qrImageUrl: "data:image/png;base64,QR" })); + expect(html).toContain('class="qr"'); + expect(html).toContain('src="data:image/png;base64,QR"'); + expect(html).not.toContain("position: absolute"); + }); + + it("wraps a long IRN summary value rather than truncating it", () => { + const irn = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0"; + const html = service.buildThermalHtml(model({ summary: [{ label: "EIMS IRN", value: irn }] })); + expect(html).toContain(irn); + expect(html).toContain("overflow-wrap: anywhere"); + }); + + it("renders a line item as stacked description + qty x rate = amount, not a table row", () => { + const html = service.buildThermalHtml( + model({ + lines: [{ description: "40ft container rail freight", quantity: 12, unitRate: 245683.95, amount: 2948207.4 }], + }), + ); + expect(html).not.toContain(" { + const html = service.buildThermalHtml(model()); + expect(html).not.toContain("width: 330px"); + expect(html).not.toContain("right: 160px"); + }); +}); 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..0f8e4ee71 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,36 @@ 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}`; + +// ── Shared HTML-builder helpers (buildHtml + buildThermalHtml) ────────────────────────────────── +// `buildFallbackPdf`'s own currency/money/date closures are a deliberately different, already- +// established convention (bare "ETB" vs "Birr (ETB)") for the vector renderer — not touched here. + +function esc(value: unknown): string { + return String(value ?? "-") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function money(amount: unknown, currency: string): string { + return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; +} + +function formatDate(value: unknown): string { + return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; +} + /** One billed line on the document (charge type / fee type agnostic). */ export interface InvoiceDocumentLine { description: string | null; @@ -120,6 +150,117 @@ export class InvoiceDocumentService { }; } + /** + * 80mm thermal invoice (ADD-P001) — physical page is the 80mm roll width; content stays within + * `THERMAL_MARGIN_MM` of each edge via `PdfRenderService`'s margin, not a narrower page, since + * thermal print mechanisms have a dead zone at the roll edge they can't reach either way. + * + * A genuinely different template from `buildHtml`, not a CSS variant of it: the A4 layout is + * absolutely-positioned and fixed-px (`.seal{right:28px}`, `.qr{right:160px}`, + * `.totals{width:330px}`), tuned for a 210mm page — none of it reflows at 72mm printable width. + * No seal here at all (a decorative wet-ink-style stamp is an A4/laser convention; no real POS + * thermal receipt carries one, and thermal heads render rotated circles badly) and line items + * are stacked (description, then `qty x rate = amount` below it) rather than a table — a real + * multi-column table leaves ~10-14 chars for description at this width, truncating almost every + * line, which stacking avoids entirely. No Chromium-less fallback — see `renderThermal`. + */ + async renderThermal(model: InvoiceDocumentModel): Promise<{ filename: string; buffer: Buffer }> { + const logoImageUrl = + model.logoImageUrl !== undefined ? model.logoImageUrl : await this.logoSettings.getLogoImageUrl(); + // Seal deliberately dropped — never fetched, so no stampSettings call either. + const resolvedModel: InvoiceDocumentModel = { ...model, logoImageUrl, stampImageUrl: null }; + + const html = this.buildThermalHtml(resolvedModel); + return { + filename: `${this.safeFilename(model.documentNumber)}-thermal.pdf`, + buffer: await this.pdf.htmlToPdfBuffer(html, { + label: `${model.title} thermal invoice`, + thermal: true, + // A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal + // printer output" — fail loudly instead; the caller has the A4 download to fall back to. + noFallback: true, + }), + }; + } + + buildThermalHtml(model: InvoiceDocumentModel): string { + const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`; + const logoInner = logoMarkup(model.logoImageUrl, "thermal-logo"); + + const summaryRows = model.summary + .map( + (row) => + `
${esc(row.label)}${esc(row.value)}
`, + ) + .join(""); + + const itemBlocks = model.lines + .map((item) => { + const currency = item.currency ?? model.currency; + return `
+
${esc(item.description)}
+
${esc(item.quantity ?? 0)} x ${esc(money(item.unitRate, currency))} = ${esc(money(item.amount, currency))}
+
`; + }) + .join(""); + + const totalRows = model.totals + .map( + (total) => + `
${esc(total.label)}${esc(money(total.amount, model.currency))}
`, + ) + .join(""); + + const qrMarkup = model.qrImageUrl + ? `
EIMS verification QR
Scan to verify (MoR EIMS)
` + : ""; + + return ` + + + + ${esc(heading)} + + + +
+ ${logoInner} +
Ethio-Djibouti Railway S.C.
+
${esc(heading)}
+
${esc(model.documentNumber)} · ${esc(formatDate(model.issuedAt))}
+
+ ${summaryRows} +
+ ${itemBlocks} +
+ ${totalRows} + ${qrMarkup} + +
+ +`; + } + /** * Vector-drawn styled invoice/receipt used when headless Chromium is * unavailable. Mirrors the HTML layout closely enough to pass as the same @@ -241,18 +382,7 @@ export class InvoiceDocumentService { } buildHtml(model: InvoiceDocumentModel): string { - const esc = (value: unknown) => - String(value ?? "-") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - const money = (amount: unknown, currency = model.currency) => - `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; - const date = (value: unknown) => - value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; - + const date = formatDate; const showCategory = Boolean(model.categoryHeader); const sealText = model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); @@ -283,7 +413,7 @@ export class InvoiceDocumentService { const totalRows = model.totals .map( (total) => - `
${esc(total.label)}${esc(money(total.amount))}
`, + `
${esc(total.label)}${esc(money(total.amount, model.currency))}
`, ) .join(""); diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts index e8c3de792..21676c160 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -15,15 +15,43 @@ const PDF_PRINT_STYLES = ` } `; +/** + * Physical roll width. Content stays within `THERMAL_MARGIN_MM` of each edge — every mainstream + * ESC/POS thermal head (Epson TM-T88, Star, Bixolon) has a dead zone near the edge of an 80mm roll + * it physically can't reach, so the page itself must stay 80mm (matching the roll the printer + * driver expects) with the safe area carved out by margin, not by shrinking the page. + */ +const THERMAL_PAGE_WIDTH_MM = 80; +const THERMAL_MARGIN_MM = 4; +/** Extra length past the measured content, so the cut isn't flush against the last line. */ +const THERMAL_FEED_MM = 6; +/** Guard against a runaway line-item list producing an absurd page. */ +const THERMAL_MAX_HEIGHT_MM = 1500; + export interface PdfRenderOptions { /** Label used in logs to identify the document kind. */ label?: string; /** Landscape A4 instead of the default portrait — wide tables need it. */ landscape?: boolean; + /** + * Render as an 80mm continuous thermal receipt instead of a fixed A4 page: content width is + * measured and the page height grows to fit it, rather than a fixed page with the format's + * `format: "A4"`. + */ + thermal?: boolean; + /** + * Refuse to degrade to a fallback PDF on failure — throw instead. For a thermal request, a + * generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal printer + * output" (it silently hands back a different document shape than what was asked for); the + * caller has an existing A4 download to point the user at instead. Ignored when `fallback` is + * also supplied — an explicit fallback always wins. + */ + noFallback?: boolean; /** * Degraded renderer used when Chromium is unavailable. Receives the * print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-` - * header). When omitted, a generic single-page fallback is produced. + * header). When omitted (and `noFallback` is not set), a generic single-page fallback is + * produced. */ fallback?: (preparedHtml: string) => Buffer; } @@ -54,17 +82,31 @@ export class PdfRenderService { const browser = await puppeteer.default.launch(launchOptions); try { const page = await browser.newPage(); - await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + const thermal = opts.thermal ?? false; + const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794; + await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 }); await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); await page.emulateMediaType("print"); await new Promise((resolve) => setTimeout(resolve, 250)); - const pdf = await page.pdf({ - format: "A4", - landscape: opts.landscape ?? false, - printBackground: true, - margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, - }); + const pdf = thermal + ? await page.pdf({ + width: `${THERMAL_PAGE_WIDTH_MM}mm`, + height: `${await this.thermalContentHeightMm(page)}mm`, + printBackground: true, + margin: { + top: `${THERMAL_MARGIN_MM}mm`, + bottom: `${THERMAL_MARGIN_MM + THERMAL_FEED_MM}mm`, + left: `${THERMAL_MARGIN_MM}mm`, + right: `${THERMAL_MARGIN_MM}mm`, + }, + }) + : await page.pdf({ + format: "A4", + landscape: opts.landscape ?? false, + printBackground: true, + margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, + }); const buffer = Buffer.from(pdf); if (!this.isValidPdf(buffer)) { @@ -79,6 +121,15 @@ export class PdfRenderService { } } catch (error) { this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`); + if (!opts.fallback && opts.noFallback) { + // A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal + // printer output" — it silently hands back a different document than what was asked for. + // Fail loudly instead; the caller already has a working A4 download to fall back to. + throw new InternalServerErrorException( + `${label} could not be generated — thermal rendering requires Chromium. ` + + "Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH, or download the A4 PDF instead.", + ); + } const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml); if (this.isValidPdf(fallback)) { this.logger.warn( @@ -92,6 +143,20 @@ export class PdfRenderService { } } + /** + * Thermal receipts are continuous-roll — there is no fixed page height. Measures the rendered + * content's actual height and adds feed clearance, so the PDF page is exactly as long as the + * receipt, not a fixed A4-length page with blank space at the bottom. + */ + private async thermalContentHeightMm(page: import("puppeteer").Page): Promise { + // String form, not a typed closure: this project's tsconfig has no `dom` lib, so `document` + // isn't a known global to type-check against — the string is evaluated in the page's own + // browser context regardless, same as the closure form would be. + const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number; + const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM; + return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm); + } + private injectPdfPrintStyles(html: string): string { if (html.includes("edr-pdf-print-fix")) return html; if (html.includes("")) { diff --git a/apps/edr-freight-api/src/modules/billing/dto/issue-memo.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/issue-memo.dto.ts new file mode 100644 index 000000000..c80657155 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/issue-memo.dto.ts @@ -0,0 +1,71 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsIn, + IsNumber, + IsObject, + IsOptional, + IsString, + Length, + ValidateNested, +} from "class-validator"; + +/** One line on a memo; omit the whole `lines` array on the parent DTO to copy the original's. */ +export class MemoLineDto { + @ApiProperty() + @IsString() + chargeType!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + quantity?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + unitRate?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + amount?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + metadata?: Record; +} + +/** `POST billing/invoices/:id/memo` body — see `BillingService.issueMemo`. */ +export class IssueMemoDto { + @ApiProperty({ enum: ["CRE", "DEB"], description: "MoR DocumentDetails.Type for the memo." }) + @IsIn(["CRE", "DEB"]) + type!: "CRE" | "DEB"; + + @ApiProperty({ description: "Why the memo was issued — MoR DocumentDetails.Reason." }) + @IsString() + @Length(1, 500) + reason!: string; + + @ApiPropertyOptional({ + type: [MemoLineDto], + description: "Omit to copy every line of the original invoice verbatim.", + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MemoLineDto) + lines?: MemoLineDto[]; +} diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts index 927e2efec..7406613fc 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -212,6 +212,59 @@ describe("toEimsInvoice", () => { expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/); }); + describe("debit/credit notes — confirmed by MoR support, same /v1/register endpoint", () => { + it("defaults DocumentDetails.Type to INV with no Reason field", () => { + const doc = toEimsInvoice(invoice(), seller, context()); + expect(doc.DocumentDetails.Type).toBe("INV"); + expect(doc.DocumentDetails).not.toHaveProperty("Reason"); + }); + + it("files a credit note with Type, Reason and RelatedDocument", () => { + const doc = toEimsInvoice( + invoice(), + seller, + context({ + documentType: "CRE", + reason: "Overbilled freight charge", + relatedDocument: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0", + }), + ); + expect(doc.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" }); + expect(doc.ReferenceDetails.RelatedDocument).toBe( + "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0", + ); + }); + + it("files a debit note the same way", () => { + const doc = toEimsInvoice( + invoice(), + seller, + context({ documentType: "DEB", reason: "Additional handling fee", relatedDocument: "IRN-1" }), + ); + expect(doc.DocumentDetails).toMatchObject({ Type: "DEB", Reason: "Additional handling fee" }); + }); + + it("throws when a credit/debit note has no reason", () => { + expect(() => + toEimsInvoice( + invoice(), + seller, + context({ documentType: "CRE", reason: null, relatedDocument: "IRN-1" }), + ), + ).toThrow(/needs a reason/); + }); + + it("throws when a credit/debit note has no relatedDocument", () => { + expect(() => + toEimsInvoice( + invoice(), + seller, + context({ documentType: "CRE", reason: "Overbilled", relatedDocument: null }), + ), + ).toThrow(/needs.*relatedDocument/); + }); + }); + it("throws when the lines do not sum to the invoice total", () => { expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow( /lines sum to 11000 but the invoice total is 9000/, diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index fb7f2f145..582e3d326 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -20,8 +20,15 @@ import { round2 } from "./invoice-settlement.util"; /** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */ const EIMS_VERSION = "1"; -/** The only `DocumentDetails.Type` observed in the supplied material. */ -const EIMS_DOCUMENT_TYPE = "INV"; +/** + * `DocumentDetails.Type`. `"INV"` is the only value observed in the collection; `"DEB"`/`"CRE"` + * (debit/credit note) were confirmed directly by MoR support — same `/v1/register` endpoint, no + * separate API. MoR's answer, verbatim: "the same endpoint used for registration should be used + * ... within the Document Detail object, you should specify DEB for a debit note, CRE for a + * credit note... add a Reason attribute under document detail object". + */ +export const EIMS_DOCUMENT_TYPES = ["INV", "DEB", "CRE"] as const; +export type EimsDocumentType = (typeof EIMS_DOCUMENT_TYPES)[number]; export interface EimsBuyerDetails { City: string | null; @@ -60,7 +67,9 @@ export interface EimsDocumentDetails { DocumentNumber: string; /** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */ Date: string; - Type: string; + Type: EimsDocumentType; + /** Only for DEB/CRE, per MoR support — why the debit/credit note was issued. Absent for INV. */ + Reason?: string; } export interface EimsInvoiceItem { @@ -212,7 +221,18 @@ export interface EimsMapperContext { unitDefault: string; incomeWithholdValue: number; transactionWithholdValue: number; - /** Null for an ordinary invoice; set only for a real related-document case. */ + /** + * `DocumentDetails.Type`. Defaults to `"INV"`. For `"DEB"`/`"CRE"` both `reason` and + * `relatedDocument` become required — confirmed directly by MoR support, not the collection. + */ + documentType?: EimsDocumentType; + /** Required when `documentType` is `"DEB"`/`"CRE"` — why the note was issued. Unused for INV. */ + reason?: string | null; + /** + * `ReferenceDetails.RelatedDocument`. Null for an ordinary invoice; required for a DEB/CRE — + * the original registered invoice's IRN, per MoR's own IRC-P06/P07 checklist ("credit memo + * from a registered invoice"). + */ relatedDocument?: string | null; /** MoR numeric country code for the buyer; our DB stores the country name. */ buyerCountryCode?: string | null; @@ -326,6 +346,26 @@ export function toEimsInvoice( ); } + const documentType = context.documentType ?? "INV"; + if (!EIMS_DOCUMENT_TYPES.includes(documentType)) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} has documentType "${documentType}", must be one of ${EIMS_DOCUMENT_TYPES.join(", ")}`, + ); + } + if (documentType !== "INV") { + if (!context.reason?.trim()) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs a reason`, + ); + } + if (!context.relatedDocument?.trim()) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} is a ${documentType} (debit/credit note) and needs ` + + "relatedDocument — the original registered invoice's IRN", + ); + } + } + const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt); if (Number.isNaN(issuedAt.getTime())) { throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`); @@ -430,7 +470,8 @@ export function toEimsInvoice( DocumentDetails: { DocumentNumber: context.documentNumber, Date: (context.formatDate ?? formatEimsDate)(issuedAt), - Type: EIMS_DOCUMENT_TYPE, + Type: documentType, + ...(documentType !== "INV" ? { Reason: context.reason! } : {}), }, ItemList, PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term }, diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 7d0ff34c4..43b6e3271 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -180,4 +180,27 @@ export class Invoice extends BaseEntity { @Column({ name: "eims_cancellation_remark", type: "text", nullable: true }) eimsCancellationRemark?: string | null; + + /** + * `DocumentDetails.Type` to file this invoice as — "INV" (default), "DEB" or "CRE". Confirmed + * by MoR support directly (not the collection): debit/credit notes go through this same + * `/v1/register` endpoint, distinguished only by `Type` + `Reason`, linked via + * `ReferenceDetails.RelatedDocument` to the original invoice's IRN. This module does not create + * debit/credit note invoices — that is a freight-workflow decision — it only files one + * correctly once these columns are set on an existing row. + */ + @Column({ name: "eims_document_type", type: "varchar", length: 8, default: "INV" }) + eimsDocumentType!: string; + + /** Required by MoR when `eimsDocumentType` is DEB/CRE — why the note was issued. */ + @Column({ name: "eims_reason", type: "text", nullable: true }) + eimsReason?: string | null; + + /** The original registered invoice this debit/credit note adjusts. Required for DEB/CRE. */ + @Column({ name: "related_invoice_id", type: "uuid", nullable: true }) + relatedInvoiceId?: string | null; + + @ManyToOne(() => Invoice) + @JoinColumn({ name: "related_invoice_id" }) + relatedInvoice?: Invoice | null; } diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts index 6246d5a89..9b3bb5183 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { DataSource } from "typeorm"; @@ -12,6 +13,9 @@ const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; /** * `query` is answered by shape: the first call is the system-state guard, the second is the * candidate lookup. Keeps the fake honest about the order the service actually asks in. + * + * `managerRow` backs `dataSource.manager.findOne`/`.update` — only exercised by the + * pre-reservation-rejection path (`failStalledCandidate`), so it defaults to the candidate itself. */ const build = ( opts: { @@ -19,6 +23,7 @@ const build = ( state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null }; candidate?: { id: string; invoiceNumber: string } | null; register?: jest.Mock; + managerRow?: { eimsStatus: EimsInvoiceStatus } | null; } = {}, ) => { const register = @@ -34,12 +39,17 @@ const build = ( return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []); }); + const managerUpdate = jest.fn().mockResolvedValue(undefined); + const managerFindOne = jest + .fn() + .mockResolvedValue(opts.managerRow === undefined ? { eimsStatus: EimsInvoiceStatus.NotSubmitted } : opts.managerRow); + const service = new EimsAutoSubmitService( - { query } as unknown as DataSource, + { query, manager: { findOne: managerFindOne, update: managerUpdate } } as unknown as DataSource, { get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService, { registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService, ); - return { service, register, query }; + return { service, register, query, managerUpdate, managerFindOne }; }; const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" }; @@ -121,6 +131,40 @@ describe("EimsAutoSubmitService.tick", () => { expect(register).toHaveBeenCalledTimes(1); }); + it("drains a pre-reservation rejection so the sweep advances, without touching the DB row's own reservation state", async () => { + const register = jest + .fn() + .mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" })); + const { service, managerFindOne, managerUpdate } = build({ candidate, register }); + + await expect(service.tick()).resolves.toBeUndefined(); + + expect(managerFindOne).toHaveBeenCalledTimes(1); + expect(managerUpdate).toHaveBeenCalledWith( + expect.anything(), + INVOICE_ID, + expect.objectContaining({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsLastError: expect.objectContaining({ message: "no related invoice" }), + }), + ); + }); + + it("leaves a row alone if it already moved past NOT_SUBMITTED by the time the rejection is handled", async () => { + const register = jest + .fn() + .mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" })); + const { service, managerUpdate } = build({ + candidate, + register, + managerRow: { eimsStatus: EimsInvoiceStatus.Submitting }, + }); + + await expect(service.tick()).resolves.toBeUndefined(); + + expect(managerUpdate).not.toHaveBeenCalled(); + }); + it("does not start a second tick while one is still filing", async () => { let release: () => void = () => {}; const register = jest.fn().mockImplementation( diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts index fb5a0d1f6..bad283817 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts @@ -1,12 +1,14 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Cron } from "@nestjs/schedule"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource } from "typeorm"; +import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js"; import { EimsConfig } from "../../config/eims.config"; +import { Invoice } from "../billing/entities/invoice.entity"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; -import { EimsInvoiceStatus } from "./eims-registration.types"; +import { EimsInvoiceError, EimsInvoiceStatus } from "./eims-registration.types"; /** * Files issued invoices with MoR EIMS on a timer. @@ -67,21 +69,62 @@ export class EimsAutoSubmitService { const candidate = await this.nextCandidate(); if (!candidate) return; - const view = await this.registration.registerInvoiceWithEims(candidate.id); - this.logger.log( - `EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` + - (view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""), - ); + try { + const view = await this.registration.registerInvoiceWithEims(candidate.id); + this.logger.log( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` + + (view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""), + ); + } catch (err) { + // Every other failure path inside registerInvoiceWithEims persists FAILED/UNKNOWN itself + // (settleFailure) before throwing. A BadRequestException is the one exception: it is only + // ever thrown *before* a reservation is taken (config assertion, DEB/CRE validation), so + // nothing is persisted — left alone, this candidate is picked again next tick forever, a + // permanent head-of-line block on every invoice behind it. Drain it instead. + if (err instanceof BadRequestException) { + await this.failStalledCandidate(candidate, err); + } else { + throw err; + } + } } catch (err) { // Never let a filing failure kill the job. The outcome is already persisted on the invoice - // (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the - // next tick at the guard above. + // (FAILED or UNKNOWN with the gateway's own message, or drained by failStalledCandidate + // above), and a blocked system number stops the next tick at the guard above. this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`); } finally { this.running = false; } } + /** + * Mark a pre-reservation rejection as FAILED so the sweep advances past it — but only if the + * invoice is still exactly where this tick left it. A reservation's own transactions + * (SUBMITTING/UNKNOWN, or a system-wide block) are authoritative; this must never clobber them, + * so the status is re-read fresh rather than trusted from the stale `candidate` row. + */ + private async failStalledCandidate( + candidate: { id: string; invoiceNumber: string }, + err: BadRequestException, + ): Promise { + const current = await this.dataSource.manager.findOne(Invoice, { where: { id: candidate.id } }); + if (current?.eimsStatus !== EimsInvoiceStatus.NotSubmitted) { + this.logger.warn( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation, but is ` + + `no longer NOT_SUBMITTED (${current?.eimsStatus ?? "not found"}) — leaving state untouched.`, + ); + return; + } + const lastError: EimsInvoiceError = { kind: "VALIDATION", message: err.message, at: new Date().toISOString() }; + await this.dataSource.manager.update(Invoice, candidate.id, { + eimsStatus: EimsInvoiceStatus.Failed, + eimsLastError: lastError, + } as QueryDeepPartialEntity); + this.logger.error( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation: ${err.message}`, + ); + } + /** Why filing is currently impossible for this system number, or null when it is free. */ private async systemBlockReason(): Promise { const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] = diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 8e7be0197..b77804eda 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -157,6 +157,12 @@ export interface EimsContextInput { session: EimsSessionContext; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; + /** `DocumentDetails.Type` — defaults to "INV" in the mapper when omitted. */ + documentType?: EimsMapperContext["documentType"]; + /** Required (by the mapper) when documentType is DEB/CRE. */ + reason?: string | null; + /** `ReferenceDetails.RelatedDocument` — the original invoice's IRN, required for DEB/CRE. */ + relatedDocument?: string | null; } export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext { @@ -206,5 +212,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E buyerIdType: invoice.buyerIdType, buyerIdNumber: invoice.buyerIdNumber, exchangeRate: input.exchangeRate ?? null, + documentType: input.documentType, + reason: input.reason ?? null, + relatedDocument: input.relatedDocument ?? null, }; } diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 8c2a33cfc..36c355bed 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -295,6 +295,58 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); }); + it("files a credit note with Type/Reason/RelatedDocument from the invoice row", async () => { + const original = invoiceRow({ + id: "original-invoice", + invoiceNumber: "INV-20260807-00001", + eimsIrn: IRN, + }); + const db = new FakeDb([ + invoiceRow({ + eimsDocumentType: "CRE", + eimsReason: "Overbilled freight charge", + relatedInvoice: original, + } as Partial), + ]); + const postSigned = jest.fn().mockResolvedValue(okResponse()); + + await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); + + const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; + expect(request.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" }); + expect(request.ReferenceDetails.RelatedDocument).toBe(IRN); + }); + + it("refuses a credit/debit note whose related invoice was never registered, before touching a counter", async () => { + const original = invoiceRow({ id: "original-invoice", eimsIrn: null }); + const db = new FakeDb([ + invoiceRow({ + eimsDocumentType: "DEB", + eimsReason: "Additional handling", + relatedInvoice: original, + } as Partial), + ]); + const postSigned = jest.fn(); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(postSigned).not.toHaveBeenCalled(); + expect(db.state).toMatchObject({ nextInvoiceCounter: 7 }); // unchanged — never reserved + }); + + it("refuses a credit/debit note with no related invoice set at all", async () => { + const db = new FakeDb([ + invoiceRow({ eimsDocumentType: "CRE", eimsReason: "x", relatedInvoice: null } as Partial), + ]); + const postSigned = jest.fn(); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(postSigned).not.toHaveBeenCalled(); + }); + it("takes SourceSystem from the token session, not from configuration", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockResolvedValue(okResponse()); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 317f4ec65..ab9f953c5 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -13,6 +13,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; import { + EimsDocumentType, EimsInvoiceRequest, EimsMapperLine, toEimsInvoice, @@ -96,6 +97,27 @@ export class EimsInvoiceRegistrationService { const invoice = await this.loadInvoiceForMapping(invoiceId); if (invoice.eimsIrn) return this.toView(invoice); + // Debit/credit notes (confirmed by MoR support: same endpoint, Type DEB/CRE + Reason, + // ReferenceDetails.RelatedDocument = the original's IRN) must fail here — before a counter is + // touched — if the original was never actually registered. + const documentType = (invoice.eimsDocumentType as EimsDocumentType | undefined) ?? "INV"; + let relatedDocument: string | null = null; + if (documentType !== "INV") { + if (!invoice.relatedInvoice) { + throw new BadRequestException({ + code: "EIMS_RELATED_INVOICE_REQUIRED", + message: `Invoice ${invoice.invoiceNumber} is a ${documentType} but has no related invoice set.`, + }); + } + if (!invoice.relatedInvoice.eimsIrn) { + throw new BadRequestException({ + code: "EIMS_RELATED_INVOICE_NOT_REGISTERED", + message: `Invoice ${invoice.invoiceNumber} is a ${documentType} against invoice ${invoice.relatedInvoice.invoiceNumber}, which was never registered with EIMS — nothing to reference.`, + }); + } + relatedDocument = invoice.relatedInvoice.eimsIrn; + } + // Authenticate before reserving: the source system comes from the token, and the state row is // keyed by it. A login failure here costs nothing — no counter has been consumed yet. const session = await this.auth.getSessionContext(); @@ -114,6 +136,9 @@ export class EimsInvoiceRegistrationService { invoiceCounter: reservation.invoiceCounter, previousIrn: reservation.previousIrn, session, + documentType, + reason: invoice.eimsReason, + relatedDocument, }), ); @@ -639,7 +664,7 @@ export class EimsInvoiceRegistrationService { ): Promise { const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { id: invoiceId }, - relations: { company: true, companyProfile: true }, + relations: { company: true, companyProfile: true, relatedInvoice: true }, }); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); 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: [ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index adbd22584..9e905df6d 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -569,6 +569,14 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:invoices:eims_receipt_register", "Register a sales or withholding receipt with MoR EIMS", ), + // Issuing a credit/debit memo is itself filing-equivalent — auto-submit picks it up like any + // other issued invoice — so it carries the same restricted grant as the eims_* actions above, + // not invoices:export. + perm( + "d2b00001-0001-4000-8000-00000000000a", + "edr_freight_app:invoices:memo_issue", + "Issue a credit or debit memo against a registered invoice", + ), // USD bookings are paid by bank transfer; Finance uploads the slip and settles // the invoice. Moves money state, so it is its own grant, not part of view. perm( @@ -1874,6 +1882,7 @@ export const FREIGHT_PERMS = { eimsResolve: "edr_freight_app:invoices:eims_resolve", eimsCancel: "edr_freight_app:invoices:eims_cancel", eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register", + memoIssue: "edr_freight_app:invoices:memo_issue", confirmOffline: "edr_freight_app:invoices:confirm_offline", }, firstMile: { @@ -2406,9 +2415,11 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, // Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel, - // eims_receipt_register. Invoices are filed with MoR by the workflow, not by a person, so - // filing is not a Finance job function — the endpoints exist for controlled testing and - // exceptional operations, and are assigned to named admins rather than a role preset. + // eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all + // (the cron sweep runs as the system); these are the *manual* exceptional-operations + // endpoints, and stay off the general Finance role. They are granted to the `chief` position + // instead — see below — the same maker–checker split already used for shipping-line credit + // mark-paid/cancel (Finance raises, chief decides). FREIGHT_PERMS.payments.view, FREIGHT_PERMS.bookings.wagonCancellationView, // Shipping-line credit ledger is a Finance surface: bill batches into @@ -2519,6 +2530,14 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.governmentExpedite, FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, + // Manual MoR EIMS actions and credit/debit memo issuance: kept off the general Finance role + // (see that preset's comment) and granted here instead — the chief is already the decision + // side of every other sensitive finance action (mark-paid/cancel approval below), and these + // are irreversible-at-MoR or receivable-creating in the same way. + FREIGHT_PERMS.invoices.eimsCancel, + FREIGHT_PERMS.invoices.eimsResolve, + FREIGHT_PERMS.invoices.eimsReceiptRegister, + FREIGHT_PERMS.invoices.memoIssue, FREIGHT_PERMS.payments.view, // Decision side of the credit-invoice two-step: finance raises // mark-paid/cancel requests, the chief approves or rejects them. diff --git a/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx b/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx index ba507513c..c668c0570 100644 --- a/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx @@ -1,11 +1,30 @@ -import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useState } from "react"; +import { + Alert, + Badge, + Button, + Card, + Group, + Modal, + NumberInput, + Radio, + Select, + SimpleGrid, + Stack, + Table, + Text, + Textarea, + TextInput, +} from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react"; +import { AlertTriangle, Ban, Download, FileText, RefreshCw, Send, ShieldCheck } from "lucide-react"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; -import type { EimsInvoiceStatus } from "@/types/eims"; +import { eimsService } from "@/services/eims.service"; +import { openPdfBlob } from "@/components/warehouses/pdf"; +import { EIMS_MODE_OF_PAYMENT, type EimsInvoiceStatus, type EimsModeOfPayment } from "@/types/eims"; import { useToast } from "@/hooks/use-toast"; const STATUS_COLOR: Record = { @@ -14,6 +33,7 @@ const STATUS_COLOR: Record = { REGISTERED: "edr-green", FAILED: "red", UNKNOWN: "orange", + CANCELLED: "gray", }; const STATUS_LABEL: Record = { @@ -22,6 +42,7 @@ const STATUS_LABEL: Record = { REGISTERED: "Filed", FAILED: "Rejected", UNKNOWN: "Unacknowledged", + CANCELLED: "Cancelled", }; function Field({ label, value }: { label: string; value?: string | number | null }) { @@ -37,6 +58,367 @@ function Field({ label, value }: { label: string; value?: string | number | null ); } +/** Reason codes from the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */ +const CANCEL_REASON_CODES = [ + { value: "1", label: "1 — Duplicate" }, + { value: "2", label: "2 — Buyer request" }, + { value: "3", label: "3 — Data entry error" }, + { value: "6", label: "6 — Calculation error" }, +]; + +function CancelModal({ + invoiceId, + opened, + onClose, +}: { + invoiceId: string; + opened: boolean; + onClose: () => void; +}) { + const { toast } = useToast(); + const [reasonCode, setReasonCode] = useState(null); + const [remark, setRemark] = useState(""); + + const cancel = useMutation( + api.invoices.eimsCancel.mutationOptions({ + onSuccess: () => { + onClose(); + toast({ title: "Cancelled with MoR" }); + }, + onError: (error) => toast({ title: "Could not cancel", description: error.message, variant: "destructive" }), + }), + ); + + return ( + + + + Cancels this invoice's registered document at MoR. Irreversible — an already-cancelled + invoice refuses a second attempt. + +