eims integration master test complete

This commit is contained in:
Hagernesh
2026-08-12 14:08:28 +00:00
parent 8d53dc17ce
commit 2d4da8110b
38 changed files with 2270 additions and 173 deletions

View File

@@ -0,0 +1,231 @@
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 { 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> = {}): 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<string, Invoice>();
receipts = new Map<string, Record<string, unknown>>();
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 } }) =>
entity === Invoice
? (this.invoices.get(options.where.id) ?? null)
: (this.receipts.get(options.where.id) ?? null),
find: async (_entity: unknown, options: { where: { invoiceId: string } }) =>
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
save: async (_entity: unknown, data: Record<string, unknown>) => {
const id = `receipt-${++this.seq}`;
const row = { id, ...data };
this.receipts.set(id, row);
return row;
},
update: async (_entity: unknown, id: string, patch: Record<string, unknown>) => {
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),
) =>
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,
);
const okResponse = (over: Record<string, unknown> = {}) => ({
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("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);
});
});