Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts

345 lines
14 KiB
TypeScript

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> = {}): 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; 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<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),
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<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("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/);
});
});