import { BadRequestException, ConflictException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { DataSource } from "typeorm"; import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper"; import { eimsInvoiceConfig } from "./eims-test-fixtures"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; import { EimsInvoiceStatus } from "./eims-registration.types"; const SYSTEM_NUMBER = "B0360154BA"; const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222"; const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0"; const config = (over: Partial = {}): EimsConfig => ({ enabled: true, baseUrl: "https://core.mor.gov.et", clientId: "cid", clientSecret: "secret", apiKey: "key", tin: "0000034558", systemNumber: SYSTEM_NUMBER, systemType: "SYS", privateKeyPath: "/dev/null", certificatePath: "/dev/null", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, invoice: eimsInvoiceConfig(over), }) as EimsConfig; const invoiceRow = (over: Partial = {}): Invoice => ({ id: INVOICE_ID, invoiceNumber: "INV-20260807-00042", currency: "ETB", issuedAt: new Date(2026, 7, 7, 9, 5, 3), totalAmount: "10000.00", eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null, eimsInvoiceCounter: null, eimsSubmittedAt: null, eimsAckDate: null, eimsLastError: null, company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858", phone: "0912345678", email: "buyer@abc.et", region: "13", zone: "SHA", woreda: "574", kebele: "03", houseNo: "NEW", country: "Ethiopia", }, ...over, }) as unknown as Invoice; const LINES = [ { chargeType: "RAIL_FREIGHT", description: "Addis to Djibouti", quantity: "1.00", unitRate: "10000.00", amount: "10000.00", }, ]; /** * In-memory stand-in for the two locked rows. `update` merges, `createQueryBuilder(...).getOne()` * returns the live object — enough to assert ordering, values and the reservation lifecycle without * a database. */ class FakeDb { invoices = new Map(); state: EimsSystemState | null = null; /** Runs before every transaction body, to simulate a concurrent writer. */ onTransaction: (() => void) | null = null; constructor(invoices: Invoice[], state?: Partial) { for (const inv of invoices) this.invoices.set(inv.id, inv); this.state = { id: "state-1", systemNumber: SYSTEM_NUMBER, nextInvoiceCounter: 7, previousIrn: null, inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null, ...state, } as EimsSystemState; } private manager = { createQueryBuilder: (entity: unknown) => { const isInvoice = entity === Invoice; let id: string | undefined; const builder = { setLock: () => builder, where: (_clause: string, params: Record) => { id = params.invoiceId ?? params.systemNumber; return builder; }, getOne: async () => (isInvoice ? (this.invoices.get(id!) ?? null) : this.state), }; 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) => { if (entity === Invoice) Object.assign(this.invoices.get(id)!, patch); else Object.assign(this.state!, patch); }, query: async () => [], getRepository: () => ({ findOne: async (options: { where: { id: string } }) => this.invoices.get(options.where.id) ?? null, }), }; asDataSource(): DataSource { return { manager: this.manager, getRepository: this.manager.getRepository, query: async () => LINES, transaction: async (body: (m: unknown) => Promise) => { this.onTransaction?.(); return body(this.manager); }, } as unknown as DataSource; } } /** The source system comes from the access token, so the service is handed a session, not config. */ const SESSION = { systemNumber: SYSTEM_NUMBER, systemType: "SYS" }; const build = ( db: FakeDb, postSigned: jest.Mock, cfg: EimsConfig = config(), postBearer: jest.Mock = jest.fn(), getSessionContext: jest.Mock = jest.fn().mockResolvedValue(SESSION), ) => new EimsInvoiceRegistrationService( db.asDataSource(), { get: () => cfg } as unknown as ConfigService, { postSigned, postBearer } as unknown as EimsClientService, { getSessionContext } as unknown as EimsAuthService, ); /** Document number the fixtures register under; `/v1/verify` must echo it back. */ const DOCUMENT_NUMBER = "INV-20260807-00042"; /** * `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase * `irn`. * * The fixture is deliberately *coherent* — same IRN on both sides. The supplied Postman collection * pairs a saved request and a saved response whose literal IRNs disagree, which is an artefact of * the mock rather than gateway behaviour; asserting against that inconsistency would encode the * mock's bug as a requirement. Resolution requires the returned `Irn` to match the one asked for, * and these fixtures exercise that honestly. */ const verifyResponse = (over: Record = {}) => ({ statusCode: 200, message: "SUCCESS", body: { Irn: IRN, TransactionType: "B2B", DocumentDetails: { Type: "INV", DocumentNumber: DOCUMENT_NUMBER, Date: "07-08-2026T09:05:03" }, Version: "1", ...over, }, }); const okResponse = (irn = IRN) => ({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } }); const apiError = (kind: string, status?: number) => new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status); describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { it("registers, persists the IRN and advances the chain", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockResolvedValue(okResponse()); const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); expect(postSigned).toHaveBeenCalledTimes(1); expect(postSigned.mock.calls[0][0]).toBe("/v1/register"); expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN, eimsInvoiceCounter: 7, eimsAckDate: "2026-08-07T09:05:03Z[Etc/UTC]", }); expect(db.state).toMatchObject({ previousIrn: IRN, nextInvoiceCounter: 8, inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null, }); }); it("sends the exact reserved counter and previous IRN to the mapper", async () => { const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" }); const postSigned = jest.fn().mockResolvedValue(okResponse()); await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; expect(request.SourceSystem.InvoiceCounter).toBe(42); expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN"); expect(request.DocumentDetails.DocumentNumber).toBe("INV-20260807-00042"); expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); }); it("takes SourceSystem from the token session, not from configuration", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockResolvedValue(okResponse()); // Config disagrees on purpose: only the session may reach the wire. const cfg = config(); (cfg as { systemNumber: string }).systemNumber = "CONFIG-ONLY"; (cfg as { systemType: string }).systemType = "MAN"; await build( db, postSigned, cfg, jest.fn(), jest.fn().mockResolvedValue({ systemNumber: "FROM-TOKEN", systemType: "POS" }), ).registerInvoiceWithEims(INVOICE_ID); const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; expect(request.SourceSystem.SystemNumber).toBe("FROM-TOKEN"); expect(request.SourceSystem.SystemType).toBe("POS"); }); it("does not consume a counter when authentication fails", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn(); const getSessionContext = jest.fn().mockRejectedValue(new Error("login failed")); await expect( build(db, postSigned, config(), jest.fn(), getSessionContext).registerInvoiceWithEims( INVOICE_ID, ), ).rejects.toThrow(/login failed/); expect(postSigned).not.toHaveBeenCalled(); expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null }); expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted); }); it("is idempotent — an invoice with an IRN never reaches EIMS", async () => { const db = new FakeDb([ invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }), ]); const postSigned = jest.fn(); const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); expect(postSigned).not.toHaveBeenCalled(); expect(view.eimsIrn).toBe(IRN); }); it("lets only one of two concurrent calls reach EIMS", async () => { const db = new FakeDb([invoiceRow()]); let resolvePost: (v: unknown) => void = () => {}; const postSigned = jest .fn() .mockImplementation(() => new Promise((resolve) => (resolvePost = resolve))); const service = build(db, postSigned); const first = service.registerInvoiceWithEims(INVOICE_ID); // Let the first reservation commit and its HTTP call start; it is now parked on `resolvePost`. await new Promise((resolve) => setImmediate(resolve)); expect(postSigned).toHaveBeenCalledTimes(1); const second = service.registerInvoiceWithEims(INVOICE_ID); await expect(second).rejects.toBeInstanceOf(ConflictException); resolvePost(okResponse()); await first; expect(postSigned).toHaveBeenCalledTimes(1); }); it("blocks a different invoice while a submission is in flight (survives a restart)", async () => { // A committed reservation left behind by a dead process. const db = new FakeDb( [ invoiceRow({ eimsStatus: EimsInvoiceStatus.Submitting, eimsInvoiceCounter: 7 }), invoiceRow({ id: OTHER_INVOICE_ID, invoiceNumber: "INV-20260807-00043" }), ], { inFlightInvoiceId: INVOICE_ID, inFlightCounter: 7, nextInvoiceCounter: 8 }, ); const postSigned = jest.fn(); await expect( build(db, postSigned).registerInvoiceWithEims(OTHER_INVOICE_ID), ).rejects.toThrow(/already in flight/); expect(postSigned).not.toHaveBeenCalled(); }); it("fails locally on incomplete tax configuration, with zero HTTP calls", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn(); await expect( build(db, postSigned, config({ taxCode: "", taxRatePercent: null })).registerInvoiceWithEims( INVOICE_ID, ), ).rejects.toBeInstanceOf(BadRequestException); expect(postSigned).not.toHaveBeenCalled(); expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted); expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null }); }); it.each([ ["SCHEMA_VALIDATION", 400], ["RULE_VALIDATION", 406], ])("marks %s (%i) FAILED and clears the global block", async (kind, status) => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockRejectedValue(apiError(kind, status)); await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( EimsApiException, ); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null, }); expect(db.state).toMatchObject({ inFlightInvoiceId: null, blockedReason: null, previousIrn: null, nextInvoiceCounter: 8, // consumed: the attempt reached the gateway }); }); it("treats a success response with no IRN as a failed registration", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } }); await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow( /returned no IRN/, ); expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed); expect(db.state).toMatchObject({ inFlightInvoiceId: null, blockedReason: null }); }); it("marks a timeout UNKNOWN and keeps the system blocked", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT")); await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( EimsApiException, ); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Unknown, eimsIrn: null, }); expect(db.state!.inFlightInvoiceId).toBe(INVOICE_ID); expect(db.state!.blockedReason).toMatch(/never acknowledged/); expect(db.state!.previousIrn).toBeNull(); }); it("an UNKNOWN result blocks a different invoice too", async () => { const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]); const postSigned = jest.fn().mockRejectedValueOnce(apiError("TIMEOUT")); const service = build(db, postSigned); await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( EimsApiException, ); await expect(service.registerInvoiceWithEims(OTHER_INVOICE_ID)).rejects.toThrow( /registration is blocked/, ); expect(postSigned).toHaveBeenCalledTimes(1); }); it("never reuses a counter once an attempt has begun", async () => { const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]); const postSigned = jest .fn() .mockRejectedValueOnce(apiError("RULE_VALIDATION", 406)) .mockResolvedValueOnce(okResponse()); const service = build(db, postSigned); await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( EimsApiException, ); await service.registerInvoiceWithEims(OTHER_INVOICE_ID); expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7); expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8); }); }); describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { it("verifies the stored IRN over the unsigned bearer transport", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); const postSigned = jest.fn(); const postBearer = jest.fn().mockResolvedValue(verifyResponse()); const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims( INVOICE_ID, ); // Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched. expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); expect(postSigned).not.toHaveBeenCalled(); expect(result.body).toMatchObject({ Irn: IRN }); }); it("rejects a 200 that carries no Irn", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); await expect( build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), ).rejects.toThrow(/returned no Irn/); }); it("refuses to verify an invoice with no IRN", async () => { const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]); const postBearer = jest.fn(); await expect( build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), ).rejects.toThrow(/no EIMS IRN to verify/); expect(postBearer).not.toHaveBeenCalled(); }); }); describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { const blocked = () => new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], { inFlightInvoiceId: INVOICE_ID, inFlightCounter: 7, nextInvoiceCounter: 8, blockedReason: "never acknowledged", }); it("records a confirmed IRN, resumes the chain and clears the block", async () => { const db = blocked(); const postBearer = jest.fn().mockResolvedValue(verifyResponse()); const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( INVOICE_ID, { irn: IRN }, ); // The IRN is confirmed at the gateway before it is ever written. expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN }); expect(db.state).toMatchObject({ previousIrn: IRN, inFlightInvoiceId: null, blockedReason: null, }); }); it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => { const db = blocked(); const postBearer = jest .fn() .mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" })); await expect( build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/answered the lookup for IRN/); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Unknown, eimsIrn: null, }); expect(db.state).toMatchObject({ inFlightInvoiceId: INVOICE_ID, blockedReason: "never acknowledged", previousIrn: null, }); }); it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => { const db = blocked(); const postBearer = jest.fn().mockResolvedValue( verifyResponse({ DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" }, }), ); await expect( build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/not INV-20260807-00042/); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Unknown, eimsIrn: null, }); expect(db.state).toMatchObject({ inFlightInvoiceId: INVOICE_ID, blockedReason: "never acknowledged", previousIrn: null, }); }); it("refuses an IRN the gateway does not acknowledge at all", async () => { const db = blocked(); const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} }); await expect( build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/returned no Irn/); expect(db.state!.blockedReason).toBe("never acknowledged"); }); it("discards the attempt, leaving the chain where it was", async () => { const db = blocked(); const postBearer = jest.fn(); const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( INVOICE_ID, { discard: true }, ); expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null }); expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm expect(db.state).toMatchObject({ previousIrn: null, inFlightInvoiceId: null, blockedReason: null, }); }); it("refuses to resolve an invoice that is not the in-flight one", async () => { const db = blocked(); db.invoices.set(OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID })); const postBearer = jest.fn().mockResolvedValue(verifyResponse()); await expect( build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, { irn: IRN, }), ).rejects.toThrow(/in-flight EIMS submission is invoice/); }); it("requires either an IRN or an explicit discard", async () => { await expect( build(blocked(), jest.fn()).resolveEimsRegistration(INVOICE_ID, {}), ).rejects.toBeInstanceOf(BadRequestException); }); });