From 7d8ab932c201d5fbd379f44d8e82eb42fbf89629 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 17:33:58 +0000 Subject: [PATCH 1/2] feat(eims): implement POST /v1/bulkCancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New endpoint: POST invoices/eims/bulk-cancel, body { items: [{invoiceId, reasonCode, remark?}] }. Same eimsCancel permission as single cancel — a batch-scale version of the same irreversible-at-MoR action, not a new capability. Same local-eligibility doctrine as single cancel: an already-cancelled or never-registered invoice is refused right here, no HTTP call, before it gets a seat in the batch. Only genuinely eligible invoices go into the one /v1/bulkCancel request; every outcome (local refusal or MoR's own per-IRN result) is reported back independently — one invoice failing never blocks the rest. MoR's bulk response mixes success and error shapes in the same array, disambiguated by Status (capital, error) vs status (lowercase, success) — matched back to our invoices by IRN. Notably the bulk success shape carries no cancellationDate at all, unlike single cancel. Left out of this pass: bulkRegister. It's async (returns only a conversationId immediately, results arrive via a webhook callback we don't have yet) and needs manual counter/previousIrn management per the collection's own docs — a materially different reservation model than today's single-invoice TX1/TX2 pattern. Scoping that is a separate, bigger piece of work. --- .../dto/bulk-cancel-eims-registration.dto.ts | 33 ++++++ .../eims/eims-cancellation.service.spec.ts | 104 +++++++++++++++++ .../modules/eims/eims-cancellation.service.ts | 106 +++++++++++++++++- .../modules/eims/eims-invoice.controller.ts | 12 ++ .../modules/eims/eims-registration.types.ts | 40 +++++++ 5 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/eims/dto/bulk-cancel-eims-registration.dto.ts 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; From e67ccbb9cd1aa81b511c85a6ce8bbb5789bad573 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 18:10:55 +0000 Subject: [PATCH 2/2] fix(eims): stop sending our internal fee-basis tag as MoR's ItemList Unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed live 2026-08-17 on INV-20260817-00008: MoR rejected the document with a SCHEMA ERROR on ItemList[0].Unit — 'PER_CONTAINER' (from the line's own metadata.unit) fails MoR's enum (LTR/MTR/101/PCS/ROL/MTS/PKG/SET/KLG), its 8-char max, and its ^[A-Za-z]{3,8}$ regex all at once. line.metadata.unit is our own fee-basis tag (PER_CONTAINER/PER_TON/ PER_ITEM — how a charge is computed) and was never a MoR unit of measure; the mapper was reusing the same field name for two unrelated concepts. Every line now sends the single configured EIMS_UNIT_DEFAULT instead of guessing a per-line value that doesn't exist in MoR's vocabulary. --- .../src/modules/billing/eims-invoice.mapper.spec.ts | 4 +++- .../src/modules/billing/eims-invoice.mapper.ts | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) 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 6d58a7bec..75d39a500 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 @@ -151,7 +151,9 @@ describe("toEimsInvoice", () => { // EimsLineTax.discount comment in eims-invoice.mapper.ts. Discount: 25, TotalLineAmount: 1050, - Unit: "CTR", + // Not "CTR" from the line's metadata.unit — that's our internal fee-basis tag, not a MoR + // unit of measure, and is never read for this field (see the mapper's own comment). + Unit: "PCS", }); expect(doc.ValueDetails).toEqual({ Discount: null, 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 b8755c709..c72e61c7a 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 @@ -463,7 +463,13 @@ export function toEimsInvoice( const PreTaxValue = round2(num(line.amount)); const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100); const ExciseTaxValue = round2(tax.exciseTaxValue); - const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault; + // `line.metadata.unit` is our own fee-basis tag (PER_CONTAINER/PER_TON/PER_ITEM — how a charge + // is computed, see the fee-rule docs), never a MoR unit of measure — sending it as-is here + // (confirmed live 2026-08-17: "PER_CONTAINER" fails Unit's enum, its 8-char max, and its regex + // all at once) is what a prior version of this mapper did by mistake. MoR's own enum + // (LTR/MTR/101/PCS/ROL/MTS/PKG/SET/KLG) has no freight-shipment concept at all, so every line + // uses the single configured default rather than guessing a per-line value that doesn't exist. + const unit = context.unitDefault; return { Discount: round2(tax.discount),