From 218df593450b6e2720cfbd73fbd23c9e217bc897 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 30 Jun 2026 15:36:49 +0800 Subject: [PATCH] refactor(subscription): tighten payment flow boundaries --- .../subscription-screen.helpers.test.ts | 149 +++++++++++++ .../subscription-checkout-button.tsx | 123 +---------- .../subscription-screen.helpers.ts | 58 +++++ src/app/subscription/subscription-screen.tsx | 208 +++--------------- .../use-subscription-payment-flow.ts | 148 +++++++++++++ .../__tests__/subscription_exit.test.ts | 48 ++++ src/lib/navigation/subscription_exit.ts | 28 +++ .../payment/__tests__/payment_launch.test.ts | 43 ++++ src/lib/payment/payment_launch.ts | 121 ++++++++++ 9 files changed, 640 insertions(+), 286 deletions(-) create mode 100644 src/app/subscription/__tests__/subscription-screen.helpers.test.ts create mode 100644 src/app/subscription/use-subscription-payment-flow.ts create mode 100644 src/lib/navigation/__tests__/subscription_exit.test.ts create mode 100644 src/lib/navigation/subscription_exit.ts create mode 100644 src/lib/payment/__tests__/payment_launch.test.ts create mode 100644 src/lib/payment/payment_launch.ts diff --git a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts new file mode 100644 index 00000000..90aaf000 --- /dev/null +++ b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; + +import { PaymentPlan } from "@/data/dto/payment"; + +import { + findSelectedSubscriptionPlan, + getDefaultSubscriptionPlanId, + toCoinsOfferPlanViews, + toVipOfferPlanViews, +} from "../subscription-screen.helpers"; + +function makePlan( + overrides: Partial[0]>, +): PaymentPlan { + return PaymentPlan.from({ + planId: "plan", + planName: "Plan", + orderType: "vip_monthly", + vipDays: 30, + dolAmount: null, + creditBalance: 0, + amountCents: 1990, + originalAmountCents: null, + dailyPriceCents: null, + currency: "USD", + ...overrides, + }); +} + +describe("subscription screen helpers", () => { + it("maps at most three VIP plans and all coin plans", () => { + const plans = [ + makePlan({ planId: "vip_monthly", planName: "Monthly" }), + makePlan({ planId: "vip_quarterly", planName: "Quarterly", vipDays: 90 }), + makePlan({ planId: "vip_annual", planName: "Annual", vipDays: 365 }), + makePlan({ planId: "vip_extra", planName: "Extra", vipDays: 30 }), + makePlan({ + planId: "coin_1000", + planName: "Coins", + orderType: "coins_1000", + vipDays: null, + dolAmount: 1000, + amountCents: 990, + }), + ]; + + expect(toVipOfferPlanViews(plans).map((plan) => plan.id)).toEqual([ + "vip_monthly", + "vip_quarterly", + "vip_annual", + ]); + expect(toCoinsOfferPlanViews(plans)).toMatchObject([ + { + id: "coin_1000", + coins: 1000, + price: "9,9", + currency: "US$", + }, + ]); + }); + + it("finds selected coin plans in VIP mode", () => { + const selected = findSelectedSubscriptionPlan({ + canSubscribeVip: true, + selectedPlanId: "coin_1000", + vipPlans: [ + { + id: "vip_monthly", + title: "Monthly", + price: "19,9", + currency: "usd", + originalPrice: "", + }, + ], + coinPlans: [{ id: "coin_1000", coins: 1000, price: "9,9", currency: "US$" }], + }); + + expect(selected?.id).toBe("coin_1000"); + }); + + it("selects the first VIP plan by default when VIP can be purchased", () => { + expect( + getDefaultSubscriptionPlanId({ + canSubscribeVip: true, + selectedPlanId: "", + status: "ready", + isLoadingPlans: false, + selectedPlan: null, + vipPlans: [ + { + id: "vip_monthly", + title: "Monthly", + price: "19,9", + currency: "usd", + originalPrice: "", + }, + ], + coinPlans: [], + }), + ).toBe("vip_monthly"); + }); + + it("selects the first coin plan by default in top-up-only mode", () => { + expect( + getDefaultSubscriptionPlanId({ + canSubscribeVip: false, + selectedPlanId: "", + status: "ready", + isLoadingPlans: false, + selectedPlan: null, + vipPlans: [], + coinPlans: [ + { + id: "coin_1000", + coins: 1000, + price: "9,9", + currency: "US$", + }, + ], + }), + ).toBe("coin_1000"); + }); + + it("does not override an existing selected plan", () => { + expect( + getDefaultSubscriptionPlanId({ + canSubscribeVip: false, + selectedPlanId: "coin_1000", + status: "ready", + isLoadingPlans: false, + selectedPlan: { + id: "coin_1000", + coins: 1000, + price: "9,9", + currency: "US$", + }, + vipPlans: [], + coinPlans: [ + { + id: "coin_1000", + coins: 1000, + price: "9,9", + currency: "US$", + }, + ], + }), + ).toBeNull(); + }); +}); diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index 12022336..4438509b 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -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 | 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): 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 { - 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({ ); } - -function getStripeClientSecret( - payParams: Record, -): 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; -} diff --git a/src/app/subscription/subscription-screen.helpers.ts b/src/app/subscription/subscription-screen.helpers.ts index 3e8d4225..1d9935a5 100644 --- a/src/app/subscription/subscription-screen.helpers.ts +++ b/src/app/subscription/subscription-screen.helpers.ts @@ -64,3 +64,61 @@ export function toCoinsOfferPlanView(plan: PaymentPlan): CoinsOfferPlanView { currency: formatCoinCurrency(plan.currency), }; } + +export function toVipOfferPlanViews( + plans: readonly PaymentPlan[], +): VipOfferPlanView[] { + return plans.filter(isVipPlan).slice(0, 3).map(toVipOfferPlanView); +} + +export function toCoinsOfferPlanViews( + plans: readonly PaymentPlan[], +): CoinsOfferPlanView[] { + return plans.filter(isCreditPlan).map(toCoinsOfferPlanView); +} + +export function findSelectedSubscriptionPlan(input: { + canSubscribeVip: boolean; + selectedPlanId: string; + vipPlans: readonly VipOfferPlanView[]; + coinPlans: readonly CoinsOfferPlanView[]; +}): VipOfferPlanView | CoinsOfferPlanView | null { + const plans = input.canSubscribeVip + ? [...input.vipPlans, ...input.coinPlans] + : input.coinPlans; + return plans.find((plan) => plan.id === input.selectedPlanId) ?? null; +} + +export function getDefaultSubscriptionPlanId(input: { + canSubscribeVip: boolean; + selectedPlanId: string; + status: string; + isLoadingPlans: boolean; + selectedPlan: VipOfferPlanView | CoinsOfferPlanView | null; + vipPlans: readonly VipOfferPlanView[]; + coinPlans: readonly CoinsOfferPlanView[]; +}): string | null { + if (input.canSubscribeVip) { + const firstVipPlanId = input.vipPlans[0]?.id ?? ""; + const hasSelectedVipPlan = input.vipPlans.some( + (plan) => plan.id === input.selectedPlanId, + ); + const hasSelectedCoinsPlan = input.coinPlans.some( + (plan) => plan.id === input.selectedPlanId, + ); + const canSelectPlan = + firstVipPlanId.length > 0 && + (input.status === "ready" || + input.status === "paid" || + input.status === "failed"); + + if (!canSelectPlan || hasSelectedVipPlan || hasSelectedCoinsPlan) { + return null; + } + return firstVipPlanId; + } + + if (input.isLoadingPlans || input.coinPlans.length === 0) return null; + if (input.selectedPlan !== null) return null; + return input.coinPlans[0]?.id ?? null; +} diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index ed59615c..98996bc4 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -1,21 +1,9 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useEffect, useMemo } from "react"; import { BackButton } from "@/app/_components"; import { Checkbox, MobileShell } from "@/app/_components/core"; import { AppConstants } from "@/core/app_constants"; -import { consumePendingChatImageReturn } from "@/lib/navigation/chat_image_return_session"; -import { - clearPendingPaymentOrder, - getPendingPaymentOrderForType, -} from "@/lib/payment/pending_payment_order"; -import { - usePaymentDispatch, - usePaymentState, -} from "@/stores/payment/payment-context"; -import { useUserDispatch } from "@/stores/user/user-context"; -import { ROUTES } from "@/router/routes"; import { SubscriptionCheckoutButton, @@ -26,12 +14,13 @@ import { } from "./components"; import styles from "./components/subscription-screen.module.css"; import { - isCreditPlan, - isVipPlan, - toCoinsOfferPlanView, - toVipOfferPlanView, + findSelectedSubscriptionPlan, + getDefaultSubscriptionPlanId, + toCoinsOfferPlanViews, + toVipOfferPlanViews, type SubscriptionType, } from "./subscription-screen.helpers"; +import { useSubscriptionPaymentFlow } from "./use-subscription-payment-flow"; export type { SubscriptionType } from "./subscription-screen.helpers"; @@ -46,180 +35,55 @@ export function SubscriptionScreen({ shouldResumePendingOrder = false, returnTo = null, }: SubscriptionScreenProps) { - const router = useRouter(); - const userDispatch = useUserDispatch(); - const payment = usePaymentState(); - const paymentDispatch = usePaymentDispatch(); - const refreshedPaidOrderRef = useRef(null); - const resumedPendingOrderRef = useRef(null); - const successDialogShownOrderRef = useRef(null); - const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] = - useState(false); + const { + payment, + paymentDispatch, + showPaymentSuccessDialog, + handleBackClick, + handlePaymentSuccessClose, + } = useSubscriptionPaymentFlow({ + subscriptionType, + shouldResumePendingOrder, + returnTo, + }); const canSubscribeVip = subscriptionType === "vip"; - useEffect(() => { - if (payment.status === "idle") { - paymentDispatch({ type: "PaymentInit" }); - } - }, [payment.status, paymentDispatch]); - - useEffect(() => { - if (!payment.isPaid || !payment.currentOrderId) return; - if (refreshedPaidOrderRef.current === payment.currentOrderId) return; - refreshedPaidOrderRef.current = payment.currentOrderId; - userDispatch({ type: "UserFetch" }); - }, [payment.currentOrderId, payment.isPaid, userDispatch]); - - useEffect(() => { - if (!payment.isPaid || !payment.currentOrderId) return; - if (successDialogShownOrderRef.current === payment.currentOrderId) return; - - successDialogShownOrderRef.current = payment.currentOrderId; - setShowPaymentSuccessDialog(true); - }, [payment.currentOrderId, payment.isPaid]); - - useEffect(() => { - const canInspectPendingOrder = - payment.status === "ready" || - (!shouldResumePendingOrder && - (payment.isPollingOrder || payment.isPaid || payment.status === "failed")); - if (!canInspectPendingOrder) return; - - let cancelled = false; - - const handlePendingOrder = async () => { - const result = await getPendingPaymentOrderForType(subscriptionType); - if (cancelled || !result.success || result.data === null) return; - - if (!shouldResumePendingOrder) { - await clearPendingPaymentOrder(); - if ( - payment.currentOrderId === result.data.orderId && - (payment.isPollingOrder || payment.isPaid || payment.status === "failed") - ) { - paymentDispatch({ type: "PaymentReset" }); - } - return; - } - - if (payment.currentOrderId === result.data.orderId) return; - if (resumedPendingOrderRef.current === result.data.orderId) return; - - resumedPendingOrderRef.current = result.data.orderId; - paymentDispatch({ - type: "PaymentReturned", - orderId: result.data.orderId, - createdAt: result.data.createdAt, - }); - }; - - void handlePendingOrder(); - return () => { - cancelled = true; - }; - }, [ - payment.currentOrderId, - payment.isPaid, - payment.isPollingOrder, - payment.status, - paymentDispatch, - shouldResumePendingOrder, - subscriptionType, - ]); - - useEffect(() => { - if (!payment.currentOrderId) return; - if (!payment.isPaid && payment.status !== "failed") return; - - void clearPendingPaymentOrder(); - }, [payment.currentOrderId, payment.isPaid, payment.status]); - const vipOfferPlans = useMemo( - () => - payment.plans - .filter(isVipPlan) - .slice(0, 3) - .map(toVipOfferPlanView), + () => toVipOfferPlanViews(payment.plans), [payment.plans], ); const directCoinsPlans = useMemo( - () => payment.plans.filter(isCreditPlan).map(toCoinsOfferPlanView), + () => toCoinsOfferPlanViews(payment.plans), [payment.plans], ); - const selectedPlan = - (canSubscribeVip - ? [...vipOfferPlans, ...directCoinsPlans].find( - (plan) => plan.id === payment.selectedPlanId, - ) - : directCoinsPlans.find((plan) => plan.id === payment.selectedPlanId)) ?? - null; + const selectedPlan = findSelectedSubscriptionPlan({ + canSubscribeVip, + selectedPlanId: payment.selectedPlanId, + vipPlans: vipOfferPlans, + coinPlans: directCoinsPlans, + }); const isPaymentBusy = payment.isCreatingOrder || payment.isPollingOrder; const canActivate = selectedPlan !== null && payment.agreed && !isPaymentBusy; - const consumeSubscriptionExitUrl = (): string | null => { - const pendingImageReturn = consumePendingChatImageReturn(); - if (pendingImageReturn) return pendingImageReturn.returnUrl; - return null; - }; - - const handleBackClick = () => { - router.replace( - consumeSubscriptionExitUrl() ?? - (returnTo === "chat" ? ROUTES.chat : ROUTES.sidebar), - ); - }; - - const finishPaymentSuccessClose = () => { - setShowPaymentSuccessDialog(false); - paymentDispatch({ type: "PaymentReset" }); - const pendingExitUrl = consumeSubscriptionExitUrl(); - if (pendingExitUrl) { - router.replace(pendingExitUrl); - return; - } - if (returnTo === "chat") { - router.replace(ROUTES.chat); - } - }; - - const handlePaymentSuccessClose = () => { - finishPaymentSuccessClose(); - }; - useEffect(() => { - if (canSubscribeVip) { - const firstVipPlanId = vipOfferPlans[0]?.id ?? ""; - const hasSelectedVipPlan = vipOfferPlans.some( - (plan) => plan.id === payment.selectedPlanId, - ); - const hasSelectedCoinsPlan = directCoinsPlans.some( - (plan) => plan.id === payment.selectedPlanId, - ); - const canSelectPlan = - firstVipPlanId.length > 0 && - (payment.status === "ready" || - payment.status === "paid" || - payment.status === "failed"); - - if (!canSelectPlan || hasSelectedVipPlan || hasSelectedCoinsPlan) return; - - paymentDispatch({ - type: "PaymentPlanSelected", - planId: firstVipPlanId, - }); - return; - } - - if (payment.isLoadingPlans || directCoinsPlans.length === 0) return; - if (selectedPlan !== null) return; + const defaultPlanId = getDefaultSubscriptionPlanId({ + canSubscribeVip, + selectedPlanId: payment.selectedPlanId, + status: payment.status, + isLoadingPlans: payment.isLoadingPlans, + selectedPlan, + vipPlans: vipOfferPlans, + coinPlans: directCoinsPlans, + }); + if (!defaultPlanId) return; paymentDispatch({ type: "PaymentPlanSelected", - planId: directCoinsPlans[0]?.id ?? "", + planId: defaultPlanId, }); }, [ canSubscribeVip, diff --git a/src/app/subscription/use-subscription-payment-flow.ts b/src/app/subscription/use-subscription-payment-flow.ts new file mode 100644 index 00000000..9f8219ff --- /dev/null +++ b/src/app/subscription/use-subscription-payment-flow.ts @@ -0,0 +1,148 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { + consumeSubscriptionExitUrl, + consumeSubscriptionExplicitExitUrl, +} from "@/lib/navigation/subscription_exit"; +import { + clearPendingPaymentOrder, + getPendingPaymentOrderForType, +} from "@/lib/payment/pending_payment_order"; +import { + usePaymentDispatch, + usePaymentState, +} from "@/stores/payment/payment-context"; +import { useUserDispatch } from "@/stores/user/user-context"; + +import type { SubscriptionType } from "./subscription-screen.helpers"; + +export interface UseSubscriptionPaymentFlowInput { + subscriptionType: SubscriptionType; + shouldResumePendingOrder: boolean; + returnTo: "chat" | null; +} + +export function useSubscriptionPaymentFlow({ + subscriptionType, + shouldResumePendingOrder, + returnTo, +}: UseSubscriptionPaymentFlowInput) { + const router = useRouter(); + const userDispatch = useUserDispatch(); + const payment = usePaymentState(); + const paymentDispatch = usePaymentDispatch(); + const refreshedPaidOrderRef = useRef(null); + const resumedPendingOrderRef = useRef(null); + const successDialogShownOrderRef = useRef(null); + const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] = + useState(false); + + useEffect(() => { + if (payment.status === "idle") { + paymentDispatch({ type: "PaymentInit" }); + } + }, [payment.status, paymentDispatch]); + + useEffect(() => { + if (!payment.isPaid || !payment.currentOrderId) return; + if (refreshedPaidOrderRef.current === payment.currentOrderId) return; + refreshedPaidOrderRef.current = payment.currentOrderId; + userDispatch({ type: "UserFetch" }); + }, [payment.currentOrderId, payment.isPaid, userDispatch]); + + useEffect(() => { + if (!payment.isPaid || !payment.currentOrderId) return; + if (successDialogShownOrderRef.current === payment.currentOrderId) return; + + successDialogShownOrderRef.current = payment.currentOrderId; + setShowPaymentSuccessDialog(true); + }, [payment.currentOrderId, payment.isPaid]); + + useEffect(() => { + const canInspectPendingOrder = + payment.status === "ready" || + (!shouldResumePendingOrder && + (payment.isPollingOrder || + payment.isPaid || + payment.status === "failed")); + if (!canInspectPendingOrder) return; + + let cancelled = false; + + const handlePendingOrder = async () => { + const result = await getPendingPaymentOrderForType(subscriptionType); + if (cancelled || !result.success || result.data === null) return; + + if (!shouldResumePendingOrder) { + await clearPendingPaymentOrder(); + if ( + payment.currentOrderId === result.data.orderId && + (payment.isPollingOrder || + payment.isPaid || + payment.status === "failed") + ) { + paymentDispatch({ type: "PaymentReset" }); + } + return; + } + + if (payment.currentOrderId === result.data.orderId) return; + if (resumedPendingOrderRef.current === result.data.orderId) return; + + resumedPendingOrderRef.current = result.data.orderId; + paymentDispatch({ + type: "PaymentReturned", + orderId: result.data.orderId, + createdAt: result.data.createdAt, + }); + }; + + void handlePendingOrder(); + return () => { + cancelled = true; + }; + }, [ + payment.currentOrderId, + payment.isPaid, + payment.isPollingOrder, + payment.status, + paymentDispatch, + shouldResumePendingOrder, + subscriptionType, + ]); + + useEffect(() => { + if (!payment.currentOrderId) return; + if (!payment.isPaid && payment.status !== "failed") return; + + void clearPendingPaymentOrder(); + }, [payment.currentOrderId, payment.isPaid, payment.status]); + + const handleBackClick = () => { + router.replace(consumeSubscriptionExitUrl(returnTo)); + }; + + const handlePaymentSuccessClose = () => { + setShowPaymentSuccessDialog(false); + paymentDispatch({ type: "PaymentReset" }); + const pendingExitUrl = consumeSubscriptionExplicitExitUrl(); + if (pendingExitUrl) { + router.replace(pendingExitUrl); + return; + } + if (returnTo === "chat") { + router.replace(consumeSubscriptionExitUrl(returnTo)); + } + }; + + return { + payment, + paymentDispatch, + showPaymentSuccessDialog, + handleBackClick, + handlePaymentSuccessClose, + }; +} diff --git a/src/lib/navigation/__tests__/subscription_exit.test.ts b/src/lib/navigation/__tests__/subscription_exit.test.ts new file mode 100644 index 00000000..e936fce2 --- /dev/null +++ b/src/lib/navigation/__tests__/subscription_exit.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ROUTES } from "@/router/routes"; + +import { consumePendingChatImageReturn } from "../chat_image_return_session"; +import { + consumeSubscriptionExitUrl, + consumeSubscriptionExplicitExitUrl, + getSubscriptionFallbackExitUrl, +} from "../subscription_exit"; + +vi.mock("../chat_image_return_session", () => ({ + consumePendingChatImageReturn: vi.fn(), +})); + +const consumePendingChatImageReturnMock = vi.mocked( + consumePendingChatImageReturn, +); + +describe("subscription exit helpers", () => { + beforeEach(() => { + consumePendingChatImageReturnMock.mockReset(); + }); + + it("uses explicit chat image return urls first", () => { + consumePendingChatImageReturnMock.mockReturnValue({ + reason: "image_paywall", + messageId: "msg_1", + returnUrl: "/chat/image/msg_1", + createdAt: 1, + }); + + expect(consumeSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1"); + }); + + it("falls back to chat when requested", () => { + consumePendingChatImageReturnMock.mockReturnValue(null); + + expect(consumeSubscriptionExitUrl("chat")).toBe(ROUTES.chat); + }); + + it("falls back to sidebar by default", () => { + consumePendingChatImageReturnMock.mockReturnValue(null); + + expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.sidebar); + expect(consumeSubscriptionExitUrl(null)).toBe(ROUTES.sidebar); + }); +}); diff --git a/src/lib/navigation/subscription_exit.ts b/src/lib/navigation/subscription_exit.ts new file mode 100644 index 00000000..561a31dc --- /dev/null +++ b/src/lib/navigation/subscription_exit.ts @@ -0,0 +1,28 @@ +"use client"; + +import { ROUTES } from "@/router/routes"; + +import { consumePendingChatImageReturn } from "./chat_image_return_session"; + +export type SubscriptionReturnTo = "chat" | null; + +export function consumeSubscriptionExplicitExitUrl(): string | null { + const pendingImageReturn = consumePendingChatImageReturn(); + if (pendingImageReturn) return pendingImageReturn.returnUrl; + return null; +} + +export function getSubscriptionFallbackExitUrl( + returnTo: SubscriptionReturnTo, +): string { + return returnTo === "chat" ? ROUTES.chat : ROUTES.sidebar; +} + +export function consumeSubscriptionExitUrl( + returnTo: SubscriptionReturnTo, +): string { + return ( + consumeSubscriptionExplicitExitUrl() ?? + getSubscriptionFallbackExitUrl(returnTo) + ); +} diff --git a/src/lib/payment/__tests__/payment_launch.test.ts b/src/lib/payment/__tests__/payment_launch.test.ts new file mode 100644 index 00000000..de18abf6 --- /dev/null +++ b/src/lib/payment/__tests__/payment_launch.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { + getPaymentUrl, + getStripeClientSecret, + isEzpayPayment, +} from "../payment_launch"; + +describe("payment launch helpers", () => { + it("extracts supported redirect urls from payment params", () => { + expect(getPaymentUrl({ cashier_url: "https://pay.example/cashier" })).toBe( + "https://pay.example/cashier", + ); + expect(getPaymentUrl({ checkoutUrl: "https://pay.example/checkout" })).toBe( + "https://pay.example/checkout", + ); + expect(getPaymentUrl({ url: "" })).toBeNull(); + }); + + it("detects ezpay provider case-insensitively", () => { + expect(isEzpayPayment({ provider: "ezpay" })).toBe(true); + expect(isEzpayPayment({ provider: "EZPAY" })).toBe(true); + expect(isEzpayPayment({ provider: "stripe" })).toBe(false); + }); + + it("extracts stripe client secrets only for stripe-like providers", () => { + expect( + getStripeClientSecret({ + provider: "stripe", + clientSecret: "pi_secret", + }), + ).toBe("pi_secret"); + expect(getStripeClientSecret({ client_secret: "pi_secret_snake" })).toBe( + "pi_secret_snake", + ); + expect( + getStripeClientSecret({ + provider: "ezpay", + clientSecret: "pi_secret", + }), + ).toBeNull(); + }); +}); diff --git a/src/lib/payment/payment_launch.ts b/src/lib/payment/payment_launch.ts new file mode 100644 index 00000000..d533b76e --- /dev/null +++ b/src/lib/payment/payment_launch.ts @@ -0,0 +1,121 @@ +"use client"; + +import { Logger, Result } from "@/utils"; + +import { + savePendingEzpayOrder, + type PendingPaymentReturnTo, + type PendingPaymentSubscriptionType, +} from "./pending_payment_order"; + +const log = new Logger("LibPaymentPaymentLaunch"); + +export function getPaymentUrl(payParams: Record): 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; +} + +export function isEzpayPayment(payParams: Record): boolean { + const provider = payParams.provider; + return typeof provider === "string" && provider.toLowerCase() === "ezpay"; +} + +export function getStripeClientSecret( + payParams: Record, +): 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; +} + +export interface LaunchEzpayRedirectInput { + orderId: string | null; + paymentUrl: string; + subscriptionType: PendingPaymentSubscriptionType; + returnTo?: PendingPaymentReturnTo; + onFailed: (errorMessage: string) => void; +} + +export async function launchEzpayRedirect({ + orderId, + paymentUrl, + subscriptionType, + returnTo, + onFailed, +}: LaunchEzpayRedirectInput): Promise { + log.debug("[payment-launch] launchEzpayRedirect START", { + hasOrderId: Boolean(orderId), + orderId, + subscriptionType, + paymentUrl, + }); + + if (!orderId) { + const errorMessage = "Missing order id before opening Ezpay."; + log.error("[payment-launch] 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("[payment-launch] pending ezpay order save failed", { + orderId, + subscriptionType, + error: saveResult.error, + }); + onFailed(errorMessage); + return; + } + + log.debug("[payment-launch] pending ezpay order saved", { + orderId, + subscriptionType, + }); + + log.debug("[payment-launch] launchEzpayRedirect NOW", { + orderId, + subscriptionType, + paymentUrl, + }); + window.location.href = paymentUrl; +}