Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts
Hagernesh 7d8ab932c2 feat(eims): implement POST /v1/bulkCancel
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.
2026-08-17 17:49:33 +00:00

260 lines
11 KiB
TypeScript

import { BadRequestException, ConflictException } from "@nestjs/common";
import { DataSource } from "typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { EimsCancellationService } from "./eims-cancellation.service";
import { EimsClientService } from "./eims-client.service";
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> = {}): Invoice =>
({
id: INVOICE_ID,
invoiceNumber: "INV-20260807-00042",
companyId: "company-1",
eimsStatus: EimsInvoiceStatus.Registered,
eimsIrn: IRN,
eimsCancelledAt: null,
eimsCancellationDate: null,
eimsCancellationReasonCode: null,
eimsCancellationRemark: null,
...over,
}) as unknown as Invoice;
/** In-memory stand-in, same shape as the registration spec's FakeDb but with only what cancel needs. */
class FakeDb {
invoices = new Map<string, Invoice>();
companyContact: { phone: string | null; email: string | null } | null = null;
constructor(invoices: Invoice[]) {
for (const inv of invoices) this.invoices.set(inv.id, inv);
}
private manager = {
createQueryBuilder: (entity: unknown) => {
let id: string | undefined;
const builder = {
setLock: () => builder,
where: (_clause: string, params: Record<string, string>) => {
id = params.invoiceId;
return builder;
},
getOne: async () => (entity === Invoice ? (this.invoices.get(id!) ?? null) : null),
};
return builder;
},
findOne: async (_entity: unknown, options: { where: { id: string } }) =>
this.invoices.get(options.where.id) ?? null,
update: async (_entity: unknown, id: string, patch: Record<string, unknown>) => {
Object.assign(this.invoices.get(id)!, patch);
},
};
asDataSource(): DataSource {
return {
manager: this.manager,
query: async () => (this.companyContact ? [this.companyContact] : []),
transaction: async (body: (m: unknown) => Promise<unknown>) => body(this.manager),
} as unknown as DataSource;
}
}
const build = (db: FakeDb, postBearer: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined)) =>
new EimsCancellationService(
db.asDataSource(),
{ postBearer } as unknown as EimsClientService,
{ directSend } as unknown as NotificationsService,
);
describe("EimsCancellationService.cancelInvoiceWithEims", () => {
it("cancels a registered invoice and persists MoR's confirmation", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest
.fn()
.mockResolvedValue({ statusCode: 200, message: "Success", body: { cancellationDate: "Sun Dec 22 21:55:03 EAT 2024" } });
const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1", "Duplicate");
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "Duplicate" });
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
expect(view.eimsCancellationDate).toBe("Sun Dec 22 21:55:03 EAT 2024");
expect(view.eimsCancellationReasonCode).toBe("1");
expect(view.eimsCancellationRemark).toBe("Duplicate");
expect(db.invoices.get(INVOICE_ID)?.eimsCancelledAt).toBeInstanceOf(Date);
});
it("defaults Remark to an empty string, matching the collection's request shape", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1");
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "" });
});
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();
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
ConflictException,
);
expect(postBearer).not.toHaveBeenCalled();
});
it("refuses to cancel an invoice that was never registered", async () => {
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null })]);
const postBearer = jest.fn();
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
BadRequestException,
);
expect(postBearer).not.toHaveBeenCalled();
});
it("propagates a MoR rejection and leaves the invoice REGISTERED", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest
.fn()
.mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS cancel failed (406)", 406));
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
EimsApiException,
);
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Registered);
});
it("notifies the buyer company on success, without blocking the result", async () => {
const db = new FakeDb([invoiceRow()]);
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
const directSend = jest.fn().mockResolvedValue(undefined);
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
const view = await build(db, postBearer, directSend).cancelInvoiceWithEims(INVOICE_ID, "1");
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining("cancelled"));
});
it("does not fail cancellation when the buyer notification itself fails", async () => {
const db = new FakeDb([invoiceRow()]);
db.companyContact = { phone: "+251911000000", email: null };
const directSend = jest.fn().mockRejectedValue(new Error("sms provider down"));
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
const view = await build(db, postBearer, directSend).cancelInvoiceWithEims(INVOICE_ID, "1");
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);
});
});