import { BadRequestException } 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 { InvoiceDocumentService } from "../billing/documents/invoice-document.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 { EimsReceiptStatus } from "./entities/eims-receipt.entity"; import { EimsReceiptService } from "./eims-receipt.service"; const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0"; const SESSION = { systemNumber: "B0360154BA", systemType: "SYS" }; const invoiceRow = (over: Partial = {}): Invoice => ({ id: INVOICE_ID, invoiceNumber: "INV-20260807-00042", companyId: "company-1", currency: "ETB", totalAmount: "10000.00", paidAmount: "10000.00", balanceAmount: "0.00", eimsIrn: IRN, ...over, }) as unknown as Invoice; /** In-memory stand-in: Invoice lookups + EimsReceipt save/update/find. */ class FakeDb { invoices = new Map(); receipts = new Map>(); companyContact: { phone: string | null; email: string | null } | null = null; private seq = 0; constructor(invoices: Invoice[]) { for (const inv of invoices) this.invoices.set(inv.id, inv); } private manager = { findOne: async ( entity: unknown, options: { where: { id?: string; invoiceId?: string } }, ) => { if (entity === Invoice) return this.invoices.get(options.where.id!) ?? null; const receipt = options.where.id ? this.receipts.get(options.where.id) : undefined; if (!receipt) return null; if (options.where.invoiceId && receipt.invoiceId !== options.where.invoiceId) return null; return receipt; }, find: async (_entity: unknown, options: { where: { invoiceId: string } }) => [...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId), save: async (_entity: unknown, data: Record) => { const id = `receipt-${++this.seq}`; const row = { id, ...data }; this.receipts.set(id, row); return row; }, update: async (_entity: unknown, id: string, patch: Record) => { Object.assign(this.receipts.get(id)!, patch); }, }; asDataSource(): DataSource { return { manager: this.manager, query: async () => (this.companyContact ? [this.companyContact] : []), } as unknown as DataSource; } } const config = (): EimsConfig => ({ tin: "0053481357" }) as EimsConfig; const build = ( db: FakeDb, postBearer: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined), documents: { render: jest.Mock } = { render: jest.fn() }, ) => new EimsReceiptService( db.asDataSource(), { get: () => config() } as unknown as ConfigService, { postBearer } as unknown as EimsClientService, { getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService, { directSend } as unknown as NotificationsService, documents as unknown as InvoiceDocumentService, ); const okResponse = (over: Record = {}) => ({ statusCode: 200, message: "Success", body: { status: "A", rrn: "rrn-value", qr: "iVBORw0KGgo...", ...over }, }); describe("EimsReceiptService.registerSalesReceipt", () => { it("registers a sales receipt and persists the RRN/QR", async () => { const db = new FakeDb([invoiceRow()]); const postBearer = jest.fn().mockResolvedValue(okResponse()); const receipt = await build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH", } as never); expect(postBearer).toHaveBeenCalledTimes(1); expect(postBearer.mock.calls[0][0]).toBe("/v1/receipt/sales"); const request = postBearer.mock.calls[0][1]; expect(request.SellerTIN).toBe("0053481357"); expect(request.SourceSystemNumber).toBe("B0360154BA"); expect(request.Invoices[0].InvoiceIRN).toBe(IRN); expect(request.TransactionDetails.ModeOfPayment).toBe("CASH"); expect(receipt.status).toBe(EimsReceiptStatus.Registered); expect(receipt.rrn).toBe("rrn-value"); expect(receipt.qr).toBe("iVBORw0KGgo..."); }); it("derives mode/date/voucher/amount from the recorded manual payment", async () => { const db = new FakeDb([ invoiceRow({ payments: [ { amount: 4000, method: "CASH", reference: "CRV-000123", paidAt: "2026-08-20T09:00:00.000Z", metadata: null }, ], } as never), ]); const postBearer = jest.fn().mockResolvedValue(okResponse()); await build(db, postBearer).registerSalesReceipt(INVOICE_ID, {} as never); const request = postBearer.mock.calls[0][1]; expect(request.TransactionDetails.ModeOfPayment).toBe("CASH"); expect(request.ManualReceiptNumber).toBe("CRV-000123"); expect(request.ReceiptDate).toBe("2026-08-20T09:00:00.000Z"); expect(request.CollectedAmount).toBe(4000); }); it("puts a gateway reference in TransactionNumber, never ManualReceiptNumber, and demands an explicit mode", async () => { const db = new FakeDb([ invoiceRow({ payments: [ { amount: 10000, method: "GATEWAY", reference: "txn-9f8e7d", paidAt: "2026-08-21T10:00:00.000Z", metadata: null }, ], } as never), ]); const postBearer = jest.fn().mockResolvedValue(okResponse()); const service = build(db, postBearer); // GATEWAY says nothing about the channel — deriving would guess a tax field. await expect(service.registerSalesReceipt(INVOICE_ID, {} as never)).rejects.toThrow( BadRequestException, ); await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "Card" } as never); const request = postBearer.mock.calls[0][1]; expect(request.TransactionDetails.TransactionNumber).toBe("txn-9f8e7d"); expect(request.ManualReceiptNumber).not.toBe("txn-9f8e7d"); }); it("defaults PaymentCoverage to FULL when the invoice balance is 0, PARTIAL otherwise", async () => { const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]); const postBearer = jest.fn().mockResolvedValue(okResponse()); await build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never); expect(postBearer.mock.calls[0][1].Invoices[0].PaymentCoverage).toBe("PARTIAL"); }); it("rejects an unconfirmed receipt currency locally, with zero HTTP calls", async () => { const db = new FakeDb([invoiceRow()]); const postBearer = jest.fn(); await expect( build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH", currency: "GBP", } as never), ).rejects.toBeInstanceOf(BadRequestException); expect(postBearer).not.toHaveBeenCalled(); }); it("refuses to file a receipt against an unregistered invoice", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: null })]); const postBearer = jest.fn(); await expect( build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never), ).rejects.toBeInstanceOf(BadRequestException); expect(postBearer).not.toHaveBeenCalled(); }); it("marks the receipt FAILED on a deterministic rejection and rethrows", async () => { const db = new FakeDb([invoiceRow()]); const postBearer = jest .fn() .mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS receipt failed (406)", 406)); await expect( build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never), ).rejects.toBeInstanceOf(EimsApiException); const [receipt] = [...db.receipts.values()]; expect(receipt.status).toBe(EimsReceiptStatus.Failed); }); it("marks the receipt UNKNOWN on an ambiguous failure (never auto-retried)", async () => { const db = new FakeDb([invoiceRow()]); const postBearer = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "EIMS receipt timed out")); await expect( build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never), ).rejects.toBeInstanceOf(EimsApiException); const [receipt] = [...db.receipts.values()]; expect(receipt.status).toBe(EimsReceiptStatus.Unknown); }); 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(okResponse()); const receipt = await build(db, postBearer, directSend).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH", } as never); expect(receipt.status).toBe(EimsReceiptStatus.Registered); expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining("sales")); }); }); describe("EimsReceiptService.registerWithholdingReceipt", () => { it("registers a withholding receipt and persists the RRN", async () => { const db = new FakeDb([invoiceRow()]); const postBearer = jest.fn().mockResolvedValue(okResponse()); const receipt = await build(db, postBearer).registerWithholdingReceipt(INVOICE_ID, { type: "TWHT", preTaxAmount: 6000, withholdingAmount: 120, } as never); expect(postBearer.mock.calls[0][0]).toBe("/v1/receipt/withholding"); const request = postBearer.mock.calls[0][1]; expect(request.InvoiceDetail.InvoiceIRN).toBe(IRN); expect(request.WithholdDetail).toMatchObject({ Type: "TWHT", PreTaxAmount: 6000, WithholdingAmount: 120 }); expect(receipt.status).toBe(EimsReceiptStatus.Registered); expect(receipt.rrn).toBe("rrn-value"); }); it("requires an exchangeRate for a non-ETB invoice", async () => { const db = new FakeDb([invoiceRow({ currency: "USD" })]); const postBearer = jest.fn(); await expect( build(db, postBearer).registerWithholdingReceipt(INVOICE_ID, { type: "TWHT", preTaxAmount: 6000, withholdingAmount: 120, } as never), ).rejects.toBeInstanceOf(BadRequestException); expect(postBearer).not.toHaveBeenCalled(); }); }); describe("EimsReceiptService.listReceipts", () => { it("returns every receipt filed against the invoice", async () => { const db = new FakeDb([invoiceRow()]); const postBearer = jest.fn().mockResolvedValue(okResponse()); const service = build(db, postBearer); await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never); await service.registerWithholdingReceipt(INVOICE_ID, { type: "TWHT", preTaxAmount: 100, withholdingAmount: 2, } as never); const list = await service.listReceipts(INVOICE_ID); expect(list).toHaveLength(2); }); }); describe("EimsReceiptService.document", () => { it("renders a sealed PDF for a registered sales receipt, with RRN and QR in the model", async () => { const db = new FakeDb([invoiceRow()]); const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) }; const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents); const receipt = await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH", collectedAmount: 500, } as never); await service.document(INVOICE_ID, receipt.id); expect(documents.render).toHaveBeenCalledTimes(1); const model = documents.render.mock.calls[0][0]; expect(model.kind).toBe("RECEIPT"); expect(model.qrImageUrl).toBe("data:image/png;base64,iVBORw0KGgo..."); expect(model.summary).toContainEqual({ label: "RRN", value: "rrn-value" }); expect(model.lines[0].amount).toBe(500); expect(model.sealText).toBe("EDR PAID"); }); it("renders a withholding receipt with the withheld amount and a non-PAID seal", async () => { const db = new FakeDb([invoiceRow()]); const documents = { render: jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }) }; const service = build(db, jest.fn().mockResolvedValue(okResponse()), undefined, documents); const receipt = await service.registerWithholdingReceipt(INVOICE_ID, { type: "TWHT", preTaxAmount: 1000, withholdingAmount: 20, } as never); await service.document(INVOICE_ID, receipt.id); const model = documents.render.mock.calls[0][0]; expect(model.lines[0].amount).toBe(20); expect(model.sealText).toBe("EDR"); expect(model.sealText).not.toContain("PAID"); }); it("refuses to render a receipt that was never acknowledged by MoR", async () => { const db = new FakeDb([invoiceRow()]); const postBearer = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "EIMS receipt timed out")); const documents = { render: jest.fn() }; const service = build(db, postBearer, undefined, documents); await expect( service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never), ).rejects.toBeInstanceOf(EimsApiException); const [receipt] = [...db.receipts.values()]; await expect(service.document(INVOICE_ID, receipt.id as string)).rejects.toBeInstanceOf(BadRequestException); expect(documents.render).not.toHaveBeenCalled(); }); it("scopes the lookup to the given invoice — a receipt from another invoice is not found", async () => { const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222"; const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]); const service = build(db, jest.fn().mockResolvedValue(okResponse())); const receipt = await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never); await expect(service.document(OTHER_INVOICE_ID, receipt.id)).rejects.toThrow(/not found/); }); });