mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1410 from Tria-plc/credit-invoice
feat(eims): derive sales receipt fields from recorded invoice payment
This commit is contained in:
@@ -86,17 +86,16 @@ describe("toEimsInvoice", () => {
|
||||
expect(doc.SellerDetails).toBe(seller);
|
||||
});
|
||||
|
||||
it("maps the buyer from the company row and leaves unmodelled fields null", () => {
|
||||
it("maps the buyer from the company row and omits Id fields for a TIN-identified buyer", () => {
|
||||
const doc = toEimsInvoice(invoice(), seller, context());
|
||||
|
||||
// MoR rule 7004 rejects an explicit IdType/IdNumber null — the keys must be absent.
|
||||
expect(doc.BuyerDetails).toEqual({
|
||||
// Resolved by the registration service before the counter was reserved; the mapper copies.
|
||||
City: "31",
|
||||
Country: "70",
|
||||
Email: "buyer@abc.et",
|
||||
HouseNumber: "NEW",
|
||||
IdNumber: null,
|
||||
IdType: null,
|
||||
Tin: "0999930000",
|
||||
LegalName: "ABC Trading PLC",
|
||||
Phone: "0912345678",
|
||||
|
||||
@@ -35,8 +35,9 @@ export interface EimsBuyerDetails {
|
||||
City: string | null;
|
||||
Email: string | null;
|
||||
HouseNumber: string | null;
|
||||
IdNumber: string | null;
|
||||
IdType: string | null;
|
||||
/** Omitted entirely for a TIN-identified buyer — MoR rule 7004 rejects an explicit null. */
|
||||
IdNumber?: string;
|
||||
IdType?: string;
|
||||
Tin: string;
|
||||
LegalName: string;
|
||||
Phone: string | null;
|
||||
@@ -410,8 +411,8 @@ export function toEimsInvoice(
|
||||
City: context.buyerGeo.City,
|
||||
Email: company.email ?? null,
|
||||
HouseNumber: company.houseNo ?? null,
|
||||
IdNumber: context.buyerIdNumber ?? null,
|
||||
IdType: context.buyerIdType ?? null,
|
||||
...(context.buyerIdNumber != null ? { IdNumber: context.buyerIdNumber } : {}),
|
||||
...(context.buyerIdType != null ? { IdType: context.buyerIdType } : {}),
|
||||
Tin: company.tin,
|
||||
LegalName: company.name,
|
||||
Phone: company.phone ?? null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsNumber, IsOptional, IsString, Length } from "class-validator";
|
||||
|
||||
import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
|
||||
@@ -9,9 +9,15 @@ import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
|
||||
* guessed (payment method, collector, provider references — none of it is modelled on `Invoice`).
|
||||
*/
|
||||
export class RegisterSalesReceiptDto {
|
||||
@ApiProperty({ enum: EIMS_MODE_OF_PAYMENT, description: "MoR's confirmed ModeOfPayment enum." })
|
||||
@ApiPropertyOptional({
|
||||
enum: EIMS_MODE_OF_PAYMENT,
|
||||
description:
|
||||
"MoR's confirmed ModeOfPayment enum. Optional when the invoice's recorded payment method " +
|
||||
"maps unambiguously (CASH, CHEQUE, CPO, CARD, BANK_TRANSFER); otherwise required.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(EIMS_MODE_OF_PAYMENT)
|
||||
modeOfPayment!: EimsModeOfPayment;
|
||||
modeOfPayment?: EimsModeOfPayment;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Defaults to "Payment received".' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -116,6 +116,47 @@ describe("EimsReceiptService.registerSalesReceipt", () => {
|
||||
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());
|
||||
|
||||
@@ -17,6 +17,8 @@ import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims
|
||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||
import {
|
||||
EIMS_MODE_OF_PAYMENT,
|
||||
EimsModeOfPayment,
|
||||
EimsReceiptResponse,
|
||||
EimsSalesReceiptRequest,
|
||||
EimsWithholdReceiptRequest,
|
||||
@@ -41,8 +43,11 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU
|
||||
* double-submission guard the way `/v1/cancel` does ("IRN already Canceled."), so the same caution
|
||||
* applies as an unacknowledged registration: a human must check the MoR portal first.
|
||||
*
|
||||
* Several request fields have no confirmed source in this codebase (payment method, collector,
|
||||
* withholding rate/amount) and are never guessed — see the two DTOs.
|
||||
* Sales receipts derive what the invoice's payment ledger actually records — amount, date,
|
||||
* finance's voucher number (ManualReceiptNumber), gateway transaction id, and the payment mode
|
||||
* where the ledger method maps unambiguously to MoR's enum. Fields with no recorded source
|
||||
* (collector, withholding rate/amount, mobile-money modes) are still asked of the caller, never
|
||||
* guessed — see the two DTOs.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsReceiptService {
|
||||
@@ -68,7 +73,26 @@ export class EimsReceiptService {
|
||||
|
||||
const session = await this.auth.getSessionContext();
|
||||
const receiptNumber = this.generateReceiptNumber(invoice);
|
||||
const collectedAmount = dto.collectedAmount ?? Number(invoice.paidAmount);
|
||||
|
||||
// The newest ledger entry is the payment this receipt vouches for. A gateway settlement's
|
||||
// `reference` is the provider transaction id; a manual settlement's `reference` is finance's
|
||||
// own voucher number (CRV) — that one belongs in ManualReceiptNumber so the registered
|
||||
// receipt matches finance's books.
|
||||
const lastPayment = invoice.payments?.length
|
||||
? invoice.payments[invoice.payments.length - 1]
|
||||
: null;
|
||||
const isGateway = (lastPayment?.method ?? "").toUpperCase() === "GATEWAY";
|
||||
const modeOfPayment = dto.modeOfPayment ?? deriveModeOfPayment(lastPayment?.method);
|
||||
if (!modeOfPayment) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_MODE_OF_PAYMENT_REQUIRED",
|
||||
message:
|
||||
`Recorded payment method "${lastPayment?.method ?? "none"}" has no unambiguous MoR ` +
|
||||
`ModeOfPayment — pass modeOfPayment (one of ${EIMS_MODE_OF_PAYMENT.join(", ")}).`,
|
||||
});
|
||||
}
|
||||
const collectedAmount =
|
||||
dto.collectedAmount ?? (lastPayment ? lastPayment.amount : Number(invoice.paidAmount));
|
||||
const balance = Number(invoice.balanceAmount);
|
||||
|
||||
const request: EimsSalesReceiptRequest = {
|
||||
@@ -77,9 +101,9 @@ export class EimsReceiptService {
|
||||
Reason: dto.reason ?? "Payment received",
|
||||
// ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema
|
||||
// error for this field was ever observed to confirm which form MoR actually requires.
|
||||
ReceiptDate: new Date().toISOString(),
|
||||
ReceiptDate: lastPayment?.paidAt ?? new Date().toISOString(),
|
||||
ReceiptCounter: String(Date.now()),
|
||||
ManualReceiptNumber: receiptNumber,
|
||||
ManualReceiptNumber: (!isGateway && lastPayment?.reference) || receiptNumber,
|
||||
SourceSystemType: session.systemType,
|
||||
SourceSystemNumber: session.systemNumber,
|
||||
ReceiptCurrency: currency,
|
||||
@@ -97,7 +121,7 @@ export class EimsReceiptService {
|
||||
},
|
||||
],
|
||||
TransactionDetails: {
|
||||
ModeOfPayment: dto.modeOfPayment,
|
||||
ModeOfPayment: modeOfPayment,
|
||||
ChequeNumber: dto.chequeNumber ?? null,
|
||||
CPONumber: dto.cpoNumber ?? null,
|
||||
DocumentNumber: dto.documentNumber ?? null,
|
||||
@@ -105,7 +129,7 @@ export class EimsReceiptService {
|
||||
PaymentServiceProvider: dto.paymentServiceProvider ?? null,
|
||||
OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null,
|
||||
AccountNumber: dto.accountNumber ?? null,
|
||||
TransactionNumber: dto.transactionNumber ?? null,
|
||||
TransactionNumber: dto.transactionNumber ?? (isGateway ? (lastPayment?.reference ?? null) : null),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -286,3 +310,17 @@ export class EimsReceiptService {
|
||||
return `REC-${invoice.invoiceNumber}-${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded ledger method → MoR ModeOfPayment, only where the mapping is unambiguous. Mobile-money
|
||||
* methods (TELEBIRR, EBIRR, …) have no MoR enum slot, and "GATEWAY" says nothing about the real
|
||||
* channel — those return undefined and the caller must supply modeOfPayment explicitly. Guessing
|
||||
* a tax field is worse than asking.
|
||||
*/
|
||||
function deriveModeOfPayment(method: string | null | undefined): EimsModeOfPayment | undefined {
|
||||
if (!method) return undefined;
|
||||
const normalized = method.toUpperCase().replace(/-/g, "_");
|
||||
const direct = EIMS_MODE_OF_PAYMENT.find((m) => m.toUpperCase().replace(/ /g, "_") === normalized);
|
||||
if (direct) return direct;
|
||||
return normalized === "BANK_TRANSFER" ? "Local Bank Transfer" : undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user