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 { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; import { NotificationsService } from "../notifications/notifications.service"; 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; /** `sendCompanyChannels`'s contact lookup, when a test needs it non-empty. */ companyContact: { phone: string | null; email: string | null } | 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, nextDocumentNumber: 5, previousIrn: null, inFlightInvoiceId: null, inFlightCounter: null, inFlightDocumentNumber: 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 (sql: string) => { if (sql.includes("eims_system_state")) { return [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }]; } if (sql.includes("freight.companies")) return this.companyContact ? [this.companyContact] : []; return 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 | undefined = undefined, notify: jest.Mock = jest.fn().mockResolvedValue(undefined), directSend: jest.Mock = jest.fn().mockResolvedValue(undefined), ) => new EimsInvoiceRegistrationService( db.asDataSource(), { get: () => cfg } as unknown as ConfigService, { postSigned, postBearer } as unknown as EimsClientService, { getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION), } as unknown as EimsAuthService, { notify } as unknown as NotificationInboxService, { directSend } as unknown as NotificationsService, ); /** * Document number the fixtures register under; `/v1/verify` must echo it back. * * A plain integer, not our `invoiceNumber`: MoR validates the field against * `^(0|[1-9][0-9]{0,8})$`. It is allocated from `nextDocumentNumber` above. */ const DOCUMENT_NUMBER = "5"; /** * `/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, over: Record = {}) => ({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]", ...over }, }); 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("persists signedQR alongside the IRN", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockResolvedValue(okResponse(IRN, { signedQR: "signed-qr-payload" })); const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); expect(view.eimsSignedQr).toBe("signed-qr-payload"); expect(db.invoices.get(INVOICE_ID)?.eimsSignedQr).toBe("signed-qr-payload"); }); it("leaves eimsSignedQr null when the gateway does not return one", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockResolvedValue(okResponse()); const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); expect(view.eimsSignedQr).toBeNull(); }); it("notifies the buyer company on a successful registration, without blocking the result", async () => { const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial)]); db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" }; const directSend = jest.fn().mockResolvedValue(undefined); const postSigned = jest.fn().mockResolvedValue(okResponse()); const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend) .registerInvoiceWithEims(INVOICE_ID); expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered); expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining(IRN)); expect(directSend).toHaveBeenCalledWith("email", "buyer@abc.et", expect.stringContaining(IRN)); }); it("does not fail registration when the buyer notification itself fails", async () => { const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial)]); db.companyContact = { phone: "+251911000000", email: null }; const directSend = jest.fn().mockRejectedValue(new Error("sms provider down")); const postSigned = jest.fn().mockResolvedValue(okResponse()); const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend) .registerInvoiceWithEims(INVOICE_ID); expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered); }); 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(DOCUMENT_NUMBER); expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); }); it("files a credit note with Type/Reason/RelatedDocument from the invoice row", async () => { const original = invoiceRow({ id: "original-invoice", invoiceNumber: "INV-20260807-00001", eimsIrn: IRN, }); const db = new FakeDb([ invoiceRow({ eimsDocumentType: "CRE", eimsReason: "Overbilled freight charge", relatedInvoice: original, } as Partial), ]); const postSigned = jest.fn().mockResolvedValue(okResponse()); await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; expect(request.DocumentDetails).toMatchObject({ Type: "CRE", Reason: "Overbilled freight charge" }); expect(request.ReferenceDetails.RelatedDocument).toBe(IRN); }); it("refuses a credit/debit note whose related invoice was never registered, before touching a counter", async () => { const original = invoiceRow({ id: "original-invoice", eimsIrn: null }); const db = new FakeDb([ invoiceRow({ eimsDocumentType: "DEB", eimsReason: "Additional handling", relatedInvoice: original, } as Partial), ]); const postSigned = jest.fn(); await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( BadRequestException, ); expect(postSigned).not.toHaveBeenCalled(); expect(db.state).toMatchObject({ nextInvoiceCounter: 7 }); // unchanged — never reserved }); it("refuses a credit/debit note with no related invoice set at all", async () => { const db = new FakeDb([ invoiceRow({ eimsDocumentType: "CRE", eimsReason: "x", relatedInvoice: null } as Partial), ]); const postSigned = jest.fn(); await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( BadRequestException, ); expect(postSigned).not.toHaveBeenCalled(); }); 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, // Returned, not consumed: MoR tracks the sequence and rejects a gap // ("Invoice counter is not correct. expected : 1"). nextInvoiceCounter: 7, }); }); 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("returns both the counter and the document number after a refusal, keeps both after an ambiguous result", 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); // A deterministic rejection rolls both numbers back — MoR's expected-next-value for either // sequence only advances on acceptance (rule 7001 for DocumentNumber, same as InvoiceCounter). const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest; const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest; expect(first.SourceSystem.InvoiceCounter).toBe(7); expect(second.SourceSystem.InvoiceCounter).toBe(7); expect(first.DocumentDetails.DocumentNumber).toBe("5"); expect(second.DocumentDetails.DocumentNumber).toBe("5"); }); it("blocks and alerts with the real IRN when MoR accepts but persisting it locally fails", async () => { const db = new FakeDb([invoiceRow()]); const notify = jest.fn().mockResolvedValue(undefined); const postSigned = jest.fn().mockResolvedValue(okResponse()); // 1st/2nd calls are the reserve() updates; the 3rd is settleSuccess's invoice update — the one // that actually failed live (eims_irn too narrow for the real value MoR returned). let call = 0; const manager = (db as unknown as { manager: { update: jest.Mock } }).manager; const realUpdate = manager.update; manager.update = jest.fn(async (entity: unknown, id: string, patch: Record) => { call++; if (call === 3) throw new Error("value too long for type character varying(64)"); return realUpdate(entity, id, patch); }); await expect( build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims( INVOICE_ID, ), ).rejects.toThrow(/value too long/); // The IRN is never lost, even though the normal success path never committed. expect(db.state?.blockedReason).toContain(IRN); expect(db.state?.blockedReason).toContain("ACCEPTED"); expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Submitting); expect(notify).toHaveBeenCalledTimes(1); const sent = notify.mock.calls[0][0]; expect(sent.priority).toBe("HIGH"); expect(sent.body).toContain(IRN); }); }); describe("EimsInvoiceRegistrationService staff alerting", () => { it("raises a high-priority alert when a result is ambiguous, because all filing is blocked", async () => { const db = new FakeDb([invoiceRow()]); const notify = jest.fn().mockResolvedValue(undefined); const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT")); await expect( build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims( INVOICE_ID, ), ).rejects.toBeInstanceOf(EimsApiException); expect(notify).toHaveBeenCalledTimes(1); const sent = notify.mock.calls[0][0]; expect(sent.priority).toBe("HIGH"); expect(sent.title).toMatch(/blocked/i); expect(sent.recipients.permissionKeys).toContain("edr_freight_app:invoices:eims_resolve"); }); it("raises a normal-priority alert for a deterministic rejection", async () => { const db = new FakeDb([invoiceRow()]); const notify = jest.fn().mockResolvedValue(undefined); const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406)); await expect( build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims( INVOICE_ID, ), ).rejects.toBeInstanceOf(EimsApiException); expect(notify.mock.calls[0][0].priority).toBe("NORMAL"); }); it("does not alert on a successful filing", async () => { const db = new FakeDb([invoiceRow()]); const notify = jest.fn(); await build(db, jest.fn().mockResolvedValue(okResponse()), config(), jest.fn(), undefined, notify) .registerInvoiceWithEims(INVOICE_ID); expect(notify).not.toHaveBeenCalled(); }); it("lets the filing outcome stand even if the alert itself fails", async () => { const db = new FakeDb([invoiceRow()]); const notify = jest.fn().mockRejectedValue(new Error("inbox down")); const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406)); await expect( build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims( INVOICE_ID, ), ).rejects.toThrow(/EIMS register failed \(406\)/); expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed); }); }); 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, eimsDocumentNumber: DOCUMENT_NUMBER, }), ], { 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: "99999" }, }), ); await expect( build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/not 5/); 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, eimsDocumentNumber: "6" }), ); 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); }); });