feat(eims): derive sales receipt fields from recorded invoice payment
|
Before Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 673 KiB |
|
Before Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 289 KiB |
BIN
INV-20260812-00005-QR.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
INV-20260812-00005.pdf
Normal 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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -571,6 +571,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
relations: { company: true },
|
relations: { company: true },
|
||||||
});
|
});
|
||||||
if (!booking) return;
|
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) {
|
if (!booking.trainScheduleId) {
|
||||||
// A paid booking with no train is money taken and nothing boarding. The
|
// A paid booking with no train is money taken and nothing boarding. The
|
||||||
// hold was expired before the payment landed (webhook lag beat the
|
// hold was expired before the payment landed (webhook lag beat the
|
||||||
|
|||||||