diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts index e125bb0bf..8c391aef1 100644 --- a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts @@ -13,11 +13,17 @@ export const MAX_PAYMENT_HOURS = 2; export const CUTOFF_MINUTES = 30; /** - * payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes) - * - * checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level - * checkinMinutesBefore so that each route's own window is respected. + * How long a passenger is given to finish one provider payment session, once opened. + * 5 minutes of actual paying (redirect → PIN/OTP → provider callback) + 1 minute of slack. */ +export const PAYMENT_SESSION_MINUTES = 6; + + +export const MIN_PAYMENT_WINDOW_MINUTES = 7; + +export const PAYMENT_SETTLE_MARGIN_SECONDS = 60; + + export function computePaymentDeadline( createdAt: Date, departureAt: Date, @@ -27,3 +33,19 @@ export function computePaymentDeadline( const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000); return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; } + + +export function canOpenPaymentSession( + paymentDeadline: Date, + now: Date = new Date(), +): boolean { + return paymentDeadline.getTime() - now.getTime() >= MIN_PAYMENT_WINDOW_MINUTES * 60 * 1000; +} + +export function computePaymentSessionExpiry( + paymentDeadline: Date, + now: Date = new Date(), +): Date { + const sessionEnd = new Date(now.getTime() + PAYMENT_SESSION_MINUTES * 60 * 1000); + return sessionEnd < paymentDeadline ? sessionEnd : paymentDeadline; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index dc389e5a1..1d033e5ff 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -154,6 +154,10 @@ export class InitiateResponseDto { @ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto; @ApiPropertyOptional() merchantOrderId?: string; + /** When this payment session stops being offered — PAYMENT_SESSION_MINUTES from initiation, capped at paymentDeadline. Drives the client-side countdown. */ + @ApiPropertyOptional() sessionExpiresAt?: string; + /** The booking's payment deadline: after it, the booking is auto-cancelled. */ + @ApiPropertyOptional() paymentDeadline?: string; } export class IntentStatusDto { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 8c4edf083..186383335 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -16,6 +16,11 @@ import { ProviderMethod, ProviderPaymentStatus, } from "@edr/types"; +import { + MAX_PAYMENT_HOURS, + MIN_PAYMENT_WINDOW_MINUTES, + PAYMENT_SESSION_MINUTES, +} from "../../common/utils/payment-deadline.utils"; describe("PaymentsService", () => { let service: PaymentsService; @@ -163,6 +168,69 @@ describe("PaymentsService", () => { ).rejects.toThrow(BadRequestException); }); + /** + * A booking whose payment deadline lands exactly `minutesLeft` from now: the deadline is + * MIN(createdAt + MAX_PAYMENT_HOURS, departure - checkin), so back-date createdAt and keep + * departure far away. Derived from MAX_PAYMENT_HOURS so the test survives changes to it. + */ + const bookingWithDeadlineIn = (minutesLeft: number) => ({ + ...mockBooking, + createdAt: new Date( + Date.now() - (MAX_PAYMENT_HOURS * 60 - minutesLeft) * 60 * 1000, + ), + originStationId: null, + schedule: { + departureAt: new Date(Date.now() + 10 * 60 * 60 * 1000), + stopTimes: [], + route: null, + }, + }); + + it("should refuse to open a provider session that cannot finish before auto-cancel", async () => { + // 2 minutes left — the real incident: the session was opened, the provider captured the + // money, and the auto-cancel cron had already cancelled the booking by then. + mockPrisma.booking.findUnique.mockResolvedValue(bookingWithDeadlineIn(2)); + + await expect( + service.initiatePayment({ + bookingId: "booking-1", + method: "TELEBIRR" as any, + }), + ).rejects.toThrow(BadRequestException); + + // Nothing may reach the provider — no session, no capture, no orphan payment. + expect(mockPaymentClient.initiate).not.toHaveBeenCalled(); + }); + + it("should open a session and report its expiry when the window is wide enough", async () => { + const minutesLeft = MIN_PAYMENT_WINDOW_MINUTES + 3; + mockPrisma.booking.findUnique.mockResolvedValue( + bookingWithDeadlineIn(minutesLeft), + ); + mockPaymentClient.initiate.mockResolvedValue( + requiresActionSnapshot(ProviderMethod.TELEBIRR), + ); + mockPrisma.paymentIntent.upsert.mockResolvedValue({ + id: "intent-1", + status: PaymentIntentStatus.REQUIRES_ACTION, + merchantOrderId: "PSG-MERCH-123", + }); + + const result = await service.initiatePayment({ + bookingId: "booking-1", + method: "TELEBIRR" as any, + }); + + expect(mockPaymentClient.initiate).toHaveBeenCalled(); + // Session ends PAYMENT_SESSION_MINUTES from now — before the deadline, not at it. + const sessionMs = + new Date(result.sessionExpiresAt!).getTime() - Date.now(); + expect(sessionMs).toBeLessThanOrEqual(PAYMENT_SESSION_MINUTES * 60 * 1000); + expect(new Date(result.sessionExpiresAt!).getTime()).toBeLessThan( + new Date(result.paymentDeadline!).getTime(), + ); + }); + it("should initiate a provider payment through the payment microservice", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPaymentClient.initiate.mockResolvedValue( diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 14e48dce5..2b1b14cf1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -29,7 +29,13 @@ import { MarkPaidResponseDto, BillQueryResponseDto, } from "./internal-payments.dto"; -import { computePaymentDeadline } from "../../common/utils/payment-deadline.utils"; +import { + computePaymentDeadline, + computePaymentSessionExpiry, + canOpenPaymentSession, + MIN_PAYMENT_WINDOW_MINUTES, + PAYMENT_SETTLE_MARGIN_SECONDS, +} from "../../common/utils/payment-deadline.utils"; import { PaymentClientService, PaymentDiagnostic, @@ -260,6 +266,27 @@ export class PaymentsService { return this.initiateWalletPayment(booking); } + // Refuse to open a provider session that cannot finish before auto-cancel. Everything below + // this point hands the passenger off to an external provider (redirect/HPP/OTP), which takes + // minutes; TasksService cancels the booking the first cron tick after its payment deadline. + // Opening a session with less than MIN_PAYMENT_WINDOW_MINUTES left produces the worst possible + // outcome — the provider captures the money and the booking is already CANCELLED when the + // capture lands. WALLET is exempt (returned above): it is an instant internal balance debit. + const paymentDeadline = await this.computeBookingPaymentDeadline(booking.id); + const sessionExpiresAt = paymentDeadline + ? computePaymentSessionExpiry(paymentDeadline) + : undefined; + if (paymentDeadline && !canOpenPaymentSession(paymentDeadline)) { + const remainingMs = paymentDeadline.getTime() - Date.now(); + throw new BadRequestException( + remainingMs <= 0 + ? "The payment window for this booking has expired. Please make a new booking." + : `Too little time is left to start a payment (${Math.ceil(remainingMs / 60000)} minute(s) ` + + `until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` + + `Please make a new booking.`, + ); + } + // Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the // single passenger projection row (upserted by bookingId below) tracks the latest session. // Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here. @@ -317,9 +344,7 @@ export class PaymentsService { payerName = booking.seats.find((s) => s.leg === 1)?.passengerName ?? booking.seats[0]?.passengerName; - expiresAt = ( - await this.computeBookingPaymentDeadline(booking.id) - )?.toISOString(); + expiresAt = paymentDeadline?.toISOString(); } const snapshot = await this.paymentClient.initiate({ @@ -350,7 +375,11 @@ export class PaymentsService { where: { id: intent.id }, }); } - return this.formatIntentResponse(intent); + return { + ...this.formatIntentResponse(intent), + sessionExpiresAt: sessionExpiresAt?.toISOString(), + paymentDeadline: paymentDeadline?.toISOString(), + }; } /** @@ -436,8 +465,15 @@ export class PaymentsService { if (booking.status !== "PENDING_PAYMENT") { return { ...base, stillPayable: false, reason: "NOT_PAYABLE" }; } + // A CBE debit confirmed now lands in seconds, so this doesn't need the full + // MIN_PAYMENT_WINDOW_MINUTES that opening a session does — but it must not be confirmed so + // close to the deadline that the auto-cancel cron cancels the booking before the capture is + // registered. Refusing here is what keeps CBE from debiting a passenger for a dead booking. const deadline = await this.computeBookingPaymentDeadline(booking.id); - if (deadline && deadline.getTime() < Date.now()) { + if ( + deadline && + deadline.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < Date.now() + ) { return { ...base, stillPayable: false, reason: "EXPIRED" }; } return { ...base, stillPayable: true, reason: null }; diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 51e90381b..3af351e32 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -353,7 +353,7 @@ export class DMoneyProvider implements PaymentProvider { return this.config.get("dmoney.returnUrl") ?? ""; } private get timeoutExpress(): string { - return this.config.get("dmoney.timeoutExpress") ?? "120m"; + return this.config.get("dmoney.timeoutExpress") ?? "5m"; } private get language(): string { return this.config.get("dmoney.language") ?? "en";