refactor(subscription): tighten payment flow boundaries

This commit is contained in:
2026-06-30 15:36:49 +08:00
parent 61504050e5
commit 218df59345
9 changed files with 640 additions and 286 deletions
@@ -6,12 +6,17 @@
*/
import { useEffect, useRef, useState } from "react";
import { savePendingEzpayOrder } from "@/lib/payment/pending_payment_order";
import {
getPaymentUrl,
getStripeClientSecret,
isEzpayPayment,
launchEzpayRedirect,
} from "@/lib/payment/payment_launch";
import {
usePaymentDispatch,
usePaymentState,
} from "@/stores/payment/payment-context";
import { AppEnvUtil, Logger, Result } from "@/utils";
import { AppEnvUtil, Logger } from "@/utils";
import { StripePaymentDialog } from "./stripe-payment-dialog";
import dialogStyles from "./stripe-payment-dialog.module.css";
@@ -69,7 +74,7 @@ export function SubscriptionCheckoutButton({
orderId: payment.currentOrderId,
paymentUrl,
subscriptionType,
returnTo,
returnTo: returnTo ?? undefined,
onFailed: (errorMessage) =>
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }),
});
@@ -159,7 +164,7 @@ export function SubscriptionCheckoutButton({
orderId: payment.currentOrderId,
paymentUrl: ezpayPaymentUrl,
subscriptionType,
returnTo,
returnTo: returnTo ?? undefined,
onFailed: (errorMessage) => {
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage });
@@ -219,97 +224,6 @@ export function SubscriptionCheckoutButton({
);
}
function getPaymentUrl(payParams: Record<string, unknown>): string | null {
const keys = [
"cashierUrl",
"cashier_url",
"checkoutUrl",
"checkout_url",
"paymentUrl",
"payment_url",
"approvalUrl",
"approval_url",
"redirectUrl",
"redirect_url",
"url",
];
for (const key of keys) {
const value = payParams[key];
if (typeof value === "string" && value.length > 0) return value;
}
return null;
}
function isEzpayPayment(payParams: Record<string, unknown>): boolean {
const provider = payParams.provider;
return typeof provider === "string" && provider.toLowerCase() === "ezpay";
}
interface RedirectToEzpayInput {
orderId: string | null;
paymentUrl: string;
subscriptionType: "vip" | "topup";
returnTo?: "chat" | null;
onFailed: (errorMessage: string) => void;
}
async function launchEzpayRedirect({
orderId,
paymentUrl,
subscriptionType,
returnTo,
onFailed,
}: RedirectToEzpayInput): Promise<void> {
log.debug("[subscription-checkout] launchEzpayRedirect START", {
hasOrderId: Boolean(orderId),
orderId,
subscriptionType,
paymentUrl,
});
if (!orderId) {
const errorMessage = "Missing order id before opening Ezpay.";
log.error("[subscription-checkout] pending ezpay order save skipped", {
subscriptionType,
paymentUrl,
errorMessage,
});
onFailed(errorMessage);
return;
}
const saveResult = await savePendingEzpayOrder({
orderId,
subscriptionType,
...(returnTo ? { returnTo } : {}),
});
if (Result.isErr(saveResult)) {
const errorMessage =
"Could not save payment order before opening Ezpay. Please try again.";
log.error("[subscription-checkout] pending ezpay order save failed", {
orderId,
subscriptionType,
error: saveResult.error,
});
onFailed(errorMessage);
return;
}
log.debug("[subscription-checkout] pending ezpay order saved", {
orderId,
subscriptionType,
});
log.debug("[subscription-checkout] launchEzpayRedirect NOW", {
orderId,
subscriptionType,
paymentUrl,
});
window.location.href = paymentUrl;
}
interface EzpayRedirectConfirmDialogProps {
orderId: string;
isConfirming: boolean;
@@ -363,22 +277,3 @@ function EzpayRedirectConfirmDialog({
</div>
);
}
function getStripeClientSecret(
payParams: Record<string, unknown>,
): string | null {
const provider = payParams.provider;
const clientSecret = payParams.clientSecret ?? payParams.client_secret;
const isStripeProvider =
typeof provider !== "string" || provider.toLowerCase() === "stripe";
if (
isStripeProvider &&
typeof clientSecret === "string" &&
clientSecret.length > 0
) {
return clientSecret;
}
return null;
}