refactor(payment): share launch flow

This commit is contained in:
2026-07-09 12:38:53 +08:00
parent a92780373d
commit c67689f8a1
4 changed files with 290 additions and 258 deletions
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { shouldShowEzpayConfirmation } from "../use-payment-launch-flow";
describe("shouldShowEzpayConfirmation", () => {
it("shows confirmation for Ezpay in non-production builds", () => {
expect(
shouldShowEzpayConfirmation({
currentOrderId: "order-1",
isProduction: false,
payParams: { provider: "ezpay" },
paymentUrl: "https://pay.example/checkout",
}),
).toBe(true);
});
it("does not show confirmation in production", () => {
expect(
shouldShowEzpayConfirmation({
currentOrderId: "order-1",
isProduction: true,
payParams: { provider: "ezpay" },
paymentUrl: "https://pay.example/checkout",
}),
).toBe(false);
});
it("does not show confirmation without an order id", () => {
expect(
shouldShowEzpayConfirmation({
currentOrderId: null,
isProduction: false,
payParams: { provider: "ezpay" },
paymentUrl: "https://pay.example/checkout",
}),
).toBe(false);
});
it("does not show confirmation for non-Ezpay providers", () => {
expect(
shouldShowEzpayConfirmation({
currentOrderId: "order-1",
isProduction: false,
payParams: { provider: "stripe" },
paymentUrl: "https://pay.example/checkout",
}),
).toBe(false);
});
});
+208
View File
@@ -0,0 +1,208 @@
"use client";
import { type Dispatch, useEffect, useRef, useState } from "react";
import {
getPaymentUrl,
getStripeClientSecret,
isEzpayPayment,
launchEzpayRedirect,
} from "@/lib/payment/payment_launch";
import type {
PendingPaymentReturnTo,
PendingPaymentSubscriptionType,
} from "@/lib/payment/pending_payment_order";
import type {
PaymentContextState,
} from "@/stores/payment/payment-context";
import type { PaymentEvent } from "@/stores/payment/payment-events";
import { AppEnvUtil, Logger } from "@/utils";
const UNSUPPORTED_PAYMENT_PARAMS_MESSAGE =
"Payment parameters did not include a supported URL or Stripe client secret.";
export interface ShouldShowEzpayConfirmationInput {
currentOrderId: string | null;
isProduction: boolean;
payParams: Record<string, unknown> | null;
paymentUrl: string | null;
}
export interface UsePaymentLaunchFlowInput {
log: Logger;
logScope: string;
payment: PaymentContextState;
paymentDispatch: Dispatch<PaymentEvent>;
returnTo?: PendingPaymentReturnTo;
subscriptionType: PendingPaymentSubscriptionType;
}
export interface PaymentLaunchFlow {
ezpayPaymentUrl: string | null;
handleEzpayCancel: () => void;
handleEzpayConfirm: () => void;
handleStripeClose: () => void;
handleStripeConfirmed: () => void;
isConfirmingEzpay: boolean;
resetLaunchState: () => void;
shouldShowEzpayConfirmDialog: boolean;
shouldShowStripeDialog: boolean;
stripeClientSecret: string | null;
}
export function shouldShowEzpayConfirmation({
currentOrderId,
isProduction,
payParams,
paymentUrl,
}: ShouldShowEzpayConfirmationInput): boolean {
return Boolean(
!isProduction &&
payParams &&
isEzpayPayment(payParams) &&
paymentUrl &&
currentOrderId,
);
}
export function usePaymentLaunchFlow({
log,
logScope,
payment,
paymentDispatch,
returnTo,
subscriptionType,
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
const launchedNonceRef = useRef(0);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
string | null
>(null);
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams)
: null;
const ezpayPaymentUrl = payment.payParams
? getPaymentUrl(payment.payParams)
: null;
useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
return;
}
launchedNonceRef.current = payment.launchNonce;
const clientSecret = getStripeClientSecret(payment.payParams);
if (clientSecret) return;
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
const isEzpay = isEzpayPayment(payment.payParams);
if (!AppEnvUtil.isProduction() && isEzpay) {
log.debug(`[${logScope}] ezpay confirmation required`, {
orderId: payment.currentOrderId,
paymentUrl,
subscriptionType,
});
return;
}
if (isEzpay) {
void launchEzpayRedirect({
orderId: payment.currentOrderId,
paymentUrl,
subscriptionType,
...(returnTo ? { returnTo } : {}),
onFailed: (errorMessage) =>
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }),
});
return;
}
window.location.href = paymentUrl;
return;
}
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage: UNSUPPORTED_PAYMENT_PARAMS_MESSAGE,
});
}, [
log,
logScope,
payment.currentOrderId,
payment.launchNonce,
payment.payParams,
paymentDispatch,
returnTo,
subscriptionType,
]);
const shouldShowStripeDialog =
stripeClientSecret !== null &&
stripeClientSecret !== hiddenStripeClientSecret &&
!payment.isPaid;
const shouldShowEzpayConfirmDialog = shouldShowEzpayConfirmation({
currentOrderId: payment.currentOrderId,
isProduction: AppEnvUtil.isProduction(),
payParams: payment.payParams,
paymentUrl: ezpayPaymentUrl,
});
function resetLaunchState(): void {
setIsConfirmingEzpay(false);
setHiddenStripeClientSecret(null);
}
function handleStripeClose(): void {
if (stripeClientSecret) {
setHiddenStripeClientSecret(stripeClientSecret);
}
if (payment.orderStatus !== "paid") {
paymentDispatch({ type: "PaymentReset" });
}
}
function handleStripeConfirmed(): void {
if (stripeClientSecret) {
setHiddenStripeClientSecret(stripeClientSecret);
}
}
function handleEzpayConfirm(): void {
if (!payment.currentOrderId || !ezpayPaymentUrl) return;
setIsConfirmingEzpay(true);
void launchEzpayRedirect({
orderId: payment.currentOrderId,
paymentUrl: ezpayPaymentUrl,
subscriptionType,
...(returnTo ? { returnTo } : {}),
onFailed: (errorMessage) => {
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage });
},
});
}
function handleEzpayCancel(): void {
log.debug(`[${logScope}] ezpay confirmation cancelled`, {
orderId: payment.currentOrderId,
subscriptionType,
});
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentReset" });
}
return {
ezpayPaymentUrl,
handleEzpayCancel,
handleEzpayConfirm,
handleStripeClose,
handleStripeConfirmed,
isConfirmingEzpay,
resetLaunchState,
shouldShowEzpayConfirmDialog,
shouldShowStripeDialog,
stripeClientSecret,
};
}
@@ -4,19 +4,12 @@
* *
* 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。 * 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。
*/ */
import { useEffect, useRef, useState } from "react"; import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import {
getPaymentUrl,
getStripeClientSecret,
isEzpayPayment,
launchEzpayRedirect,
} from "@/lib/payment/payment_launch";
import { import {
usePaymentDispatch, usePaymentDispatch,
usePaymentState, usePaymentState,
} from "@/stores/payment/payment-context"; } from "@/stores/payment/payment-context";
import { AppEnvUtil, Logger } from "@/utils"; import { Logger } from "@/utils";
import { StripePaymentDialog } from "./stripe-payment-dialog"; import { StripePaymentDialog } from "./stripe-payment-dialog";
import dialogStyles from "./stripe-payment-dialog.module.css"; import dialogStyles from "./stripe-payment-dialog.module.css";
@@ -39,65 +32,14 @@ export function SubscriptionCheckoutButton({
}: SubscriptionCheckoutButtonProps) { }: SubscriptionCheckoutButtonProps) {
const payment = usePaymentState(); const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch(); const paymentDispatch = usePaymentDispatch();
const launchedNonceRef = useRef(0); const paymentLaunch = usePaymentLaunchFlow({
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< log,
string | null logScope: "subscription-checkout",
>(null); payment,
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
return;
}
launchedNonceRef.current = payment.launchNonce;
const clientSecret = getStripeClientSecret(payment.payParams);
if (clientSecret) {
return;
}
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
const isEzpay = isEzpayPayment(payment.payParams);
if (!AppEnvUtil.isProduction() && isEzpay) {
log.debug("[subscription-checkout] ezpay confirmation required", {
orderId: payment.currentOrderId,
paymentUrl,
subscriptionType,
});
return;
}
if (isEzpay) {
void launchEzpayRedirect({
orderId: payment.currentOrderId,
paymentUrl,
subscriptionType,
returnTo: returnTo ?? undefined,
onFailed: (errorMessage) =>
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }),
});
return;
}
window.location.href = paymentUrl;
return;
}
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage:
"Payment parameters did not include a supported URL or Stripe client secret.",
});
}, [
payment.currentOrderId,
payment.launchNonce,
payment.payParams,
paymentDispatch, paymentDispatch,
returnTo, returnTo: returnTo ?? undefined,
subscriptionType, subscriptionType,
]); });
const isLoading = const isLoading =
payment.isCreatingOrder || payment.isCreatingOrder ||
@@ -118,69 +60,10 @@ export function SubscriptionCheckoutButton({
const handleClick = () => { const handleClick = () => {
if (disabled || isLoading) return; if (disabled || isLoading) return;
setIsConfirmingEzpay(false); paymentLaunch.resetLaunchState();
setHiddenStripeClientSecret(null);
paymentDispatch({ type: "PaymentCreateOrderSubmitted" }); paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
}; };
const handleStripeClose = () => {
if (stripeClientSecret) {
setHiddenStripeClientSecret(stripeClientSecret);
}
if (payment.orderStatus !== "paid") {
paymentDispatch({ type: "PaymentReset" });
}
};
const handleStripeConfirmed = () => {
if (stripeClientSecret) {
setHiddenStripeClientSecret(stripeClientSecret);
}
};
const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams)
: null;
const shouldShowStripeDialog =
stripeClientSecret !== null &&
stripeClientSecret !== hiddenStripeClientSecret &&
!payment.isPaid;
const ezpayPaymentUrl = payment.payParams
? getPaymentUrl(payment.payParams)
: null;
const shouldShowEzpayConfirmDialog =
!AppEnvUtil.isProduction() &&
payment.payParams &&
isEzpayPayment(payment.payParams) &&
ezpayPaymentUrl &&
payment.currentOrderId
? true
: false;
const handleEzpayConfirm = () => {
if (!payment.currentOrderId || !ezpayPaymentUrl) return;
setIsConfirmingEzpay(true);
void launchEzpayRedirect({
orderId: payment.currentOrderId,
paymentUrl: ezpayPaymentUrl,
subscriptionType,
returnTo: returnTo ?? undefined,
onFailed: (errorMessage) => {
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage });
},
});
};
const handleEzpayCancel = () => {
log.debug("[subscription-checkout] ezpay confirmation cancelled", {
orderId: payment.currentOrderId,
subscriptionType,
});
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentReset" });
};
return ( return (
<> <>
<SubscriptionCtaButton <SubscriptionCtaButton
@@ -205,19 +88,19 @@ export function SubscriptionCheckoutButton({
{payment.errorMessage} {payment.errorMessage}
</p> </p>
) : null} ) : null}
{shouldShowEzpayConfirmDialog ? ( {paymentLaunch.shouldShowEzpayConfirmDialog ? (
<EzpayRedirectConfirmDialog <EzpayRedirectConfirmDialog
orderId={payment.currentOrderId ?? ""} orderId={payment.currentOrderId ?? ""}
isConfirming={isConfirmingEzpay} isConfirming={paymentLaunch.isConfirmingEzpay}
onCancel={handleEzpayCancel} onCancel={paymentLaunch.handleEzpayCancel}
onConfirm={handleEzpayConfirm} onConfirm={paymentLaunch.handleEzpayConfirm}
/> />
) : null} ) : null}
{shouldShowStripeDialog ? ( {paymentLaunch.shouldShowStripeDialog && paymentLaunch.stripeClientSecret ? (
<StripePaymentDialog <StripePaymentDialog
clientSecret={stripeClientSecret} clientSecret={paymentLaunch.stripeClientSecret}
onClose={handleStripeClose} onClose={paymentLaunch.handleStripeClose}
onConfirmed={handleStripeConfirmed} onConfirmed={paymentLaunch.handleStripeConfirmed}
/> />
) : null} ) : null}
</> </>
+16 -124
View File
@@ -1,19 +1,12 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import {
getPaymentUrl,
getStripeClientSecret,
isEzpayPayment,
launchEzpayRedirect,
} from "@/lib/payment/payment_launch";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { import {
usePaymentDispatch, usePaymentDispatch,
usePaymentState, usePaymentState,
} from "@/stores/payment/payment-context"; } from "@/stores/payment/payment-context";
import { AppEnvUtil, Logger } from "@/utils"; import { Logger } from "@/utils";
import { StripePaymentDialog } from "../subscription/components/stripe-payment-dialog"; import { StripePaymentDialog } from "../subscription/components/stripe-payment-dialog";
import dialogStyles from "../subscription/components/stripe-payment-dialog.module.css"; import dialogStyles from "../subscription/components/stripe-payment-dialog.module.css";
@@ -34,80 +27,16 @@ export function TipCheckoutButton({
}: TipCheckoutButtonProps) { }: TipCheckoutButtonProps) {
const payment = usePaymentState(); const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch(); const paymentDispatch = usePaymentDispatch();
const launchedNonceRef = useRef(0); const paymentLaunch = usePaymentLaunchFlow({
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< log,
string | null logScope: "tip-checkout",
>(null); payment,
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
return;
}
launchedNonceRef.current = payment.launchNonce;
const clientSecret = getStripeClientSecret(payment.payParams);
if (clientSecret) return;
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
const isEzpay = isEzpayPayment(payment.payParams);
if (!AppEnvUtil.isProduction() && isEzpay) {
log.debug("[tip-checkout] ezpay confirmation required", {
orderId: payment.currentOrderId,
paymentUrl,
});
return;
}
if (isEzpay) {
void launchEzpayRedirect({
orderId: payment.currentOrderId,
paymentUrl,
subscriptionType: "tip",
onFailed: (errorMessage) =>
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }),
});
return;
}
window.location.href = paymentUrl;
return;
}
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage:
"Payment parameters did not include a supported URL or Stripe client secret.",
});
}, [
payment.currentOrderId,
payment.launchNonce,
payment.payParams,
paymentDispatch, paymentDispatch,
]); subscriptionType: "tip",
});
const isLoading = const isLoading =
isAuthLoading || payment.isCreatingOrder || payment.isPollingOrder; isAuthLoading || payment.isCreatingOrder || payment.isPollingOrder;
const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams)
: null;
const shouldShowStripeDialog =
stripeClientSecret !== null &&
stripeClientSecret !== hiddenStripeClientSecret &&
!payment.isPaid;
const ezpayPaymentUrl = payment.payParams
? getPaymentUrl(payment.payParams)
: null;
const shouldShowEzpayConfirmDialog =
!AppEnvUtil.isProduction() &&
payment.payParams &&
isEzpayPayment(payment.payParams) &&
ezpayPaymentUrl &&
payment.currentOrderId
? true
: false;
const label = payment.isPollingOrder const label = payment.isPollingOrder
? "Processing payment..." ? "Processing payment..."
: payment.isCreatingOrder : payment.isCreatingOrder
@@ -116,43 +45,6 @@ export function TipCheckoutButton({
? "Thanks for the coffee" ? "Thanks for the coffee"
: "Order and Buy"; : "Order and Buy";
const handleStripeClose = () => {
if (stripeClientSecret) {
setHiddenStripeClientSecret(stripeClientSecret);
}
if (payment.orderStatus !== "paid") {
paymentDispatch({ type: "PaymentReset" });
}
};
const handleStripeConfirmed = () => {
if (stripeClientSecret) {
setHiddenStripeClientSecret(stripeClientSecret);
}
};
const handleEzpayConfirm = () => {
if (!payment.currentOrderId || !ezpayPaymentUrl) return;
setIsConfirmingEzpay(true);
void launchEzpayRedirect({
orderId: payment.currentOrderId,
paymentUrl: ezpayPaymentUrl,
subscriptionType: "tip",
onFailed: (errorMessage) => {
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage });
},
});
};
const handleEzpayCancel = () => {
log.debug("[tip-checkout] ezpay confirmation cancelled", {
orderId: payment.currentOrderId,
});
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentReset" });
};
return ( return (
<> <>
<button <button
@@ -168,20 +60,20 @@ export function TipCheckoutButton({
{payment.errorMessage} {payment.errorMessage}
</p> </p>
) : null} ) : null}
{shouldShowEzpayConfirmDialog ? ( {paymentLaunch.shouldShowEzpayConfirmDialog ? (
<EzpayRedirectConfirmDialog <EzpayRedirectConfirmDialog
orderId={payment.currentOrderId ?? ""} orderId={payment.currentOrderId ?? ""}
isConfirming={isConfirmingEzpay} isConfirming={paymentLaunch.isConfirmingEzpay}
onCancel={handleEzpayCancel} onCancel={paymentLaunch.handleEzpayCancel}
onConfirm={handleEzpayConfirm} onConfirm={paymentLaunch.handleEzpayConfirm}
/> />
) : null} ) : null}
{shouldShowStripeDialog ? ( {paymentLaunch.shouldShowStripeDialog && paymentLaunch.stripeClientSecret ? (
<StripePaymentDialog <StripePaymentDialog
clientSecret={stripeClientSecret} clientSecret={paymentLaunch.stripeClientSecret}
returnPath={ROUTES.tip} returnPath={ROUTES.tip}
onClose={handleStripeClose} onClose={paymentLaunch.handleStripeClose}
onConfirmed={handleStripeConfirmed} onConfirmed={paymentLaunch.handleStripeConfirmed}
/> />
) : null} ) : null}
</> </>