fix: ( excess-baggage ) pay in the selected method's currency and record settlement

This commit is contained in:
Abubeker Yasin
2026-08-17 15:33:43 +03:00
parent 41080c1650
commit fa16087a4a
10 changed files with 1522 additions and 28 deletions

View File

@@ -8,6 +8,7 @@ import {
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { PaymentReferenceType } from "@edr/types";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import {
PaymentEventDto,
@@ -51,6 +52,12 @@ export class InternalPaymentsController {
async billQuery(
@Body() request: BillQueryRequestDto,
): Promise<BillQueryResponseDto> {
// Routed on referenceType: the passenger app issues CBE bills for bookings AND for excess
// baggage charges, and they live in different tables. Treating every referenceId as a
// bookingId would report a perfectly payable baggage bill as NOT_FOUND to the teller.
if (request.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) {
return this.paymentsService.billQueryExcessBaggage(request.referenceId);
}
return this.paymentsService.billQuery(request.referenceId);
}
}

View File

@@ -46,6 +46,10 @@ describe("PaymentsService", () => {
paymentMethod: {
findUnique: jest.fn(),
},
excessBaggageCharge: {
findUnique: jest.fn(),
update: jest.fn(),
},
currencyExchangeRate: {
findFirst: jest.fn(),
},
@@ -562,4 +566,196 @@ describe("PaymentsService", () => {
);
});
});
/**
* Excess baggage settles through the same outbox → RabbitMQ path as bookings. Before this
* existed the consumer dropped every EXCESS_BAGGAGE event as "foreign-reference", so a charge
* the payer had genuinely paid stayed PENDING until its TTL flipped it to EXPIRED.
*/
describe("handlePaymentEvent — excess baggage", () => {
const CHARGE_ID = "charge-1";
const succeededEvent = (overrides: Record<string, any> = {}) =>
({
eventId: "evt-1",
eventType: "payment.succeeded",
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.EXCESS_BAGGAGE,
referenceId: CHARGE_ID,
amountMinor: 500,
currency: "ETB",
providerTxnId: "TXN-9",
...overrides,
}) as any;
it("marks a pending charge PAID", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
id: CHARGE_ID,
status: "PENDING",
});
const result = await service.handlePaymentEvent(succeededEvent());
expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CHARGE_ID },
data: expect.objectContaining({ status: "PAID" }),
}),
);
expect(result).toEqual({ processed: true });
});
it("marks an EXPIRED charge PAID — the TTL governs starting a payment, not receiving one", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
id: CHARGE_ID,
status: "EXPIRED",
});
await service.handlePaymentEvent(succeededEvent());
expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: "PAID" }),
}),
);
});
it("does not re-pay an already PAID charge", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
id: CHARGE_ID,
status: "PAID",
});
const result = await service.handlePaymentEvent(succeededEvent());
expect(mockPrisma.excessBaggageCharge.update).not.toHaveBeenCalled();
expect(result).toEqual({ processed: true, alreadyFinalized: true });
});
it("accepts a foreign-currency settlement without a short-pay comparison", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
id: CHARGE_ID,
status: "PENDING",
});
// 500.00 ETB charge settled as 1625 DJF — numerically unlike the stored total.
await service.handlePaymentEvent(
succeededEvent({ amountMinor: 1625, currency: "DJF" }),
);
expect(mockPrisma.excessBaggageCharge.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: "PAID" }),
}),
);
});
it("acks a failure event without touching the charge", async () => {
const result = await service.handlePaymentEvent(
succeededEvent({ eventType: "payment.failed" }),
);
expect(mockPrisma.excessBaggageCharge.update).not.toHaveBeenCalled();
expect(result).toEqual({ processed: true });
});
it("acks an event for a charge that no longer exists", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(null);
const result = await service.handlePaymentEvent(succeededEvent());
expect(result).toEqual({
processed: false,
reason: "charge-not-found",
});
});
});
/**
* The live hop CBE makes while a teller is on the line, for a baggage bill. This is the
* double-payment guard: anything other than stillPayable=true makes CBE refuse the debit.
*/
describe("billQueryExcessBaggage", () => {
const payable = {
id: "charge-1",
excessWeightKg: 7,
totalMinor: 25_000,
status: "PENDING",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
booking: {
bookingRef: "BAG-001",
seats: [{ leg: 1, passengerName: "Abebe Kebede" }],
passenger: { user: { fullName: "Account Holder" } },
},
};
it("reports a pending charge as payable, in ETB, with the passenger name", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(payable);
const result = await service.billQueryExcessBaggage("charge-1");
expect(result).toMatchObject({
stillPayable: true,
currency: "ETB",
currentAmountMinor: 250,
payerName: "Abebe Kebede",
});
expect(result.paymentReason).toContain("BAG-001");
});
it("refuses a charge already paid at the counter in cash", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
...payable,
status: "CASH_COLLECTED",
});
await expect(
service.billQueryExcessBaggage("charge-1"),
).resolves.toMatchObject({
stillPayable: false,
reason: "ALREADY_PAID",
});
});
it("refuses a waived charge", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
...payable,
status: "WAIVED",
});
await expect(
service.billQueryExcessBaggage("charge-1"),
).resolves.toMatchObject({ stillPayable: false, reason: "CANCELLED" });
});
it("refuses a charge whose deadline has passed", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
...payable,
expiresAt: new Date(Date.now() - 1000),
});
await expect(
service.billQueryExcessBaggage("charge-1"),
).resolves.toMatchObject({ stillPayable: false, reason: "EXPIRED" });
});
it("refuses within the settle margin, so a debit cannot land after expiry", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({
...payable,
expiresAt: new Date(Date.now() + 5_000), // inside the 60s margin
});
await expect(
service.billQueryExcessBaggage("charge-1"),
).resolves.toMatchObject({ stillPayable: false, reason: "EXPIRED" });
});
it("reports NOT_FOUND for a bill whose charge is gone", async () => {
mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(null);
await expect(
service.billQueryExcessBaggage("charge-1"),
).resolves.toEqual({ stillPayable: false, reason: "NOT_FOUND" });
});
});
});

View File

@@ -533,6 +533,73 @@ export class PaymentsService {
return { ...base, stillPayable: true, reason: null };
}
/**
* Bill-query for an excess baggage charge — the same live "still payable?" hop as bookings,
* against `ExcessBaggageCharge` instead. This is the double-payment guard for baggage bills:
* once the charge is paid, waived or lapsed, CBE is told to refuse the debit.
*
* The charge's own `expiresAt` is the deadline (extended to the CBE bill window when the bill
* was issued), so there is no separate schedule-derived deadline to compute as there is for a
* booking.
*/
async billQueryExcessBaggage(
chargeId: string,
): Promise<BillQueryResponseDto> {
const charge = await this.prisma.excessBaggageCharge.findUnique({
where: { id: chargeId },
include: {
booking: {
include: { seats: true, passenger: { include: { user: true } } },
},
},
});
// A bill reference we issued whose charge has since been deleted — a data problem, not a
// customer-facing cancellation.
if (!charge) return { stillPayable: false, reason: "NOT_FOUND" };
const base = {
payerName:
charge.booking?.seats?.find((s) => s.leg === 1)?.passengerName ??
charge.booking?.seats?.[0]?.passengerName ??
charge.booking?.passenger?.user?.fullName ??
null,
// The charge is always booked in ETB and CBE settles ETB only, so no conversion applies.
currentAmountMinor: this.currencyService.displayMinorToChargeMajor(
charge.totalMinor,
"ETB",
),
currency: "ETB",
// Rendered beside the amount on CBE's confirmation screen. The weight and booking ref are
// both on the agent's slip, so the payer can match the two before confirming.
paymentReason: `Excess baggage ${charge.excessWeightKg}kg — booking ${
charge.booking?.bookingRef ?? ""
}`.trim(),
};
// Paid first: a charge settled by any method (including cash at the counter) must be reported
// as already paid, never as merely "not payable".
if (charge.status === "PAID" || charge.status === "CASH_COLLECTED") {
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
}
// A supervisor wrote the charge off; from the payer's side the debt is gone.
if (charge.status === "WAIVED") {
return { ...base, stillPayable: false, reason: "CANCELLED" };
}
// Confirmed CBE debits land in seconds, but must not be accepted so close to the deadline
// that the sweep expires the intent before the capture is registered.
if (
charge.status === "EXPIRED" ||
charge.expiresAt.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 <
Date.now()
) {
return { ...base, stillPayable: false, reason: "EXPIRED" };
}
if (charge.status !== "PENDING") {
return { ...base, stillPayable: false, reason: "NOT_PAYABLE" };
}
return { ...base, stillPayable: true, reason: null };
}
/**
* The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's
* origin-segment time and that stop's own check-in window, falling back to the route default.
@@ -1396,6 +1463,77 @@ export class PaymentsService {
return { processed: true };
}
/**
* Settlement for an excess baggage charge paid through the passenger portal link.
*
* Deliberately has NO short-payment amount guard, unlike the booking path: the charge is stored
* in ETB while `event.amountMinor` arrives in the provider's settlement currency (DJF for
* Waafi/D-Money/CAC, USD for card), so comparing the two directly would reject every legitimate
* cross-currency payment. The amount actually charged was computed server-side at initiate.
*
* An EXPIRED charge is still marked PAID. The link TTL only governs whether a NEW payment may be
* started; once a provider has captured the money the charge is paid, and leaving it EXPIRED
* would hide a real settlement from the agent who has to reconcile it.
*/
private async handleExcessBaggageChargeEvent(
event: PaymentEventDto,
): Promise<MarkPaidResponseDto> {
if (event.eventType === "payment.failed") {
this.logger.warn(
`excess baggage charge ${event.referenceId} payment failed`,
);
return { processed: true };
}
const charge = await this.prisma.excessBaggageCharge.findUnique({
where: { id: event.referenceId },
});
if (!charge) {
// Ack — a missing charge will not appear on redelivery; needs investigation.
this.logger.error(
`mark-paid: no excess baggage charge for reference ${event.referenceId}`,
);
return { processed: false, reason: "charge-not-found" };
}
if (charge.status === "PAID" || charge.status === "CASH_COLLECTED") {
return { processed: true, alreadyFinalized: true };
}
// Money arrived against a charge nobody expected to be paid — record it as PAID (that is the
// truth) but say so loudly: a waived charge that settles anyway needs a refund decision.
if (charge.status !== "PENDING") {
this.logger.warn(
`mark-paid: excess baggage charge ${charge.id} settled while ${charge.status} ` +
`(${event.amountMinor} ${event.currency}) — marking PAID; needs review`,
);
}
await this.prisma.excessBaggageCharge.update({
where: { id: charge.id },
data: {
status: "PAID",
// The provider's own capture time, not when this event happened to be processed — a
// replayed or dead-lettered event must not backdate the money to the wrong minute.
paidAt: event.paidAt ? new Date(event.paidAt) : new Date(),
},
});
await this.auditService.log({
action: "UPDATE",
entityType: "ExcessBaggageCharge",
entityId: charge.id,
oldData: { status: charge.status },
newData: {
status: "PAID",
providerTxnId: event.providerTxnId,
settledAmount: event.amountMinor,
settledCurrency: event.currency,
},
});
this.logger.log(
`excess baggage charge ${charge.id} marked PAID (${event.amountMinor} ${event.currency}, txn ${event.providerTxnId ?? "n/a"})`,
);
return { processed: true };
}
async handlePaymentEvent(
event: PaymentEventDto,
): Promise<MarkPaidResponseDto> {
@@ -1410,6 +1548,10 @@ export class PaymentsService {
return this.handleSupplementaryChargeEvent(event);
}
if (event.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) {
return this.handleExcessBaggageChargeEvent(event);
}
if (event.referenceType !== PaymentReferenceType.BOOKING) {
this.logger.warn(
`mark-paid: ignoring unknown referenceType ${event.referenceType}`,