feat(eims): derive sales receipt fields from recorded invoice payment

This commit is contained in:
Hagernesh
2026-08-24 12:48:41 +00:00
parent 377ab136a5
commit 565265af79
25 changed files with 108 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);
});
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",

View File

@@ -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,

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 { 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()

View File

@@ -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());

View File

@@ -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;
}

View File

@@ -571,6 +571,12 @@ export class BookingBatchService implements OnModuleInit {
relations: { company: true },
});
if (!booking) return;
// A dead booking keeps payment_status = 'PAID' (it was paid before it died),
// so every rescue path below would happily re-place and re-allocate it —
// that is how a cancelled consolidation-lapse booking came back onto its
// train 30s after being cancelled. Never resurrect a dead booking.
if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status))
return;
if (!booking.trainScheduleId) {
// A paid booking with no train is money taken and nothing boarding. The
// hold was expired before the payment landed (webhook lag beat the