mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
fix: (passenger): block payment initiation too close to the booking deadline
This commit is contained in:
@@ -13,11 +13,17 @@ export const MAX_PAYMENT_HOURS = 2;
|
|||||||
export const CUTOFF_MINUTES = 30;
|
export const CUTOFF_MINUTES = 30;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes)
|
* 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.
|
||||||
* checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level
|
|
||||||
* checkinMinutesBefore so that each route's own window is respected.
|
|
||||||
*/
|
*/
|
||||||
|
export const PAYMENT_SESSION_MINUTES = 6;
|
||||||
|
|
||||||
|
|
||||||
|
export const MIN_PAYMENT_WINDOW_MINUTES = 7;
|
||||||
|
|
||||||
|
export const PAYMENT_SETTLE_MARGIN_SECONDS = 60;
|
||||||
|
|
||||||
|
|
||||||
export function computePaymentDeadline(
|
export function computePaymentDeadline(
|
||||||
createdAt: Date,
|
createdAt: Date,
|
||||||
departureAt: Date,
|
departureAt: Date,
|
||||||
@@ -27,3 +33,19 @@ export function computePaymentDeadline(
|
|||||||
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
|
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
|
||||||
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,6 +154,10 @@ export class InitiateResponseDto {
|
|||||||
@ApiPropertyOptional({ type: ClientActionDto })
|
@ApiPropertyOptional({ type: ClientActionDto })
|
||||||
clientAction?: ClientActionDto;
|
clientAction?: ClientActionDto;
|
||||||
@ApiPropertyOptional() merchantOrderId?: string;
|
@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 {
|
export class IntentStatusDto {
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
ProviderMethod,
|
ProviderMethod,
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
|
import {
|
||||||
|
MAX_PAYMENT_HOURS,
|
||||||
|
MIN_PAYMENT_WINDOW_MINUTES,
|
||||||
|
PAYMENT_SESSION_MINUTES,
|
||||||
|
} from "../../common/utils/payment-deadline.utils";
|
||||||
|
|
||||||
describe("PaymentsService", () => {
|
describe("PaymentsService", () => {
|
||||||
let service: PaymentsService;
|
let service: PaymentsService;
|
||||||
@@ -163,6 +168,69 @@ describe("PaymentsService", () => {
|
|||||||
).rejects.toThrow(BadRequestException);
|
).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 () => {
|
it("should initiate a provider payment through the payment microservice", async () => {
|
||||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
mockPaymentClient.initiate.mockResolvedValue(
|
mockPaymentClient.initiate.mockResolvedValue(
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ import {
|
|||||||
MarkPaidResponseDto,
|
MarkPaidResponseDto,
|
||||||
BillQueryResponseDto,
|
BillQueryResponseDto,
|
||||||
} from "./internal-payments.dto";
|
} 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 {
|
import {
|
||||||
PaymentClientService,
|
PaymentClientService,
|
||||||
PaymentDiagnostic,
|
PaymentDiagnostic,
|
||||||
@@ -260,6 +266,27 @@ export class PaymentsService {
|
|||||||
return this.initiateWalletPayment(booking);
|
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
|
// 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.
|
// single passenger projection row (upserted by bookingId below) tracks the latest session.
|
||||||
// Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here.
|
// Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here.
|
||||||
@@ -317,9 +344,7 @@ export class PaymentsService {
|
|||||||
payerName =
|
payerName =
|
||||||
booking.seats.find((s) => s.leg === 1)?.passengerName ??
|
booking.seats.find((s) => s.leg === 1)?.passengerName ??
|
||||||
booking.seats[0]?.passengerName;
|
booking.seats[0]?.passengerName;
|
||||||
expiresAt = (
|
expiresAt = paymentDeadline?.toISOString();
|
||||||
await this.computeBookingPaymentDeadline(booking.id)
|
|
||||||
)?.toISOString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await this.paymentClient.initiate({
|
const snapshot = await this.paymentClient.initiate({
|
||||||
@@ -350,7 +375,11 @@ export class PaymentsService {
|
|||||||
where: { id: intent.id },
|
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") {
|
if (booking.status !== "PENDING_PAYMENT") {
|
||||||
return { ...base, stillPayable: false, reason: "NOT_PAYABLE" };
|
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);
|
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: false, reason: "EXPIRED" };
|
||||||
}
|
}
|
||||||
return { ...base, stillPayable: true, reason: null };
|
return { ...base, stillPayable: true, reason: null };
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
return this.config.get<string>("dmoney.returnUrl") ?? "";
|
return this.config.get<string>("dmoney.returnUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get timeoutExpress(): string {
|
private get timeoutExpress(): string {
|
||||||
return this.config.get<string>("dmoney.timeoutExpress") ?? "120m";
|
return this.config.get<string>("dmoney.timeoutExpress") ?? "5m";
|
||||||
}
|
}
|
||||||
private get language(): string {
|
private get language(): string {
|
||||||
return this.config.get<string>("dmoney.language") ?? "en";
|
return this.config.get<string>("dmoney.language") ?? "en";
|
||||||
|
|||||||
Reference in New Issue
Block a user