eims cancellation

This commit is contained in:
Hagernesh
2026-08-13 09:52:43 +00:00
parent 89dba01cd7
commit e66e622ede
3 changed files with 21 additions and 12 deletions

View File

@@ -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 () => {

View File

@@ -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<EimsInvoiceStatusView> {
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.

View File

@@ -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);