mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 13:28:11 +00:00
fix: ( excess-baggage ) pay in the selected method's currency and record settlement
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user