mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
345 lines
14 KiB
TypeScript
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/);
|
|
});
|
|
});
|