mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
@@ -56,7 +56,7 @@ class WaiveSupplementaryChargeDto {
|
||||
|
||||
class PaySupplementaryChargeDto {
|
||||
@ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
|
||||
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile';
|
||||
@ApiPropertyOptional({ enum: ['web', 'mobile', 'inapp'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile', 'inapp']) platform?: PaymentPlatformDto;
|
||||
}
|
||||
|
||||
@ApiTags("Payment")
|
||||
@@ -307,7 +307,14 @@ export class PaymentsController {
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true })
|
||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||
@ApiQuery({
|
||||
name: "platform",
|
||||
enum: ["web", "mobile"],
|
||||
required: false,
|
||||
description:
|
||||
"Browser-only endpoint — `inapp` is not offered here. A mini-app payer has no browser " +
|
||||
"to redirect and must go through POST /payments/initiate for the bridge payload.",
|
||||
})
|
||||
@ApiProduces("text/html")
|
||||
async checkout(
|
||||
@Query("bookingId") bookingId: string,
|
||||
|
||||
@@ -28,7 +28,8 @@ export enum PaymentMethodTypeEnum {
|
||||
CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number)
|
||||
}
|
||||
|
||||
export type PaymentPlatformDto = "web" | "mobile";
|
||||
/** Mirrors `PaymentPlatform` in @edr/types — see there for what each surface means. */
|
||||
export type PaymentPlatformDto = "web" | "mobile" | "inapp";
|
||||
|
||||
export class InitiatePaymentDto {
|
||||
@ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string;
|
||||
@@ -45,12 +46,14 @@ export class InitiatePaymentDto {
|
||||
@IsString()
|
||||
paymentMethodId?: string;
|
||||
@ApiPropertyOptional({
|
||||
enum: ["web", "mobile"],
|
||||
enum: ["web", "mobile", "inapp"],
|
||||
default: "web",
|
||||
description: "Payment platform (web or mobile)",
|
||||
description:
|
||||
"Payer surface. `inapp` = the portal is running inside a SuperApp mini-app WebView " +
|
||||
"(Telebirr), which cannot follow redirect flows and gets a bridge payload instead.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["web", "mobile"])
|
||||
@IsIn(["web", "mobile", "inapp"])
|
||||
platform?: PaymentPlatformDto;
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
@@ -117,9 +120,20 @@ export class SupportedPaymentMethodDto {
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({
|
||||
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
|
||||
enum: [
|
||||
"REDIRECT",
|
||||
"LAUNCH_APP",
|
||||
"INVOKE_BRIDGE",
|
||||
"COLLECT_OTP",
|
||||
"SHOW_BILL_REFERENCE",
|
||||
],
|
||||
})
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
type:
|
||||
| "REDIRECT"
|
||||
| "LAUNCH_APP"
|
||||
| "INVOKE_BRIDGE"
|
||||
| "COLLECT_OTP"
|
||||
| "SHOW_BILL_REFERENCE";
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
@ApiPropertyOptional({
|
||||
@@ -134,6 +148,18 @@ export class ClientActionDto {
|
||||
description: "Set when type=LAUNCH_APP (mobile flow)",
|
||||
})
|
||||
shortCode?: string;
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Set when type=INVOKE_BRIDGE (telebirr mini app) — which SuperApp host bridge to call",
|
||||
enum: ["TELEBIRR"],
|
||||
})
|
||||
bridge?: "TELEBIRR";
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Set when type=INVOKE_BRIDGE (telebirr mini app). Signed query string handed verbatim " +
|
||||
"to the host bridge (js_fun_start_pay). NOT a URL — never navigate to it.",
|
||||
})
|
||||
rawRequest?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" })
|
||||
providerOrderId?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
||||
@@ -154,6 +180,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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { EmailClientService } from '../notifications/email-client.service';
|
||||
import { PaymentClientService } from './payment-client.service';
|
||||
import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types';
|
||||
import { PaymentPlatformDto } from './payments.dto';
|
||||
|
||||
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
|
||||
|
||||
@@ -119,7 +120,7 @@ export class SupplementaryChargesService {
|
||||
async pay(
|
||||
token: string,
|
||||
method: string,
|
||||
platform?: 'web' | 'mobile',
|
||||
platform?: PaymentPlatformDto,
|
||||
requestOrigin?: string | null,
|
||||
) {
|
||||
const charge = await this.getByToken(token); // validates status/expiry
|
||||
|
||||
Reference in New Issue
Block a user