diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 1356f8cfa..6e25c72aa 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -668,3 +668,66 @@ describe("BillingService — CAC Bank (OTP debit)", () => { expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456"); }); }); + +describe("BillingService — CBE bill amounts round UP to whole birr", () => { + // CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down + // settles 0.40 short while markInvoiceAsPaid still writes paidAmount = + // totalAmount — money missing from the bank with the books saying paid. + // payInvoice and billQuery must agree, or /cbe/payment sees a mismatch. + const invoice = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: "PREPAID", + invoiceNumber: "INV-20260101-00001", + currency: "ETB", + // .40 — the case Math.round gets wrong (rounds down, underpays). + balanceAmount: 12345.4, + totalAmount: 12345.4, + company: { name: "Acme PLC" }, + paymentId: null, + dueAt: null, + }; + + const build = (payment: Record = {}) => { + const repo = { + findOne: jest.fn().mockResolvedValue(invoice), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new BillingService( + { getRepository: () => repo } as never, + {} as never, + {} as never, + makeEvents() as never, + payment as never, + {} as never, + {} as never, + ); + return { service, repo }; + }; + + it("opens the intent for the ceiled balance, never below it", async () => { + const initiate = jest.fn().mockResolvedValue({ + intentId: "intent-1", + immediateSuccess: false, + response: { intentId: "intent-1", status: "REQUIRES_ACTION" }, + }); + const { service } = build({ initiate }); + + await service.payInvoice("inv-1", { method: "CBE_BILL" }); + + expect(initiate).toHaveBeenCalledWith( + expect.objectContaining({ amountMinor: 12346 }), + ); + }); + + it("quotes the same ceiled amount on bill-query as payInvoice opened", async () => { + const { service } = build(); + + await expect(service.billQuery("booking-1")).resolves.toMatchObject({ + stillPayable: true, + currentAmountMinor: 12346, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index ea4413638..5051afd6e 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1191,7 +1191,11 @@ export class BillingService { // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, orderRef: invoice.invoiceNumber.replace(/-/g, "_"), - amountMinor: Math.round(Number(invoice.balanceAmount)), + // Whole birr, always UP. CBE bills this amount verbatim, so it must never + // land below the outstanding balance — Math.round would let a .40 balance + // settle 0.40 short. Ceil overcharges by <1 birr instead, and the same + // ceil in billQuery keeps the quoted and debited amounts identical. + amountMinor: Math.ceil(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, method: opts.method ?? "TELEBIRR", @@ -1336,7 +1340,9 @@ export class BillingService { }); if (open) { - const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount)); + // Ceil, matching payInvoice — the amount CBE quotes at the counter has to + // be the amount the intent was opened for, or /cbe/payment sees a mismatch. + const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount)); const expired = open.dueAt && open.dueAt.getTime() < Date.now(); return { stillPayable: balance > 0 && !expired, @@ -1371,7 +1377,7 @@ export class BillingService { return { stillPayable: false, payerName: latest.company?.name ?? null, - currentAmountMinor: Math.round(Number(latest.totalAmount)), + currentAmountMinor: Math.ceil(Number(latest.totalAmount)), currency: latest.currency, paymentReason: `Freight invoice ${latest.invoiceNumber}`, reason: closedInvoiceReason(latest.status), diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts index e69a0e15a..f437000bd 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts @@ -1,8 +1,8 @@ import { IsEnum, IsIn, - IsInt, IsISO8601, + IsNumber, IsOptional, IsPositive, IsString, @@ -37,7 +37,9 @@ export class PaymentEventDto { @ApiProperty() @IsString() referenceId!: string; @ApiProperty() @IsString() merchantOrderId!: string; @ApiProperty({ enum: ProviderMethod }) @IsEnum(ProviderMethod) provider!: string; - @ApiProperty() @IsInt() @IsPositive() amountMinor!: number; + // Major units, fractional (payment-api stores it as double precision) — an + // invoice of 12345.67 must not be rejected by an integer-only validator. + @ApiProperty() @IsNumber() @IsPositive() amountMinor!: number; @ApiProperty() @IsString() currency!: string; @ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string; diff --git a/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts b/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts index e285c181c..8dac9a9fd 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts @@ -1,6 +1,7 @@ import { Injectable, Logger, SetMetadata } from "@nestjs/common"; import { Nack, RabbitSubscribe } from "@golevelup/nestjs-rabbitmq"; import { Public } from "@edr/api-common"; +import { IgnoreLoggerAudit } from "@tria-plc/auditlog"; import { PAYMENT_EVENTS_DLX, PAYMENT_EVENTS_EXCHANGE,