Merge pull request #1410 from Tria-plc/credit-invoice

feat(eims): derive sales receipt fields from recorded invoice payment
This commit is contained in:
Hagernesh Tadesse
2026-08-24 16:21:38 +03:00
committed by GitHub
24 changed files with 102 additions and 17 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

BIN
INV-20260812-00005-QR.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
INV-20260812-00005.pdf Normal file

Binary file not shown.

View File

@@ -86,17 +86,16 @@ describe("toEimsInvoice", () => {
expect(doc.SellerDetails).toBe(seller); 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()); const doc = toEimsInvoice(invoice(), seller, context());
// MoR rule 7004 rejects an explicit IdType/IdNumber null — the keys must be absent.
expect(doc.BuyerDetails).toEqual({ expect(doc.BuyerDetails).toEqual({
// Resolved by the registration service before the counter was reserved; the mapper copies. // Resolved by the registration service before the counter was reserved; the mapper copies.
City: "31", City: "31",
Country: "70", Country: "70",
Email: "buyer@abc.et", Email: "buyer@abc.et",
HouseNumber: "NEW", HouseNumber: "NEW",
IdNumber: null,
IdType: null,
Tin: "0999930000", Tin: "0999930000",
LegalName: "ABC Trading PLC", LegalName: "ABC Trading PLC",
Phone: "0912345678", Phone: "0912345678",

View File

@@ -35,8 +35,9 @@ export interface EimsBuyerDetails {
City: string | null; City: string | null;
Email: string | null; Email: string | null;
HouseNumber: string | null; HouseNumber: string | null;
IdNumber: string | null; /** Omitted entirely for a TIN-identified buyer — MoR rule 7004 rejects an explicit null. */
IdType: string | null; IdNumber?: string;
IdType?: string;
Tin: string; Tin: string;
LegalName: string; LegalName: string;
Phone: string | null; Phone: string | null;
@@ -410,8 +411,8 @@ export function toEimsInvoice(
City: context.buyerGeo.City, City: context.buyerGeo.City,
Email: company.email ?? null, Email: company.email ?? null,
HouseNumber: company.houseNo ?? null, HouseNumber: company.houseNo ?? null,
IdNumber: context.buyerIdNumber ?? null, ...(context.buyerIdNumber != null ? { IdNumber: context.buyerIdNumber } : {}),
IdType: context.buyerIdType ?? null, ...(context.buyerIdType != null ? { IdType: context.buyerIdType } : {}),
Tin: company.tin, Tin: company.tin,
LegalName: company.name, LegalName: company.name,
Phone: company.phone ?? null, Phone: company.phone ?? null,

View File

@@ -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 { IsIn, IsNumber, IsOptional, IsString, Length } from "class-validator";
import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types"; 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`). * guessed (payment method, collector, provider references — none of it is modelled on `Invoice`).
*/ */
export class RegisterSalesReceiptDto { 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) @IsIn(EIMS_MODE_OF_PAYMENT)
modeOfPayment!: EimsModeOfPayment; modeOfPayment?: EimsModeOfPayment;
@ApiPropertyOptional({ description: 'Defaults to "Payment received".' }) @ApiPropertyOptional({ description: 'Defaults to "Payment received".' })
@IsOptional() @IsOptional()

View File

@@ -116,6 +116,47 @@ describe("EimsReceiptService.registerSalesReceipt", () => {
expect(receipt.qr).toBe("iVBORw0KGgo..."); 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 () => { it("defaults PaymentCoverage to FULL when the invoice balance is 0, PARTIAL otherwise", async () => {
const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]); const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]);
const postBearer = jest.fn().mockResolvedValue(okResponse()); const postBearer = jest.fn().mockResolvedValue(okResponse());

View File

@@ -17,6 +17,8 @@ import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
import { import {
EIMS_MODE_OF_PAYMENT,
EimsModeOfPayment,
EimsReceiptResponse, EimsReceiptResponse,
EimsSalesReceiptRequest, EimsSalesReceiptRequest,
EimsWithholdReceiptRequest, 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 * 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. * 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, * Sales receipts derive what the invoice's payment ledger actually records — amount, date,
* withholding rate/amount) and are never guessed — see the two DTOs. * 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() @Injectable()
export class EimsReceiptService { export class EimsReceiptService {
@@ -68,7 +73,26 @@ export class EimsReceiptService {
const session = await this.auth.getSessionContext(); const session = await this.auth.getSessionContext();
const receiptNumber = this.generateReceiptNumber(invoice); 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 balance = Number(invoice.balanceAmount);
const request: EimsSalesReceiptRequest = { const request: EimsSalesReceiptRequest = {
@@ -77,9 +101,9 @@ export class EimsReceiptService {
Reason: dto.reason ?? "Payment received", Reason: dto.reason ?? "Payment received",
// ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema // 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. // 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()), ReceiptCounter: String(Date.now()),
ManualReceiptNumber: receiptNumber, ManualReceiptNumber: (!isGateway && lastPayment?.reference) || receiptNumber,
SourceSystemType: session.systemType, SourceSystemType: session.systemType,
SourceSystemNumber: session.systemNumber, SourceSystemNumber: session.systemNumber,
ReceiptCurrency: currency, ReceiptCurrency: currency,
@@ -97,7 +121,7 @@ export class EimsReceiptService {
}, },
], ],
TransactionDetails: { TransactionDetails: {
ModeOfPayment: dto.modeOfPayment, ModeOfPayment: modeOfPayment,
ChequeNumber: dto.chequeNumber ?? null, ChequeNumber: dto.chequeNumber ?? null,
CPONumber: dto.cpoNumber ?? null, CPONumber: dto.cpoNumber ?? null,
DocumentNumber: dto.documentNumber ?? null, DocumentNumber: dto.documentNumber ?? null,
@@ -105,7 +129,7 @@ export class EimsReceiptService {
PaymentServiceProvider: dto.paymentServiceProvider ?? null, PaymentServiceProvider: dto.paymentServiceProvider ?? null,
OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null, OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null,
AccountNumber: dto.accountNumber ?? 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()}`; 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;
}