import { BadRequestException, ConflictException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { DataSource } from "typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures"; import { EimsAuthService } from "./eims-auth.service"; import { EimsBulkRegistrationService } from "./eims-bulk-registration.service"; import { EimsClientService } from "./eims-client.service"; import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; import { EimsApiException } from "./eims.errors"; import { EimsInvoiceStatus } from "./eims-registration.types"; import { buildEimsSeller } from "./eims-invoice-context"; const SYSTEM_NUMBER = "B0360154BA"; const INVOICE_A = "11111111-1111-4111-8111-111111111111"; const INVOICE_B = "22222222-2222-4222-8222-222222222222"; const CONVERSATION_ID = "2345678901-1735900502800-c04f8dd6-e6e2-4198-b871-c6e504fc14f5"; const invoiceRow = (over: Partial = {}): Invoice => ({ id: INVOICE_A, invoiceNumber: "INV-20260807-00001", currency: "ETB", companyId: "company-1", issuedAt: new Date(2026, 7, 7, 9, 5, 3), totalAmount: "10000.00", eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null, eimsDocumentType: "INV", eimsBulkConversationId: null, company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858", phone: "0912345678", region: "13", zone: "SHA", woreda: "574", kebele: "03", houseNo: "NEW", country: "Ethiopia", }, ...over, }) as unknown as Invoice; const LINES = (id: string) => [ { invoiceId: id, chargeType: "RAIL_FREIGHT", description: "Addis to Djibouti", quantity: "1.00", unitRate: "10000.00", amount: "10000.00", }, ]; /** In-memory stand-in covering the query/manager surface this service actually calls. */ class FakeDb { invoices = new Map(); state: EimsSystemState; 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: 1, nextDocumentNumber: 1, previousIrn: null, inFlightInvoiceId: null, inFlightCounter: null, inFlightDocumentNumber: null, inFlightConversationId: null, blockedReason: null, ...state, } as EimsSystemState; } private matches(entity: Invoice | EimsSystemState, where: Record): boolean { return Object.entries(where).every(([key, value]) => (entity as never)[key] === value); } private queryBuilder(entityCtor: unknown) { let where: Record = {}; const builder = { setLock: () => builder, where: (_clause: string, params: Record) => { where = { ...where, ...this.normalizeParams(params) }; return builder; }, andWhere: (_clause: string, params: Record) => { where = { ...where, ...this.normalizeParams(params) }; return builder; }, getOne: async () => this.find(entityCtor, where)[0] ?? null, getMany: async () => this.find(entityCtor, where), }; return builder; } private normalizeParams(params: Record): Record { // Test-only mapping from the SQL param names used in the service's own queries to entity fields. const map: Record = { invoiceId: "id", systemNumber: "systemNumber", id: "eimsBulkConversationId", }; const out: Record = {}; for (const [k, v] of Object.entries(params)) out[map[k] ?? k] = v; return out; } private find(entityCtor: unknown, where: Record): Array { const isState = entityCtor === EimsSystemState; const pool: Array = isState ? [this.state] : [...this.invoices.values()]; return pool.filter((e) => this.matches(e, where)); } private manager = { createQueryBuilder: (entityCtor: unknown) => this.queryBuilder(entityCtor), query: async () => [], findOne: async (entityCtor: unknown, options: { where: Record }) => this.find(entityCtor, options.where)[0] ?? null, update: async (entityCtor: unknown, idOrWhere: string | Record, patch: Record) => { const targets = typeof idOrWhere === "string" ? this.find(entityCtor, { id: idOrWhere }) : this.find(entityCtor, idOrWhere); for (const t of targets) Object.assign(t, patch); return { affected: targets.length }; }, getRepository: (entityCtor: unknown) => ({ findOne: async (options: { where: { id: string } }) => this.find(entityCtor, { id: options.where.id })[0] ?? null, }), }; asDataSource(): DataSource { return { manager: this.manager, // Routed by SQL text: the lines lookup and sendCompanyChannels' contact lookup share this // one entry point in the real DataSource. query: async (sql: string) => { if (sql.includes("invoice_lines")) { return [...this.invoices.keys()].flatMap((id) => LINES(id)); } return this.companyContact ? [this.companyContact] : []; }, transaction: async (body: (m: unknown) => Promise) => body(this.manager), getRepository: () => ({ find: async (options: { where: { id: { value: string[] } } }) => { const ids = options.where.id.value ?? []; return ids.map((id: string) => this.invoices.get(id)).filter(Boolean); }, createQueryBuilder: (alias: string) => { void alias; return this.queryBuilder(Invoice); }, count: async (options: { where: Record }) => this.find(Invoice, options.where).length, }), } as unknown as DataSource; } } const build = (db: FakeDb, postSigned: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined)) => new EimsBulkRegistrationService( db.asDataSource(), { get: () => eimsConfig({ invoice: eimsInvoiceConfig() }) } as unknown as ConfigService, { postSigned } as unknown as EimsClientService, { getSessionContext: async () => ({ systemNumber: SYSTEM_NUMBER, systemType: "SYS" }) } as unknown as EimsAuthService, { directSend } as unknown as NotificationsService, { getSellerDetails: (c: unknown) => buildEimsSeller(c as never) } as unknown as EimsSellerCacheService, ); const accepted = (conversationId = CONVERSATION_ID) => ({ conversationId, status: 202 }); describe("EimsBulkRegistrationService.registerBulk", () => { it("reserves sequential counters, sends one signed array, and claims MoR's real conversation id", async () => { const db = new FakeDb( [invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })], { nextInvoiceCounter: 5, nextDocumentNumber: 5, previousIrn: "prev-irn" }, ); const postSigned = jest.fn().mockResolvedValue(accepted()); const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]); expect(result).toEqual({ conversationId: CONVERSATION_ID, accepted: [INVOICE_A, INVOICE_B], alreadyRegistered: [] }); const [, request] = postSigned.mock.calls[0]; expect(request).toHaveLength(2); expect(request[0].SourceSystem.InvoiceCounter).toBe(5); expect(request[0].DocumentDetails.DocumentNumber).toBe("5"); expect(request[0].ReferenceDetails.PreviousIrn).toBe("prev-irn"); expect(request[1].SourceSystem.InvoiceCounter).toBe(6); // Only the first item in a bulk batch chains — the rest have no IRN to reference yet. expect(request[1].ReferenceDetails.PreviousIrn).toBe(""); expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID }); expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID }); expect(db.state.nextInvoiceCounter).toBe(7); expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID); }); it("skips an already-registered invoice, without consuming a counter for it", async () => { const db = new FakeDb([ invoiceRow({ eimsIrn: "already-irn", eimsStatus: EimsInvoiceStatus.Registered }), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" }), ]); const postSigned = jest.fn().mockResolvedValue(accepted()); const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]); expect(result.alreadyRegistered).toEqual([INVOICE_A]); expect(result.accepted).toEqual([INVOICE_B]); const [, request] = postSigned.mock.calls[0]; expect(request).toHaveLength(1); }); it("refuses the whole batch — no reservation, no HTTP call — when a DEB note has no registered original", async () => { const db = new FakeDb([ invoiceRow({ eimsDocumentType: "DEB", relatedInvoice: { eimsIrn: null, invoiceNumber: "INV-orig" } as never }), ]); const postSigned = jest.fn(); await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(BadRequestException); expect(postSigned).not.toHaveBeenCalled(); expect(db.state.inFlightConversationId).toBeNull(); }); it("refuses when a single-invoice submission is already in flight", async () => { const db = new FakeDb([invoiceRow()], { inFlightInvoiceId: "some-other-invoice" }); await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException); }); it("refuses when another bulk batch is already in flight", async () => { const db = new FakeDb([invoiceRow()], { inFlightConversationId: "other-conversation" }); await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException); }); it("a deterministic rejection rolls back the whole block and clears the in-flight marker", async () => { const db = new FakeDb( [invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })], { nextInvoiceCounter: 5, nextDocumentNumber: 5 }, ); const postSigned = jest.fn().mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "bad", 400)); await expect(build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B])).rejects.toBeInstanceOf(EimsApiException); expect(db.state.nextInvoiceCounter).toBe(5); expect(db.state.nextDocumentNumber).toBe(5); expect(db.state.inFlightConversationId).toBeNull(); expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Failed); expect(db.invoices.get(INVOICE_A)?.eimsBulkConversationId).toBeNull(); }); it("an ambiguous failure blocks the system number and leaves counters consumed", async () => { const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 5, nextDocumentNumber: 5 }); const postSigned = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "timed out")); await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(EimsApiException); expect(db.state.nextInvoiceCounter).toBe(6); expect(db.state.blockedReason).toMatch(/never acknowledged/); expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Unknown); }); it("refuses an empty invoice list", async () => { const db = new FakeDb([invoiceRow()]); await expect(build(db, jest.fn()).registerBulk([])).rejects.toBeInstanceOf(BadRequestException); }); }); describe("EimsBulkRegistrationService.handleBulkCallback", () => { const submittingRow = (over: Partial) => invoiceRow({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID, ...over, }); it("settles a mixed success/error callback, advancing previousIrn to the last accepted item", async () => { const db = new FakeDb( [ submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }), submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }), ], { inFlightConversationId: CONVERSATION_ID }, ); const results = await build(db, jest.fn()).handleBulkCallback([ { irn: "irn-a", status: "A", documentNumber: "5" }, { ruleError: [{ portion: "DocumentDetails", errorMessage: ["bad date"] }], status: "ERROR", docNo: "6" }, { conversionId: CONVERSATION_ID }, ]); expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "irn-a" }); expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed }); expect(db.state.previousIrn).toBe("irn-a"); expect(db.state.inFlightConversationId).toBeNull(); expect(results).toEqual( expect.arrayContaining([ expect.objectContaining({ invoiceId: INVOICE_A, success: true, irn: "irn-a" }), expect.objectContaining({ invoiceId: INVOICE_B, success: false }), ]), ); }); it("ignores a callback for an unknown or already-settled conversation", async () => { const db = new FakeDb([invoiceRow()]); const results = await build(db, jest.fn()).handleBulkCallback([ { irn: "irn-x", status: "A", documentNumber: "1" }, { conversionId: "no-such-conversation" }, ]); expect(results).toEqual([]); }); it("does not clear the in-flight marker while another invoice in the batch is still submitting", async () => { const db = new FakeDb( [ submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }), submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }), ], { inFlightConversationId: CONVERSATION_ID }, ); // Callback only reports on one of the two invoices in this batch. await build(db, jest.fn()).handleBulkCallback([ { irn: "irn-a", status: "A", documentNumber: "5" }, { conversionId: CONVERSATION_ID }, ]); expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID); }); it("reports the current state without re-settling an invoice that already resolved", async () => { const db = new FakeDb( [ invoiceRow({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "irn-a", eimsDocumentNumber: "1", eimsBulkConversationId: CONVERSATION_ID, }), ], { inFlightConversationId: CONVERSATION_ID }, ); const results = await build(db, jest.fn()).handleBulkCallback([ { irn: "irn-a", status: "A", documentNumber: "1" }, { conversionId: CONVERSATION_ID }, ]); expect(results).toEqual([ expect.objectContaining({ invoiceId: INVOICE_A, success: true, message: expect.stringContaining("Already settled") }), ]); }); });