diff --git a/apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts b/apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts new file mode 100644 index 000000000..c4782782c --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { ArrayMinSize, IsArray, IsOptional, IsString, IsUUID, Length, ValidateNested } from "class-validator"; + +export class BulkCancelEimsItemDto { + @ApiProperty({ description: "Invoice ID to cancel." }) + @IsUUID() + invoiceId!: string; + + @ApiProperty({ + description: 'Numeric reason code, e.g. "1" (Duplicate), "6" (Calculation Error).', + example: "1", + }) + @IsString() + @Length(1, 8) + reasonCode!: string; + + @ApiPropertyOptional({ description: "Free-text cancellation note.", example: "Duplicate submission" }) + @IsOptional() + @IsString() + @Length(0, 500) + remark?: string; +} + +/** `POST invoices/eims/bulk-cancel` body — see `EimsCancellationService.cancelBulkWithEims`. */ +export class BulkCancelEimsRegistrationDto { + @ApiProperty({ type: [BulkCancelEimsItemDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => BulkCancelEimsItemDto) + items!: BulkCancelEimsItemDto[]; +} 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 6bd9b04b3..b9fdf62df 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 @@ -9,7 +9,9 @@ import { EimsApiException } from "./eims.errors"; import { EimsInvoiceStatus } from "./eims-registration.types"; const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; +const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222"; const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0"; +const OTHER_IRN = "0af579eaef6f1e2d39fa77bd21cf8ecc64e26869275ae1c04eaa9ffea78b6c06"; const invoiceRow = (over: Partial = {}): Invoice => ({ @@ -153,3 +155,105 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => { expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); }); }); + +describe("EimsCancellationService.cancelBulkWithEims", () => { + it("cancels every eligible invoice in one call, matching results back by IRN", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]); + const postBearer = jest.fn().mockResolvedValue({ + statusCode: 200, + body: [ + { id: 1, tin: "t", status: "C", mode: "bulk", Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" }, + { id: 2, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" }, + ], + }); + + const results = await build(db, postBearer).cancelBulkWithEims([ + { invoiceId: INVOICE_ID, reasonCode: "1" }, + { invoiceId: OTHER_INVOICE_ID, reasonCode: "6", remark: "x" }, + ]); + + expect(postBearer).toHaveBeenCalledWith("/v1/bulkCancel", [ + { Irn: IRN, ReasonCode: "1", Remark: "" }, + { Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" }, + ]); + expect(results).toEqual([ + { invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") }, + { invoiceId: OTHER_INVOICE_ID, success: true, message: expect.stringContaining("cancelled") }, + ]); + expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); + expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); + // Bulk success carries no cancellationDate at all, unlike single cancel. + expect(db.invoices.get(INVOICE_ID)?.eimsCancellationDate).toBeNull(); + }); + + it("refuses an already-cancelled or never-registered invoice locally — never sent to MoR", async () => { + const db = new FakeDb([ + invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled }), + invoiceRow({ id: OTHER_INVOICE_ID, eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null }), + ]); + const postBearer = jest.fn(); + + const results = await build(db, postBearer).cancelBulkWithEims([ + { invoiceId: INVOICE_ID, reasonCode: "1" }, + { invoiceId: OTHER_INVOICE_ID, reasonCode: "1" }, + ]); + + expect(postBearer).not.toHaveBeenCalled(); + expect(results).toEqual([ + { invoiceId: INVOICE_ID, success: false, message: expect.stringContaining("already cancelled") }, + { invoiceId: OTHER_INVOICE_ID, success: false, message: expect.stringContaining("never registered") }, + ]); + }); + + it("a mix of MoR success and rejection only updates the succeeding invoice", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]); + const postBearer = jest.fn().mockResolvedValue({ + statusCode: 200, + body: [ + { id: 1, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" }, + { Status: "Processing_Error", msg: "IRN already Canceled.", Irn: OTHER_IRN }, + ], + }); + + const results = await build(db, postBearer).cancelBulkWithEims([ + { invoiceId: INVOICE_ID, reasonCode: "1" }, + { invoiceId: OTHER_INVOICE_ID, reasonCode: "1" }, + ]); + + expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); + expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Registered); + expect(results).toEqual([ + { invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") }, + { invoiceId: OTHER_INVOICE_ID, success: false, message: "IRN already Canceled." }, + ]); + }); + + it("makes no HTTP call at all when every item fails the local eligibility check", async () => { + const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]); + const postBearer = jest.fn(); + + await build(db, postBearer).cancelBulkWithEims([{ invoiceId: INVOICE_ID, reasonCode: "1" }]); + + expect(postBearer).not.toHaveBeenCalled(); + }); + + it("notifies the buyer only for invoices that actually got cancelled", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]); + db.companyContact = { phone: "+251911000000", email: null }; + const directSend = jest.fn().mockResolvedValue(undefined); + const postBearer = jest.fn().mockResolvedValue({ + statusCode: 200, + body: [ + { status: "C", Irn: IRN }, + { Status: "Processing_Error", msg: "boom", Irn: OTHER_IRN }, + ], + }); + + await build(db, postBearer, directSend).cancelBulkWithEims([ + { invoiceId: INVOICE_ID, reasonCode: "1" }, + { invoiceId: OTHER_INVOICE_ID, reasonCode: "1" }, + ]); + + expect(directSend).toHaveBeenCalledTimes(1); + }); +}); 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 9aa525ae3..7f8605c5f 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 @@ -6,7 +6,15 @@ import { Invoice } from "../billing/entities/invoice.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { sendCompanyChannels } from "../notifications/notify-company.util"; import { EimsClientService } from "./eims-client.service"; -import { EimsCancelRequest, EimsCancelResponse, EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types"; +import { + EimsBulkCancelItemResult, + EimsBulkCancelRequest, + EimsBulkCancelResponse, + EimsCancelRequest, + EimsCancelResponse, + EimsInvoiceStatus, + EimsInvoiceStatusView, +} from "./eims-registration.types"; import { toEimsInvoiceStatusView } from "./eims-invoice-view.util"; /** @@ -92,6 +100,102 @@ export class EimsCancellationService { return this.getEimsCancellationStatus(invoiceId); } + /** + * `POST /v1/bulkCancel` — one MoR call for every eligible invoice in `items`, matching the + * collection's own shape (an array in, an array of mixed success/error results back). + * + * Same local-eligibility doctrine as `cancelInvoiceWithEims`, applied per item before anything + * goes to MoR: an already-cancelled or never-registered invoice is refused right here (no HTTP + * call, no seat in the batch) rather than sent and rejected remotely. Only genuinely eligible + * invoices are batched into the one `/v1/bulkCancel` request; everything else is reported back + * immediately. + * + * ponytail: the eligibility pass is per-invoice transactions, not one covering the whole batch — + * same reasoning as the single-cancel path (cancel is idempotent at MoR, so a lock held across + * every item for the whole call isn't needed for correctness, only for avoiding a wasted call on + * an item that's already ineligible). + */ + async cancelBulkWithEims( + items: Array<{ invoiceId: string; reasonCode: string; remark?: string }>, + ): Promise { + const results = new Map(); + const eligible: Array<{ invoice: Invoice; reasonCode: string; remark?: string }> = []; + + for (const item of items) { + try { + const invoice = await this.dataSource.transaction(async (manager) => { + const inv = await this.lockInvoice(manager, item.invoiceId); + if (inv.eimsStatus === EimsInvoiceStatus.Cancelled) { + throw new ConflictException( + `Invoice ${inv.invoiceNumber} was already cancelled with EIMS${inv.eimsCancellationDate ? ` (${inv.eimsCancellationDate})` : ""}.`, + ); + } + if (!inv.eimsIrn) { + throw new BadRequestException( + `Invoice ${inv.invoiceNumber} was never registered with EIMS — nothing to cancel.`, + ); + } + return inv; + }); + eligible.push({ invoice, reasonCode: item.reasonCode, remark: item.remark }); + } catch (err) { + results.set(item.invoiceId, { + invoiceId: item.invoiceId, + success: false, + message: (err as Error).message, + }); + } + } + + if (eligible.length > 0) { + const request: EimsBulkCancelRequest = eligible.map((e) => ({ + Irn: e.invoice.eimsIrn!, + ReasonCode: e.reasonCode, + Remark: e.remark ?? "", + })); + // Outside any transaction — no DB lock held across the wire, same as single cancel. + const response = await this.client.postBearer( + "/v1/bulkCancel", + request, + ); + const byIrn = new Map((response?.body ?? []).map((entry) => [entry.Irn, entry])); + + for (const { invoice, reasonCode, remark } of eligible) { + const entry = byIrn.get(invoice.eimsIrn!); + const failed = !entry || "Status" in entry; + if (failed) { + const message = entry && "msg" in entry ? entry.msg : "EIMS bulk cancel returned no result for this invoice."; + this.logger.warn(`Invoice ${invoice.invoiceNumber} bulk cancel failed: ${message}`); + results.set(invoice.id, { invoiceId: invoice.id, success: false, message }); + continue; + } + + await this.dataSource.transaction(async (manager) => { + const fresh = await this.lockInvoice(manager, invoice.id); + // Re-checked under lock: a concurrent call may have already recorded this cancellation. + if (fresh.eimsStatus === EimsInvoiceStatus.Cancelled) return; + await manager.update(Invoice, invoice.id, { + eimsStatus: EimsInvoiceStatus.Cancelled, + eimsCancelledAt: new Date(), + // The bulk success shape carries no cancellationDate at all, unlike single cancel. + eimsCancellationDate: null, + eimsCancellationReasonCode: reasonCode, + eimsCancellationRemark: remark ?? null, + }); + }); + this.logger.log(`Invoice ${invoice.invoiceNumber} cancelled with EIMS via bulk (IRN ${invoice.eimsIrn})`); + await this.notifyBuyer(invoice); + results.set(invoice.id, { + invoiceId: invoice.id, + success: true, + message: `Invoice ${invoice.invoiceNumber} cancelled with EIMS.`, + }); + } + } + + return items.map((item) => results.get(item.invoiceId)!); + } + async getEimsCancellationStatus(invoiceId: string): Promise { const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } }); 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 7d417551f..11eb214dc 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 @@ -5,6 +5,7 @@ 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 { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto"; import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; @@ -89,6 +90,17 @@ export class EimsInvoiceController { return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark); } + @Post("eims/bulk-cancel") + @BookingStaff(FREIGHT_PERMS.invoices.eimsCancel) + @ApiOperation({ + summary: + "Cancel multiple invoices' registered EIMS documents in one call. Each invoice's outcome is " + + "reported independently — one failure never blocks the rest.", + }) + bulkCancel(@Body() dto: BulkCancelEimsRegistrationDto) { + return this.cancellation.cancelBulkWithEims(dto.items); + } + @Post(":id/eims/receipt/sales") @BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister) @ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" }) diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts index 8094d0e74..60941825c 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -89,6 +89,46 @@ export interface EimsCancelResponse { body?: EimsCancelResponseBody; } +/** `POST /v1/bulkCancel` — an array of the same `Irn`/`ReasonCode`/`Remark` shape as single cancel. */ +export type EimsBulkCancelRequest = EimsCancelRequest[]; + +/** + * One element of a `/v1/bulkCancel` response array — MoR mixes success and error shapes in the same + * array, one entry per submitted IRN, disambiguated by `Status` (capital, error) vs `status` + * (lowercase, success — always `"C"`). Unlike single cancel, a bulk success carries no + * `cancellationDate` at all. + */ +export interface EimsBulkCancelSuccessItem { + id?: number; + tin?: string; + status: string; + mode?: string; + Irn: string; + ReasonCode?: string; + Remark?: string; +} + +export interface EimsBulkCancelErrorItem { + Status: string; + msg: string; + Irn: string; +} + +export type EimsBulkCancelResponseItem = EimsBulkCancelSuccessItem | EimsBulkCancelErrorItem; + +export interface EimsBulkCancelResponse { + statusCode?: number; + message?: string; + body?: EimsBulkCancelResponseItem[]; +} + +/** One invoice's outcome from `cancelBulkWithEims` — local eligibility failure or MoR's own result. */ +export interface EimsBulkCancelItemResult { + invoiceId: string; + success: boolean; + message: string; +} + /** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */ export interface EimsInvoiceError { kind: string;