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