From e66e622ededffd804fe61f840824a914309ee696 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 13 Aug 2026 09:52:43 +0000 Subject: [PATCH] eims cancellation --- .../eims/eims-cancellation.service.spec.ts | 14 ++++++++------ .../modules/eims/eims-cancellation.service.ts | 17 ++++++++++++----- .../src/modules/eims/eims-invoice.controller.ts | 2 +- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts index ab5ea2548..6bd9b04b3 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from "@nestjs/common"; +import { BadRequestException, ConflictException } from "@nestjs/common"; import { DataSource } from "typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; @@ -96,14 +96,16 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => { expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "" }); }); - it("is idempotent — an already-cancelled invoice returns unchanged, no HTTP call", async () => { - const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]); + it("refuses re-cancelling an already-cancelled invoice, per IRC-N010 — no silent no-op", async () => { + const db = new FakeDb([ + invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled, eimsCancellationDate: "Sun Dec 22 2024" }), + ]); const postBearer = jest.fn(); - const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1"); - + await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf( + ConflictException, + ); expect(postBearer).not.toHaveBeenCalled(); - expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); }); it("refuses to cancel an invoice that was never registered", async () => { diff --git a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts index 83ff16704..73c3e87f4 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource, EntityManager } from "typeorm"; @@ -38,8 +38,11 @@ export class EimsCancellationService { ) {} /** - * Idempotent: an already-cancelled invoice returns unchanged, no HTTP call. Refuses an invoice - * that was never registered — there is no IRN to cancel. + * Refuses an already-cancelled invoice with a 409, rather than a silent no-op — IRC-N010 in + * MoR's Master Compliance Checklist requires "an appropriate error or rejection message" for a + * repeat cancellation, not a quiet success. No HTTP call either way: this is a local check, not + * a retry against MoR. Also refuses an invoice that was never registered — there is no IRN to + * cancel. */ async cancelInvoiceWithEims( invoiceId: string, @@ -48,7 +51,12 @@ export class EimsCancellationService { ): Promise { const eligible = await this.dataSource.transaction(async (manager) => { const invoice = await this.lockInvoice(manager, invoiceId); - if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return null; + if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) { + throw new ConflictException({ + code: "EIMS_ALREADY_CANCELLED", + message: `Invoice ${invoice.invoiceNumber} was already cancelled with EIMS${invoice.eimsCancellationDate ? ` (${invoice.eimsCancellationDate})` : ""}.`, + }); + } if (!invoice.eimsIrn) { throw new BadRequestException({ code: "EIMS_NOT_REGISTERED", @@ -57,7 +65,6 @@ export class EimsCancellationService { } return invoice; }); - if (!eligible) return this.getEimsCancellationStatus(invoiceId); const request: EimsCancelRequest = { Irn: eligible.eimsIrn!, ReasonCode: reasonCode, Remark: remark ?? "" }; // Outside any transaction — no DB lock is held across the wire. 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 5db756329..c02c7870e 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 @@ -81,7 +81,7 @@ export class EimsInvoiceController { @BookingStaff(FREIGHT_PERMS.invoices.eimsCancel) @ApiOperation({ summary: - "Cancel the invoice's registered EIMS document. Idempotent — an already-cancelled invoice is returned unchanged.", + "Cancel the invoice's registered EIMS document. Refuses (409) an already-cancelled invoice rather than a silent no-op — see IRC-N010.", }) cancel(@Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelEimsRegistrationDto) { return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);