diff --git a/e2e/fixtures/api-mocks.ts b/e2e/fixtures/api-mocks.ts index 8c49dcf7..f6f9f63a 100644 --- a/e2e/fixtures/api-mocks.ts +++ b/e2e/fixtures/api-mocks.ts @@ -13,6 +13,7 @@ export interface MockCoreApisOptions { paidImageInsufficientCreditsFlow?: boolean; paidVoiceInsufficientCreditsFlow?: boolean; topUpHandoffFlow?: boolean; + checkoutHandoffFlow?: boolean; } export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) { @@ -23,6 +24,7 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {} paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false, paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false, topUpHandoffFlow: options.topUpHandoffFlow ?? false, + checkoutHandoffFlow: options.checkoutHandoffFlow ?? false, }; const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false }; @@ -30,6 +32,7 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {} chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow, isChatSendTokenExpired: () => chatState.hasExpiredChatSend, topUpHandoffFlow: chatOptions.topUpHandoffFlow, + checkoutHandoffFlow: chatOptions.checkoutHandoffFlow, }); await registerCharacterMocks(page); await registerUserMocks(page); diff --git a/e2e/fixtures/api/auth.ts b/e2e/fixtures/api/auth.ts index 799cdf97..322cc7f3 100644 --- a/e2e/fixtures/api/auth.ts +++ b/e2e/fixtures/api/auth.ts @@ -1,15 +1,24 @@ import type { Page } from "@playwright/test"; import { apiEnvelope } from "../data/common"; -import { emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth"; +import { checkoutHandoffResponse, emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth"; export interface AuthMockState { chatSendTokenRefreshFlow: boolean; isChatSendTokenExpired: () => boolean; topUpHandoffFlow: boolean; + checkoutHandoffFlow: boolean; } export async function registerAuthMocks(page: Page, state: AuthMockState) { + await page.route("**/api/auth/handoff/checkout/consume", async (route) => { + if (!state.checkoutHandoffFlow) { + await route.continue(); + return; + } + await route.fulfill({ json: apiEnvelope(checkoutHandoffResponse) }); + }); + await page.route("**/api/auth/handoff/topup/consume", async (route) => { if (!state.topUpHandoffFlow) { await route.continue(); diff --git a/e2e/fixtures/data/auth.ts b/e2e/fixtures/data/auth.ts index 5f5c195e..606c8c9b 100644 --- a/e2e/fixtures/data/auth.ts +++ b/e2e/fixtures/data/auth.ts @@ -63,3 +63,15 @@ export const topUpHandoffResponse = { loginProvider: "facebook_messenger", }, }; + +export const checkoutHandoffResponse = { + token: "e2e-checkout-token", + refreshToken: "e2e-checkout-refresh-token", + loginStatus: "email", + user: e2eEmailUser, + checkoutIntent: { + planId: "vip_monthly", + autoRenew: true, + commercialOfferId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8", + }, +}; diff --git a/e2e/specs/mock/payment/checkout-handoff-external-browser.spec.ts b/e2e/specs/mock/payment/checkout-handoff-external-browser.spec.ts new file mode 100644 index 00000000..8098251f --- /dev/null +++ b/e2e/specs/mock/payment/checkout-handoff-external-browser.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from "@playwright/test"; + +import { mockCoreApis } from "@e2e/fixtures/api-mocks"; +import { apiEnvelope } from "@e2e/fixtures/data/common"; +import { + clearBrowserState, + seedEmailSession, +} from "@e2e/fixtures/test-helpers"; + +const facebookAndroidUserAgent = + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Version/4.0 Chrome/126.0.0.0 Mobile Safari/537.36 " + + "[FB_IAB/FB4A;FBAV/566.0.0.48.73;]"; +const handoffToken = "checkout-handoff-token-that-is-at-least-32-characters"; + +test.use({ userAgent: facebookAndroidUserAgent }); + +test.beforeEach(async ({ baseURL, context, page }) => { + await clearBrowserState(context, page, baseURL); +}); + +test("consumes the handoff, removes its token, and does not create an order", async ({ + page, +}) => { + await mockCoreApis(page, { checkoutHandoffFlow: true }); + let createOrderRequests = 0; + page.on("request", (request) => { + if (request.url().includes("/api/payment/create-order")) { + createOrderRequests += 1; + } + }); + const consumeRequest = page.waitForRequest( + "**/api/auth/handoff/checkout/consume", + ); + + await page.goto( + `/external-entry?target=checkout&handoffToken=${encodeURIComponent(handoffToken)}`, + ); + + expect((await consumeRequest).postDataJSON()).toEqual({ handoffToken }); + await expect(page).toHaveURL( + /\/subscription\?planId=vip_monthly&autoRenew=1&commercialOfferId=.*&payChannel=stripe/, + ); + expect(page.url()).not.toContain("handoffToken"); + await expect.poll(() => createOrderRequests).toBe(0); + await expect + .poll(() => + page.evaluate(() => ({ + loginProvider: localStorage.getItem("cozsweet:login_provider"), + loginToken: localStorage.getItem("cozsweet:login_token"), + })), + ) + .toEqual({ loginProvider: "email", loginToken: "e2e-checkout-token" }); +}); + +test("offers the Facebook external-browser path before creating a Stripe order", async ({ + page, +}) => { + await mockCoreApis(page); + let createOrderRequests = 0; + page.on("request", (request) => { + if (request.url().includes("/api/payment/create-order")) { + createOrderRequests += 1; + } + }); + await page.route("**/api/auth/handoff/checkout", async (route) => { + await route.fulfill({ + json: apiEnvelope({ + externalUrl: + "https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque-token", + expiresAt: "2026-07-28T16:00:00+00:00", + }), + }); + }); + + await seedEmailSession(page); + await page.goto("/subscription?type=vip&payChannel=stripe"); + await page.getByRole("button", { name: /Monthly,/i }).click(); + await page + .getByRole("dialog", { name: "Automatic Renewal Confirmation" }) + .getByRole("button", { name: "Confirm" }) + .click(); + const externalButton = page.getByRole("button", { + name: "Open in browser for more payment methods", + }); + await expect(externalButton).toBeVisible(); + const handoffRequest = page.waitForRequest("**/api/auth/handoff/checkout"); + await externalButton.click(); + expect((await handoffRequest).postDataJSON()).toMatchObject({ + planId: "vip_monthly", + autoRenew: true, + }); + expect(createOrderRequests).toBe(0); +}); diff --git a/src/app/_components/payment/__tests__/external-browser-checkout-button.test.tsx b/src/app/_components/payment/__tests__/external-browser-checkout-button.test.tsx new file mode 100644 index 00000000..bf0e3f2a --- /dev/null +++ b/src/app/_components/payment/__tests__/external-browser-checkout-button.test.tsx @@ -0,0 +1,76 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { createCheckoutHandoff, openUrlWithExternalBrowser } = vi.hoisted( + () => ({ + createCheckoutHandoff: vi.fn(), + openUrlWithExternalBrowser: vi.fn(), + }), +); + +vi.mock("@/lib/auth/checkout_handoff", () => ({ createCheckoutHandoff })); +vi.mock("@/utils/url-launcher-util", () => ({ + UrlLauncherUtil: { openUrlWithExternalBrowser }, +})); +vi.mock("@/utils/browser-detect", () => ({ + BrowserDetector: { isFacebookInAppBrowser: () => true }, +})); + +import { ExternalBrowserCheckoutButton } from "../external-browser-checkout-button"; + +describe("ExternalBrowserCheckoutButton", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.clearAllMocks(); + createCheckoutHandoff.mockResolvedValue({ + success: true, + data: { + externalUrl: + "https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque", + expiresAt: "2026-07-28T16:00:00+00:00", + }, + }); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("creates only a handoff before opening the external browser", async () => { + await act(async () => { + root.render( + , + ); + }); + + const button = container.querySelector("button"); + expect(button?.textContent).toContain( + "Open in browser for more payment methods", + ); + await act(async () => button?.click()); + + expect(createCheckoutHandoff).toHaveBeenCalledWith({ + planId: "vip_monthly", + autoRenew: true, + commercialOfferId: "offer-1", + }); + expect(openUrlWithExternalBrowser).toHaveBeenCalledWith( + "https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque", + ); + }); +}); diff --git a/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx b/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx index eb971f8f..db6404be 100644 --- a/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx +++ b/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx @@ -18,6 +18,8 @@ const hiddenLaunch = { shouldShowQrisDialog: false, shouldShowStripeDialog: false, stripeClientSecret: null, + stripeCustomerSessionClientSecret: null, + savedPaymentMethodsEnabled: false, }; describe("PaymentLaunchDialogs", () => { diff --git a/src/app/_components/payment/__tests__/stripe-payment-dialog.test.tsx b/src/app/_components/payment/__tests__/stripe-payment-dialog.test.tsx index 6910ce17..77291e88 100644 --- a/src/app/_components/payment/__tests__/stripe-payment-dialog.test.tsx +++ b/src/app/_components/payment/__tests__/stripe-payment-dialog.test.tsx @@ -2,18 +2,57 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { StripePaymentDialogLoading } from "../lazy-stripe-payment-dialog"; +const stripeConfirmPayment = vi.fn(async () => ({})); +const elementsSubmit = vi.fn(async () => ({})); +let capturedElementsOptions: Record | null = null; +let capturedExpressProps: Record | null = null; +let capturedPaymentProps: Record | null = null; + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = "pk_test_checkout"; +}); + +vi.mock("@stripe/stripe-js", () => ({ + loadStripe: vi.fn(() => Promise.resolve({})), +})); + +vi.mock("@stripe/react-stripe-js", () => ({ + Elements: ({ + children, + options, + }: { + children: React.ReactNode; + options: Record; + }) => { + capturedElementsOptions = options; + return <>{children}>; + }, + ExpressCheckoutElement: (props: Record) => { + capturedExpressProps = props; + return ; + }, + PaymentElement: (props: Record) => { + capturedPaymentProps = props; + return ; + }, + useElements: () => ({ submit: elementsSubmit }), + useStripe: () => ({ confirmPayment: stripeConfirmPayment }), +})); + import { StripePaymentDialog } from "../stripe-payment-dialog"; -describe("Stripe payment portal states", () => { +describe("StripePaymentDialog", () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { + vi.clearAllMocks(); + capturedElementsOptions = null; + capturedExpressProps = null; + capturedPaymentProps = null; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) .IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement("div"); - container.style.transform = "translateX(50%)"; document.body.appendChild(container); root = createRoot(container); }); @@ -24,40 +63,101 @@ describe("Stripe payment portal states", () => { document.body.style.overflow = ""; }); - it("portals the unavailable state and keeps its explicit close action", () => { - const onClose = vi.fn(); - + it("renders Express Checkout first and a collapsed Pay by card section last", () => { act(() => root.render( , ), ); - const dialog = document.body.querySelector('[role="alertdialog"]'); - expect(dialog).not.toBeNull(); - expect(container.contains(dialog)).toBe(false); - expect(dialog?.textContent).toContain("Payment unavailable"); - expect(dialog?.getAttribute("aria-labelledby")).toBeTruthy(); - expect(dialog?.getAttribute("aria-describedby")).toBeTruthy(); - - const okButton = Array.from(dialog?.querySelectorAll("button") ?? []).find( - (button) => button.textContent === "OK", - ); - act(() => okButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); - expect(onClose).toHaveBeenCalledOnce(); + expect(capturedElementsOptions).toMatchObject({ + clientSecret: "pi_123_secret_payment", + customerSessionClientSecret: "cuss_123_secret_saved", + }); + expect(capturedExpressProps?.options).toMatchObject({ + paymentMethodOrder: [ + "apple_pay", + "google_pay", + "link", + "paypal", + "amazon_pay", + "klarna", + ], + paymentMethods: { + applePay: "always", + googlePay: "always", + link: "auto", + paypal: "auto", + amazonPay: "auto", + klarna: "auto", + }, + }); + expect(capturedPaymentProps?.options).toMatchObject({ + layout: { type: "accordion", defaultCollapsed: true }, + paymentMethodOrder: ["card"], + wallets: { applePay: "never", googlePay: "never", link: "never" }, + }); + const text = document.body.textContent ?? ""; + expect(text).toContain("Pay by card"); + const express = document.body.querySelector('[data-testid="express-checkout"]'); + const card = document.body.querySelector('[data-testid="payment-element"]'); + expect(express).not.toBeNull(); + expect(card).not.toBeNull(); + expect( + express!.compareDocumentPosition(card!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); }); - it("portals the lazy loading state", () => { - act(() => root.render()); + it("does not pass an invalid or disabled Customer Session secret", () => { + act(() => + root.render( + , + ), + ); - const dialog = document.body.querySelector('[role="dialog"]'); - expect(dialog).not.toBeNull(); - expect(container.contains(dialog)).toBe(false); - expect(dialog?.textContent).toContain("Preparing secure payment"); - expect(dialog?.textContent).toContain("Loading payment methods..."); - expect(dialog?.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(capturedElementsOptions).not.toHaveProperty( + "customerSessionClientSecret", + ); + }); + + it("uses the same confirmation path for an Express Checkout approval", async () => { + const onConfirmed = vi.fn(); + act(() => + root.render( + , + ), + ); + + const onConfirm = capturedExpressProps?.onConfirm as + | ((event: { paymentFailed: ReturnType }) => Promise) + | undefined; + expect(onConfirm).toBeTypeOf("function"); + await act(async () => { + await onConfirm?.({ paymentFailed: vi.fn() }); + }); + + expect(stripeConfirmPayment).toHaveBeenCalledTimes(1); + expect(stripeConfirmPayment).toHaveBeenCalledWith( + expect.objectContaining({ + elements: expect.any(Object), + redirect: "if_required", + }), + ); + expect(onConfirmed).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/_components/payment/external-browser-checkout-button.tsx b/src/app/_components/payment/external-browser-checkout-button.tsx new file mode 100644 index 00000000..c901acab --- /dev/null +++ b/src/app/_components/payment/external-browser-checkout-button.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState, useSyncExternalStore } from "react"; + +import type { CheckoutIntent } from "@/data/schemas/auth"; +import { createCheckoutHandoff } from "@/lib/auth/checkout_handoff"; +import { BrowserDetector } from "@/utils/browser-detect"; +import { ExceptionHandler } from "@/core/errors"; +import { Result } from "@/utils/result"; +import { UrlLauncherUtil } from "@/utils/url-launcher-util"; + +export interface ExternalBrowserCheckoutButtonProps { + checkoutIntent: CheckoutIntent; + disabled?: boolean; +} + +export function ExternalBrowserCheckoutButton({ + checkoutIntent, + disabled = false, +}: ExternalBrowserCheckoutButtonProps) { + const isFacebookBrowser = useSyncExternalStore( + () => () => undefined, + () => BrowserDetector.isFacebookInAppBrowser(), + () => false, + ); + const [isOpening, setIsOpening] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + if (!isFacebookBrowser) return null; + + const handleOpen = async () => { + if (disabled || isOpening) return; + setIsOpening(true); + setErrorMessage(null); + const result = await createCheckoutHandoff(checkoutIntent); + if (Result.isErr(result)) { + setErrorMessage( + ExceptionHandler.message( + result.error, + "Could not open this checkout in your browser. Please sign in and try again.", + ), + ); + setIsOpening(false); + return; + } + UrlLauncherUtil.openUrlWithExternalBrowser(result.data.externalUrl); + }; + + return ( + + void handleOpen()} + > + {isOpening + ? "Opening browser..." + : "Open in browser for more payment methods"} + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + ); +} diff --git a/src/app/_components/payment/payment-launch-dialogs.tsx b/src/app/_components/payment/payment-launch-dialogs.tsx index d30620dc..dbbade8b 100644 --- a/src/app/_components/payment/payment-launch-dialogs.tsx +++ b/src/app/_components/payment/payment-launch-dialogs.tsx @@ -24,6 +24,8 @@ type PaymentLaunchDialogFlow = Pick< | "shouldShowQrisDialog" | "shouldShowStripeDialog" | "stripeClientSecret" + | "stripeCustomerSessionClientSecret" + | "savedPaymentMethodsEnabled" >; export interface PaymentLaunchDialogsProps { @@ -64,6 +66,10 @@ export function PaymentLaunchDialogs({ {launch.shouldShowStripeDialog && launch.stripeClientSecret ? ( void; onConfirmed?: () => void; @@ -35,12 +52,19 @@ export interface StripePaymentDialogProps { export function StripePaymentDialog({ clientSecret, + customerSessionClientSecret = null, + savedPaymentMethodsEnabled = false, returnPath = ROUTES.subscription + "/success", onClose, onConfirmed, }: StripePaymentDialogProps) { const titleId = useId(); const descriptionId = useId(); + const savedCardClientSecret = getStripeCustomerSessionClientSecret({ + provider: "stripe", + customerSessionClientSecret, + savedPaymentMethodsEnabled, + }); if (!stripePromise) { return ( @@ -100,6 +124,9 @@ export function StripePaymentDialog({ stripe={stripePromise} options={{ clientSecret, + ...(savedCardClientSecret + ? { customerSessionClientSecret: savedCardClientSecret } + : {}), appearance: { theme: "stripe", variables: { @@ -132,6 +159,20 @@ function StripePaymentForm({ const elements = useElements(); const [errorMessage, setErrorMessage] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const submittingRef = useRef(false); + const [hasExpressPaymentMethods, setHasExpressPaymentMethods] = useState< + boolean | null + >(null); + const [expressMaxColumns, setExpressMaxColumns] = useState(1); + + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + const query = window.matchMedia("(min-width: 640px)"); + const updateColumns = () => setExpressMaxColumns(query.matches ? 2 : 1); + updateColumns(); + query.addEventListener("change", updateColumns); + return () => query.removeEventListener("change", updateColumns); + }, []); const handleLoadError = (event: Parameters< NonNullable["onLoadError"]> @@ -150,38 +191,118 @@ function StripePaymentForm({ ); }; - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - if (!stripe || !elements || isSubmitting) return; + const updateExpressAvailability = ( + availablePaymentMethods: + | AvailablePaymentMethods + | { + applePay?: { available: boolean }; + googlePay?: { available: boolean }; + link?: { available: boolean }; + paypal?: { available: boolean }; + amazonPay?: { available: boolean }; + klarna?: { available: boolean }; + } + | undefined, + ) => { + const availability = { + applePay: isExpressMethodAvailable(availablePaymentMethods?.applePay), + googlePay: isExpressMethodAvailable(availablePaymentMethods?.googlePay), + link: isExpressMethodAvailable(availablePaymentMethods?.link), + paypal: isExpressMethodAvailable(availablePaymentMethods?.paypal), + amazonPay: isExpressMethodAvailable(availablePaymentMethods?.amazonPay), + klarna: isExpressMethodAvailable(availablePaymentMethods?.klarna), + }; + setHasExpressPaymentMethods(Object.values(availability).some(Boolean)); + const browser = BrowserDetector.isFacebookInAppBrowser() + ? "facebook" + : BrowserDetector.getBrowserName() || "unknown"; + const platform = PlatformDetector.getPlatform(); + const metadata = { + browser, + platform, + availablePaymentMethods: availability, + }; + log.info( + "[stripe-payment-dialog] Express Checkout availability", + metadata, + ); + behaviorAnalytics.elementClick( + "stripe.express_checkout_availability", + "Stripe Express Checkout availability", + metadata, + ); + }; + const handleExpressLoadError = (event: Parameters< + NonNullable< + React.ComponentProps["onLoadError"] + > + >[0]) => { + setHasExpressPaymentMethods(false); + log.warn("[stripe-payment-dialog] Express Checkout load failed", { + type: event.error.type, + code: event.error.code, + message: event.error.message, + }); + }; + + const confirmPayment = async ( + options: { + submitPaymentElement: boolean; + expressEvent?: StripeExpressCheckoutElementConfirmEvent; + }, + ) => { + if (!stripe || !elements || submittingRef.current) return; + + submittingRef.current = true; setIsSubmitting(true); setErrorMessage(null); - const { error: submitError } = await elements.submit(); - if (submitError) { - setErrorMessage( - ExceptionHandler.message(submitError, "Please check your payment info."), - ); - setIsSubmitting(false); - return; + if (options.submitPaymentElement) { + let submitError: unknown; + try { + ({ error: submitError } = await elements.submit()); + } catch (error) { + submitError = error; + } + if (submitError) { + setErrorMessage( + ExceptionHandler.message( + submitError, + "Please check your payment info.", + ), + ); + submittingRef.current = false; + setIsSubmitting(false); + return; + } } const returnUrl = new URL( returnPath ?? ROUTES.subscription + "/success", window.location.origin, ); - const { error } = await stripe.confirmPayment({ - elements, - confirmParams: { - return_url: returnUrl.toString(), - }, - redirect: "if_required", - }); + let confirmationError: unknown; + try { + ({ error: confirmationError } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: returnUrl.toString(), + }, + redirect: "if_required", + })); + } catch (error) { + confirmationError = error; + } - if (error) { - setErrorMessage( - ExceptionHandler.message(error, "Payment confirmation failed."), + if (confirmationError) { + const message = ExceptionHandler.message( + confirmationError, + "Payment confirmation failed.", ); + setErrorMessage(message); + options.expressEvent?.paymentFailed({ reason: "fail", message }); + submittingRef.current = false; setIsSubmitting(false); return; } @@ -189,9 +310,74 @@ function StripePaymentForm({ onConfirmed?.(); }; + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + await confirmPayment({ submitPaymentElement: true }); + }; + return ( - + + + updateExpressAvailability(event.availablePaymentMethods) + } + onAvailablePaymentMethodsChange={(event) => + updateExpressAvailability(event.paymentMethods) + } + onLoadError={handleExpressLoadError} + onConfirm={(event) => + void confirmPayment({ + submitPaymentElement: false, + expressEvent: event, + }) + } + /> + + + + Pay by card + + + {errorMessage ? ( (null); const [handoffCompleted, setHandoffCompleted] = useState(false); + const [checkoutDestination, setCheckoutDestination] = useState( + null, + ); const hasNavigatedRef = useRef(false); const handoffStartedRef = useRef(false); const targetRoute = resolveExternalEntryTarget({ target }); @@ -71,11 +76,14 @@ export default function ExternalEntryPersist({ mode, promotionType, }); - const isTopUpHandoff = targetRoute === ROUTES.subscription; + const normalizedTarget = target?.trim().toLowerCase() ?? ""; + const isTopUpHandoff = normalizedTarget === "topup"; + const isCheckoutHandoff = normalizedTarget === "checkout"; + const isLoginHandoff = isTopUpHandoff || isCheckoutHandoff; const displayedHandoffError = handoffError ?? - (isTopUpHandoff && !hasValue(handoffToken) - ? "This top-up link is invalid. Please request a new link in Messenger." + (isLoginHandoff && !hasValue(handoffToken) + ? "This checkout link is invalid. Please return and request a new link." : null); useEffect(() => { @@ -128,7 +136,7 @@ export default function ExternalEntryPersist({ useEffect(() => { if (hasNavigatedRef.current || !hasPersisted) return; - if (isTopUpHandoff) { + if (isLoginHandoff) { if (!authState.hasInitialized || authState.isLoading) return; if (handoffCompleted) { if ( @@ -138,25 +146,60 @@ export default function ExternalEntryPersist({ return; } hasNavigatedRef.current = true; - router.replace(destination); + router.replace(checkoutDestination ?? destination); return; } if (handoffStartedRef.current) return; if (!hasValue(handoffToken)) return; handoffStartedRef.current = true; void (async () => { - const result = await consumeTopUpHandoff(handoffToken); - if (Result.isErr(result)) { - log.warn("[ExternalEntryPersist] top-up handoff failed", result.error); + if (isCheckoutHandoff) { + const result = await consumeCheckoutHandoff(handoffToken); + if (Result.isErr(result)) { + log.warn( + "[ExternalEntryPersist] checkout handoff failed", + result.error, + ); + window.history.replaceState( + window.history.state, + "", + "/external-entry?target=checkout", + ); + setHandoffError( + "This checkout link is invalid, expired, or already used. Please return and request a new link.", + ); + return; + } + window.history.replaceState( + window.history.state, + "", + "/external-entry?target=checkout", + ); + setCheckoutDestination( + resolveCheckoutIntentDestination(result.data), + ); + } else { + const result = await consumeTopUpHandoff(handoffToken); + if (Result.isErr(result)) { + log.warn( + "[ExternalEntryPersist] top-up handoff failed", + result.error, + ); + window.history.replaceState( + window.history.state, + "", + "/external-entry?target=topup", + ); + setHandoffError( + "This top-up link is invalid or has expired. Please request a new link in Messenger.", + ); + return; + } window.history.replaceState( window.history.state, "", "/external-entry?target=topup", ); - setHandoffError( - "This top-up link is invalid or has expired. Please request a new link in Messenger.", - ); - return; } setHandoffCompleted(true); authDispatch({ type: "AuthInit" }); @@ -190,9 +233,12 @@ export default function ExternalEntryPersist({ authState.isLoading, authState.loginStatus, destination, + checkoutDestination, hasPersisted, handoffCompleted, handoffToken, + isCheckoutHandoff, + isLoginHandoff, isTopUpHandoff, psid, router, diff --git a/src/app/external-entry/page.tsx b/src/app/external-entry/page.tsx index 6dfc6319..0a93552e 100644 --- a/src/app/external-entry/page.tsx +++ b/src/app/external-entry/page.tsx @@ -8,6 +8,7 @@ * `/external-entry?target=tip` * `/external-entry?target=private-zone&character=nayeli` * `/external-entry?target=topup&handoffToken=` + * `/external-entry?target=checkout&handoffToken=` * * 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储, * 再通过 `router.replace()` 清理 URL 并跳转到最终页面。 diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index ed745726..434e4cf3 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -6,6 +6,7 @@ */ import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow"; import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs"; +import { ExternalBrowserCheckoutButton } from "@/app/_components/payment/external-browser-checkout-button"; import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character"; import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit"; import { @@ -70,6 +71,21 @@ export function SubscriptionCheckoutButton({ > {label} + {payment.payChannel === "stripe" && payment.selectedPlanId ? ( + + ) : null} {payment.errorMessage ? ( {label} + {payment.payChannel === "stripe" && payment.selectedPlanId ? ( + + ) : null} {payment.errorMessage ? ( {payment.errorMessage} diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts index c581a79b..19138893 100644 --- a/src/data/repositories/auth_repository.ts +++ b/src/data/repositories/auth_repository.ts @@ -1,6 +1,10 @@ import { ExceptionHandler } from "@/core/errors"; import type { IAuthRepository } from "@/data/repositories/interfaces"; import { + CheckoutHandoffConsumeRequestSchema, + CheckoutHandoffCreateRequestSchema, + type CheckoutHandoffCreateResponse, + type CheckoutIntent, FacebookIdentityRequestSchema, FacebookLoginRequestSchema, FbIdLoginRequestSchema, @@ -276,6 +280,30 @@ export class AuthRepository implements IAuthRepository { }); } + /** 创建订单前生成一次性外部浏览器支付交接链接。 */ + async createCheckoutHandoff( + checkoutIntent: CheckoutIntent, + ): Promise> { + return Result.wrap(() => + this.api.createCheckoutHandoff( + CheckoutHandoffCreateRequestSchema.parse(checkoutIntent), + ), + ); + } + + /** 消费支付交接凭证,保存后端签发的正式登录态并返回购买意图。 */ + async consumeCheckoutHandoff( + handoffToken: string, + ): Promise> { + return Result.wrap(async () => { + const response = await this.api.consumeCheckoutHandoff( + CheckoutHandoffConsumeRequestSchema.parse({ handoffToken }), + ); + await this._saveLoginData(response, response.loginStatus); + return response.checkoutIntent; + }); + } + /** * 刷新 token:先读本地的 refresh token,空则直接返回错误; * 调用 API 成功后写回新的 login token + refresh token。 diff --git a/src/data/repositories/interfaces/iauth_repository.ts b/src/data/repositories/interfaces/iauth_repository.ts index 4a2e55a0..305c4d3c 100644 --- a/src/data/repositories/interfaces/iauth_repository.ts +++ b/src/data/repositories/interfaces/iauth_repository.ts @@ -13,6 +13,8 @@ import type { Result } from "@/utils/result"; import type { + CheckoutHandoffCreateResponse, + CheckoutIntent, GuestLoginResponse, LoginStatus, LoginResponse, @@ -74,6 +76,16 @@ export interface IAuthRepository { /** 消费一次性充值登录凭证并建立正式会话。 */ consumeTopUpHandoff(handoffToken: string): Promise>; + /** 在创建订单前生成10分钟有效的外部浏览器支付链接。 */ + createCheckoutHandoff( + checkoutIntent: CheckoutIntent, + ): Promise>; + + /** 消费单次支付交接凭证并保存正式登录态。 */ + consumeCheckoutHandoff( + handoffToken: string, + ): Promise>; + /** 刷新 token:先读本地的 refresh token,空则返回错误。 */ refreshToken(): Promise>; diff --git a/src/data/schemas/auth/__tests__/checkout_handoff.test.ts b/src/data/schemas/auth/__tests__/checkout_handoff.test.ts new file mode 100644 index 00000000..7b1065ec --- /dev/null +++ b/src/data/schemas/auth/__tests__/checkout_handoff.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { + CheckoutHandoffConsumeResponseSchema, + CheckoutHandoffCreateRequestSchema, +} from "../checkout_handoff"; + +describe("checkout handoff schemas", () => { + it("keeps only the minimal order-free purchase intent", () => { + expect( + CheckoutHandoffCreateRequestSchema.parse({ + planId: "vip_monthly", + autoRenew: true, + recipientCharacterId: "elio", + price: 9.99, + }), + ).toEqual({ + planId: "vip_monthly", + autoRenew: true, + recipientCharacterId: "elio", + }); + }); + + it("parses the formal session and checkout intent returned after consumption", () => { + const parsed = CheckoutHandoffConsumeResponseSchema.parse({ + user: { + id: "00000000-0000-0000-0000-000000000123", + username: "checkout-user", + email: "checkout@example.com", + platform: "web", + }, + token: "access-token", + refreshToken: "refresh-token", + loginStatus: "email", + checkoutIntent: { + planId: "vip_monthly", + autoRenew: true, + }, + }); + + expect(parsed.checkoutIntent.planId).toBe("vip_monthly"); + expect(parsed.loginStatus).toBe("email"); + }); +}); diff --git a/src/data/schemas/auth/checkout_handoff.ts b/src/data/schemas/auth/checkout_handoff.ts new file mode 100644 index 00000000..f97be1d0 --- /dev/null +++ b/src/data/schemas/auth/checkout_handoff.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +import { stringOrEmpty } from "../nullable-defaults"; +import { UserSchema } from "../user/user"; + +export const CheckoutIntentSchema = z + .object({ + planId: z.string().min(1).max(120), + autoRenew: z.boolean(), + recipientCharacterId: z.string().min(1).max(80).optional(), + commercialOfferId: z.string().min(1).max(80).optional(), + chatActionId: z.uuid().optional(), + }) + .readonly(); + +export const CheckoutHandoffCreateRequestSchema = CheckoutIntentSchema; + +export const CheckoutHandoffCreateResponseSchema = z + .object({ + externalUrl: z.url(), + expiresAt: z.string().min(1), + }) + .readonly(); + +export const CheckoutHandoffConsumeRequestSchema = z + .object({ + handoffToken: z.string().min(32).max(512), + }) + .readonly(); + +export const CheckoutHandoffConsumeResponseSchema = z + .object({ + user: UserSchema, + token: z.string().min(1), + refreshToken: stringOrEmpty, + loginStatus: z.enum([ + "email", + "google", + "facebook", + "facebookMessenger", + ]), + checkoutIntent: CheckoutIntentSchema, + }) + .readonly(); + +export type CheckoutIntent = z.output; +export type CheckoutHandoffCreateRequest = z.output< + typeof CheckoutHandoffCreateRequestSchema +>; +export type CheckoutHandoffCreateResponse = z.output< + typeof CheckoutHandoffCreateResponseSchema +>; +export type CheckoutHandoffConsumeRequest = z.output< + typeof CheckoutHandoffConsumeRequestSchema +>; +export type CheckoutHandoffConsumeResponse = z.output< + typeof CheckoutHandoffConsumeResponseSchema +>; diff --git a/src/data/schemas/auth/index.ts b/src/data/schemas/auth/index.ts index 0446d39b..5a6f533f 100644 --- a/src/data/schemas/auth/index.ts +++ b/src/data/schemas/auth/index.ts @@ -3,6 +3,7 @@ */ export * from "./facebook_user_data"; +export * from "./checkout_handoff"; export * from "./login_status"; export * from "./request/facebook_identity_request"; export * from "./request/facebook_login_request"; diff --git a/src/data/services/api/api_contract.json b/src/data/services/api/api_contract.json index c78af6d7..5cd59520 100644 --- a/src/data/services/api/api_contract.json +++ b/src/data/services/api/api_contract.json @@ -6,6 +6,8 @@ "facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" }, "facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" }, "topUpHandoffConsume": { "method": "post", "path": "/api/auth/handoff/topup/consume" }, + "checkoutHandoffCreate": { "method": "post", "path": "/api/auth/handoff/checkout" }, + "checkoutHandoffConsume": { "method": "post", "path": "/api/auth/handoff/checkout/consume" }, "refresh": { "method": "post", "path": "/api/auth/refresh" }, "logout": { "method": "post", "path": "/api/auth/logout" }, "getCurrentUser": { "method": "get", "path": "/api/auth/me" }, diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 22b7e7d2..6828c1ed 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -30,6 +30,12 @@ export class ApiPath { /** 消费一次性充值登录凭证 */ static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path; + /** 创建一次性外部浏览器支付交接链接(创建订单前)。 */ + static readonly checkoutHandoffCreate = apiContract.checkoutHandoffCreate.path; + + /** 消费一次性外部浏览器支付交接凭证。 */ + static readonly checkoutHandoffConsume = apiContract.checkoutHandoffConsume.path; + /** 刷新 Token */ static readonly refresh = apiContract.refresh.path; diff --git a/src/data/services/api/auth_api.ts b/src/data/services/api/auth_api.ts index c4f67254..8da96b18 100644 --- a/src/data/services/api/auth_api.ts +++ b/src/data/services/api/auth_api.ts @@ -7,6 +7,12 @@ import { z } from "zod"; import { + CheckoutHandoffConsumeRequest, + CheckoutHandoffConsumeResponse, + CheckoutHandoffConsumeResponseSchema, + CheckoutHandoffCreateRequest, + CheckoutHandoffCreateResponse, + CheckoutHandoffCreateResponseSchema, FacebookIdentityRequest, FacebookIdentityResponse, FacebookIdentityResponseSchema, @@ -104,6 +110,32 @@ export class AuthApi { ); } + /** 在创建支付订单前生成一次性外部浏览器交接链接。 */ + async createCheckoutHandoff( + body: CheckoutHandoffCreateRequest, + ): Promise { + const env = await httpClient>( + ApiPath.checkoutHandoffCreate, + { method: "POST", body }, + ); + return CheckoutHandoffCreateResponseSchema.parse( + unwrap(env) as Record, + ); + } + + /** 消费一次性支付交接凭证并恢复正式登录态与购买意图。 */ + async consumeCheckoutHandoff( + body: CheckoutHandoffConsumeRequest, + ): Promise { + const env = await httpClient>( + ApiPath.checkoutHandoffConsume, + { method: "POST", body }, + ); + return CheckoutHandoffConsumeResponseSchema.parse( + unwrap(env) as Record, + ); + } + /** * 绑定 Facebook ASID / PSID 到当前用户 */ diff --git a/src/lib/auth/checkout_handoff.ts b/src/lib/auth/checkout_handoff.ts new file mode 100644 index 00000000..b9dc2e52 --- /dev/null +++ b/src/lib/auth/checkout_handoff.ts @@ -0,0 +1,18 @@ +import type { + CheckoutHandoffCreateResponse, + CheckoutIntent, +} from "@/data/schemas/auth"; +import { getAuthRepository } from "@/data/repositories/auth_repository"; +import type { Result } from "@/utils/result"; + +export function createCheckoutHandoff( + checkoutIntent: CheckoutIntent, +): Promise> { + return getAuthRepository().createCheckoutHandoff(checkoutIntent); +} + +export function consumeCheckoutHandoff( + handoffToken: string, +): Promise> { + return getAuthRepository().consumeCheckoutHandoff(handoffToken); +} diff --git a/src/lib/navigation/__tests__/external_entry.test.ts b/src/lib/navigation/__tests__/external_entry.test.ts index 9d98e7ba..a165beee 100644 --- a/src/lib/navigation/__tests__/external_entry.test.ts +++ b/src/lib/navigation/__tests__/external_entry.test.ts @@ -3,12 +3,38 @@ import { describe, expect, it } from "vitest"; import { getCharacterRoutes, ROUTES } from "@/router/routes"; import { + resolveCheckoutIntentDestination, resolveExternalEntryDestination, resolveExternalEntryPromotionType, resolveExternalEntryTarget, shouldBindExternalFacebookIdentity, } from "../external_entry"; +describe("checkout external browser destination", () => { + it("restores subscription intent without carrying the handoff token", () => { + expect( + resolveCheckoutIntentDestination({ + planId: "vip_monthly", + autoRenew: true, + commercialOfferId: "offer-1", + chatActionId: "00000000-0000-0000-0000-000000000123", + }), + ).toBe( + "/subscription?planId=vip_monthly&autoRenew=1&commercialOfferId=offer-1&chatActionId=00000000-0000-0000-0000-000000000123&payChannel=stripe", + ); + }); + + it("restores a character tip intent by canonical character id", () => { + expect( + resolveCheckoutIntentDestination({ + planId: "maya_coffee", + autoRenew: false, + recipientCharacterId: "maya-tan", + }), + ).toBe("/characters/maya/tip?planId=maya_coffee&payChannel=stripe"); + }); +}); + const facebookBusinessEntryCases = [ ["private-space", "elio", "chat", null, null, getCharacterRoutes("elio").chat, null], ["private-space", "maya", "chat", null, null, getCharacterRoutes("maya").chat, null], diff --git a/src/lib/navigation/external_entry.ts b/src/lib/navigation/external_entry.ts index 8dcee9d3..a67515df 100644 --- a/src/lib/navigation/external_entry.ts +++ b/src/lib/navigation/external_entry.ts @@ -4,8 +4,10 @@ import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { UserStorage } from "@/data/storage/user/user_storage"; import type { PendingChatPromotionType } from "@/data/storage/navigation"; import type { LoginStatus } from "@/data/schemas/auth"; +import type { CheckoutIntent } from "@/data/schemas/auth"; import { DEFAULT_CHARACTER_SLUG, + getCharacterById, getCharacterBySlug, } from "@/data/constants/character"; import { getCharacterRoutes, ROUTES } from "@/router/routes"; @@ -100,6 +102,30 @@ export function resolveExternalEntryDestination({ return routes.chat; } +export function resolveCheckoutIntentDestination( + intent: CheckoutIntent, +): string { + const params = new URLSearchParams({ planId: intent.planId }); + + if (intent.recipientCharacterId) { + const character = getCharacterById(intent.recipientCharacterId); + const routes = getCharacterRoutes( + character?.slug ?? DEFAULT_CHARACTER_SLUG, + ); + if (intent.chatActionId) params.set("chatActionId", intent.chatActionId); + params.set("payChannel", "stripe"); + return `${routes.tip}?${params.toString()}`; + } + + params.set("autoRenew", intent.autoRenew ? "1" : "0"); + if (intent.commercialOfferId) { + params.set("commercialOfferId", intent.commercialOfferId); + } + if (intent.chatActionId) params.set("chatActionId", intent.chatActionId); + params.set("payChannel", "stripe"); + return `${ROUTES.subscription}?${params.toString()}`; +} + export async function persistExternalEntryPayload({ deviceId, asid, @@ -138,6 +164,7 @@ function resolveTarget( return ROUTES.privateZone; } if (target === "topup") return ROUTES.subscription; + if (target === "checkout") return ROUTES.subscription; return null; } diff --git a/src/lib/payment/__tests__/payment_launch.test.ts b/src/lib/payment/__tests__/payment_launch.test.ts index d931b6c6..e7f9b148 100644 --- a/src/lib/payment/__tests__/payment_launch.test.ts +++ b/src/lib/payment/__tests__/payment_launch.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { getPaymentUrl, + getStripeCustomerSessionClientSecret, getStripeClientSecret, isEzpayPayment, launchEzpayRedirect, @@ -43,6 +44,28 @@ describe("payment launch helpers", () => { ).toBeNull(); }); + it("enables saved cards only with an explicit flag and a Customer Session secret", () => { + expect( + getStripeCustomerSessionClientSecret({ + provider: "stripe", + customerSessionClientSecret: "cuss_123_secret_456", + savedPaymentMethodsEnabled: true, + }), + ).toBe("cuss_123_secret_456"); + expect( + getStripeCustomerSessionClientSecret({ + customerSessionClientSecret: "cuss_123_secret_456", + savedPaymentMethodsEnabled: false, + }), + ).toBeNull(); + expect( + getStripeCustomerSessionClientSecret({ + customerSessionClientSecret: "invalid-customer-session", + savedPaymentMethodsEnabled: true, + }), + ).toBeNull(); + }); + it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => { expect( resolveEzpayLaunchTarget({ diff --git a/src/lib/payment/payment_launch.ts b/src/lib/payment/payment_launch.ts index 71b93286..1563a5a1 100644 --- a/src/lib/payment/payment_launch.ts +++ b/src/lib/payment/payment_launch.ts @@ -143,6 +143,27 @@ export function getStripeClientSecret( return null; } +export function getStripeCustomerSessionClientSecret( + payParams: Record, +): string | null { + const provider = payParams.provider; + const isStripeProvider = + typeof provider !== "string" || provider.toLowerCase() === "stripe"; + const enabled = payParams.savedPaymentMethodsEnabled === true; + const clientSecret = payParams.customerSessionClientSecret; + + if ( + isStripeProvider && + enabled && + typeof clientSecret === "string" && + /^cuss_[^\s]+_secret_[^\s]+$/i.test(clientSecret) + ) { + return clientSecret; + } + + return null; +} + export interface LaunchEzpayRedirectInput { orderId: string | null; paymentUrl: string; diff --git a/src/lib/payment/payment_search_params.ts b/src/lib/payment/payment_search_params.ts index 2850d3f9..c054200e 100644 --- a/src/lib/payment/payment_search_params.ts +++ b/src/lib/payment/payment_search_params.ts @@ -22,6 +22,15 @@ export function parsePaymentPayChannel( return channel === "ezpay" || channel === "stripe" ? channel : null; } +export function parsePaymentAutoRenew( + value: PaymentSearchParamValue, +): boolean | null { + const autoRenew = getFirstPaymentSearchParam(value); + if (autoRenew === "1" || autoRenew === "true") return true; + if (autoRenew === "0" || autoRenew === "false") return false; + return null; +} + export function parseSubscriptionReturnTo( value: PaymentSearchParamValue, ): AppSubscriptionReturnTo { diff --git a/src/stores/payment/__tests__/payment-catalog-flow.test.ts b/src/stores/payment/__tests__/payment-catalog-flow.test.ts index 07193cfe..97380549 100644 --- a/src/stores/payment/__tests__/payment-catalog-flow.test.ts +++ b/src/stores/payment/__tests__/payment-catalog-flow.test.ts @@ -8,7 +8,6 @@ import { import { createTestPaymentMachine, - giftCatalog, lifetimePlan, monthlyPlan, quarterlyPlan, @@ -51,7 +50,7 @@ describe("payment catalog flow", () => { actor.stop(); }); - it("restores a valid first-category product and rejects another category", async () => { + it("restores a valid tip product across categories from checkout handoff", async () => { const actor = createActor(createTestPaymentMachine()).start(); actor.send({ type: "PaymentInit", @@ -79,8 +78,9 @@ describe("payment catalog flow", () => { snapshot.context.requestedGiftPlanId === null, ); expect(actor.getSnapshot().context.selectedPlanId).toBe( - giftCatalog.plans[0]?.planId, + "tip_flowers_usd_12_99", ); + expect(actor.getSnapshot().context.selectedGiftCategory).toBe("flowers"); actor.stop(); }); diff --git a/src/stores/payment/helper/gift.ts b/src/stores/payment/helper/gift.ts index c880ab9c..e5e04189 100644 --- a/src/stores/payment/helper/gift.ts +++ b/src/stores/payment/helper/gift.ts @@ -50,15 +50,23 @@ export function hydrateGiftProductsState( const giftProducts = response.plans.filter( (product) => product.characterId === characterId, ); - const selectedGiftCategory = giftCategories[0]?.category ?? null; + const restoredProduct = restoredPlanId + ? giftProducts.find((product) => product.planId === restoredPlanId) + : null; + const selectedGiftCategory = + restoredProduct?.category ?? + (restoredCategory && + giftCategories.some((category) => category.category === restoredCategory) + ? restoredCategory + : (giftCategories[0]?.category ?? null)); const visibleProducts = selectedGiftCategory ? giftProducts.filter( (product) => product.category === selectedGiftCategory, ) : []; - const canRestoreSelection = - restoredCategory === selectedGiftCategory && - visibleProducts.some((product) => product.planId === restoredPlanId); + const canRestoreSelection = visibleProducts.some( + (product) => product.planId === restoredPlanId, + ); const selectedPlanId = canRestoreSelection ? (restoredPlanId ?? "") : (visibleProducts[0]?.planId ?? ""); diff --git a/src/stores/payment/machine/catalog-flow.ts b/src/stores/payment/machine/catalog-flow.ts index 0cd62b0d..32a99fbb 100644 --- a/src/stores/payment/machine/catalog-flow.ts +++ b/src/stores/payment/machine/catalog-flow.ts @@ -32,14 +32,18 @@ function initializeCatalogState( const catalogChanged = planCatalog !== context.planCatalog; const characterChanged = giftCharacterId !== context.giftCharacterId; const selectionChanged = - planCatalog === "tip" && - ((event.category ?? null) !== context.selectedGiftCategory || - (event.planId ?? null) !== (context.selectedPlanId || null)); + planCatalog === "tip" + ? (event.category ?? null) !== context.selectedGiftCategory || + (event.planId ?? null) !== (context.selectedPlanId || null) + : (event.planId ?? null) !== (context.selectedPlanId || null); const commercialOfferId = planCatalog === "default" ? (event.commercialOfferId ?? null) : null; const offerChanged = commercialOfferId !== context.commercialOfferId; const chatActionId = event.chatActionId ?? null; const chatActionChanged = chatActionId !== context.chatActionId; + const initialAutoRenew = + planCatalog === "tip" ? false : (event.autoRenew ?? true); + const autoRenewChanged = initialAutoRenew !== context.autoRenew; return { planCatalog, @@ -51,7 +55,12 @@ function initializeCatalogState( planCatalog === "tip" ? (event.category ?? null) : null, requestedGiftPlanId: planCatalog === "tip" ? (event.planId ?? null) : null, - ...(catalogChanged || characterChanged || selectionChanged || offerChanged || chatActionChanged + ...(catalogChanged || + characterChanged || + selectionChanged || + offerChanged || + chatActionChanged || + autoRenewChanged ? { ...resetOrderState(), plans: [], @@ -62,7 +71,7 @@ function initializeCatalogState( planCatalog === "default" ? (event.planId ?? "") : "", isFirstRecharge: false, commercialOffer: null, - autoRenew: planCatalog !== "tip", + autoRenew: initialAutoRenew, } : {}), }; diff --git a/src/stores/payment/payment-context.tsx b/src/stores/payment/payment-context.tsx index 7b3d29e2..a731436b 100644 --- a/src/stores/payment/payment-context.tsx +++ b/src/stores/payment/payment-context.tsx @@ -20,6 +20,8 @@ export interface PaymentContextState { selectedGiftCategory: string | null; isFirstRecharge: MachineContext["isFirstRecharge"]; commercialOffer: MachineContext["commercialOffer"]; + commercialOfferId: string | null; + chatActionId: string | null; selectedPlanId: string; payChannel: MachineContext["payChannel"]; autoRenew: boolean; @@ -76,6 +78,8 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState { selectedGiftCategory: state.context.selectedGiftCategory, isFirstRecharge: state.context.isFirstRecharge, commercialOffer: state.context.commercialOffer, + commercialOfferId: state.context.commercialOfferId, + chatActionId: state.context.chatActionId, selectedPlanId: state.context.selectedPlanId, payChannel: state.context.payChannel, autoRenew: state.context.autoRenew, diff --git a/src/stores/payment/payment-events.ts b/src/stores/payment/payment-events.ts index 3d75b52f..13c86a22 100644 --- a/src/stores/payment/payment-events.ts +++ b/src/stores/payment/payment-events.ts @@ -10,6 +10,7 @@ export type PaymentEvent = characterId?: string; category?: string | null; planId?: string | null; + autoRenew?: boolean; commercialOfferId?: string | null; chatActionId?: string | null; } diff --git a/src/utils/__tests__/logger-redaction.test.ts b/src/utils/__tests__/logger-redaction.test.ts new file mode 100644 index 00000000..f93faaca --- /dev/null +++ b/src/utils/__tests__/logger-redaction.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { Logger } from "../logger"; + +describe("Logger Stripe secret redaction", () => { + it("redacts nested Stripe secret fields and values before formatting", () => { + const output = Logger.formatValue({ + payParams: { + clientSecret: "pi_123_secret_card", + nested: { + customerSessionClientSecret: "cuss_123_secret_saved", + handoffToken: "one-time-checkout-token", + safe: "order-123", + }, + }, + }); + + expect(output).toContain("order-123"); + expect(output).not.toContain("secret_card"); + expect(output).not.toContain("secret_saved"); + expect(output).not.toContain("one-time-checkout-token"); + expect(output.match(/\[REDACTED\]/g)?.length).toBeGreaterThanOrEqual(3); + }); + + it("redacts a secret embedded in a serialized response string", () => { + const output = Logger.formatValue( + JSON.stringify({ customerSessionClientSecret: "cuss_1_secret_value" }), + ); + + expect(output).not.toContain("secret_value"); + expect(output).toContain("[REDACTED]"); + }); +}); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 29b0f91a..49288b70 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -107,9 +107,13 @@ export class Logger { const trimmed = value.trim(); if (trimmed.length === 0) return ""; try { - return JSON.stringify(JSON.parse(trimmed), null, 2); + return JSON.stringify( + Logger.toSerializableLogValue(JSON.parse(trimmed)), + null, + 2, + ); } catch { - return value; + return Logger.redactSensitiveString(value); } } @@ -149,28 +153,31 @@ export class Logger { } private write(level: LogLevel, args: LogArgs): void { - Logger.reportImportantProductionLog(level, this.component, args); + const safeArgs = args.map((value) => + Logger.toSerializableLogValue(value), + ); + Logger.reportImportantProductionLog(level, this.component, safeArgs); if (Logger.shouldUseBrowserConsole()) { - Logger.writeBrowserConsole(level, this.component, args); + Logger.writeBrowserConsole(level, this.component, safeArgs); return; } switch (level) { case "debug": - this.logger.debug(...Logger.normalizeLogArgs(args)); + this.logger.debug(...Logger.normalizeLogArgs(safeArgs)); return; case "info": - this.logger.info(...Logger.normalizeLogArgs(args)); + this.logger.info(...Logger.normalizeLogArgs(safeArgs)); return; case "warn": - this.logger.warn(...Logger.normalizeLogArgs(args)); + this.logger.warn(...Logger.normalizeLogArgs(safeArgs)); return; case "error": - this.logger.error(...Logger.normalizeLogArgs(args)); + this.logger.error(...Logger.normalizeLogArgs(safeArgs)); return; case "fatal": - this.logger.fatal(...Logger.normalizeLogArgs(args)); + this.logger.fatal(...Logger.normalizeLogArgs(safeArgs)); return; } } @@ -266,6 +273,10 @@ export class Logger { return value.map((item) => Logger.toSerializableLogValue(item, seen)); } + if (typeof value === "string") { + return Logger.redactSensitiveString(value); + } + if (typeof value !== "object" || value === null) { return value; } @@ -281,19 +292,39 @@ export class Logger { const output: Record = {}; for (const [key, item] of Object.entries(value)) { - output[key] = Logger.toSerializableLogValue(item, seen); + output[key] = Logger.isSensitiveLogKey(key) + ? "[REDACTED]" + : Logger.toSerializableLogValue(item, seen); } return output; } + private static isSensitiveLogKey(key: string): boolean { + const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase(); + return ( + normalized === "clientsecret" || + normalized === "customersessionclientsecret" || + normalized === "handofftoken" || + normalized === "accesstoken" || + normalized === "refreshtoken" || + normalized === "authorization" + ); + } + + private static redactSensitiveString(value: string): string { + return /_secret_/i.test(value) ? "[REDACTED]" : value; + } + private static serializeError( error: Error, seen: WeakSet, ): Record { const output: Record = { name: error.name, - message: error.message, - stack: error.stack, + message: Logger.redactSensitiveString(error.message), + stack: error.stack + ? Logger.redactSensitiveString(error.stack) + : undefined, }; if (seen.has(error)) { @@ -303,10 +334,12 @@ export class Logger { for (const key of Object.getOwnPropertyNames(error)) { if (key in output) continue; - output[key] = Logger.toSerializableLogValue( - (error as unknown as Record)[key], - seen, - ); + output[key] = Logger.isSensitiveLogKey(key) + ? "[REDACTED]" + : Logger.toSerializableLogValue( + (error as unknown as Record)[key], + seen, + ); } return output; }
+ {errorMessage} +
(null); const [handoffCompleted, setHandoffCompleted] = useState(false); + const [checkoutDestination, setCheckoutDestination] = useState( + null, + ); const hasNavigatedRef = useRef(false); const handoffStartedRef = useRef(false); const targetRoute = resolveExternalEntryTarget({ target }); @@ -71,11 +76,14 @@ export default function ExternalEntryPersist({ mode, promotionType, }); - const isTopUpHandoff = targetRoute === ROUTES.subscription; + const normalizedTarget = target?.trim().toLowerCase() ?? ""; + const isTopUpHandoff = normalizedTarget === "topup"; + const isCheckoutHandoff = normalizedTarget === "checkout"; + const isLoginHandoff = isTopUpHandoff || isCheckoutHandoff; const displayedHandoffError = handoffError ?? - (isTopUpHandoff && !hasValue(handoffToken) - ? "This top-up link is invalid. Please request a new link in Messenger." + (isLoginHandoff && !hasValue(handoffToken) + ? "This checkout link is invalid. Please return and request a new link." : null); useEffect(() => { @@ -128,7 +136,7 @@ export default function ExternalEntryPersist({ useEffect(() => { if (hasNavigatedRef.current || !hasPersisted) return; - if (isTopUpHandoff) { + if (isLoginHandoff) { if (!authState.hasInitialized || authState.isLoading) return; if (handoffCompleted) { if ( @@ -138,25 +146,60 @@ export default function ExternalEntryPersist({ return; } hasNavigatedRef.current = true; - router.replace(destination); + router.replace(checkoutDestination ?? destination); return; } if (handoffStartedRef.current) return; if (!hasValue(handoffToken)) return; handoffStartedRef.current = true; void (async () => { - const result = await consumeTopUpHandoff(handoffToken); - if (Result.isErr(result)) { - log.warn("[ExternalEntryPersist] top-up handoff failed", result.error); + if (isCheckoutHandoff) { + const result = await consumeCheckoutHandoff(handoffToken); + if (Result.isErr(result)) { + log.warn( + "[ExternalEntryPersist] checkout handoff failed", + result.error, + ); + window.history.replaceState( + window.history.state, + "", + "/external-entry?target=checkout", + ); + setHandoffError( + "This checkout link is invalid, expired, or already used. Please return and request a new link.", + ); + return; + } + window.history.replaceState( + window.history.state, + "", + "/external-entry?target=checkout", + ); + setCheckoutDestination( + resolveCheckoutIntentDestination(result.data), + ); + } else { + const result = await consumeTopUpHandoff(handoffToken); + if (Result.isErr(result)) { + log.warn( + "[ExternalEntryPersist] top-up handoff failed", + result.error, + ); + window.history.replaceState( + window.history.state, + "", + "/external-entry?target=topup", + ); + setHandoffError( + "This top-up link is invalid or has expired. Please request a new link in Messenger.", + ); + return; + } window.history.replaceState( window.history.state, "", "/external-entry?target=topup", ); - setHandoffError( - "This top-up link is invalid or has expired. Please request a new link in Messenger.", - ); - return; } setHandoffCompleted(true); authDispatch({ type: "AuthInit" }); @@ -190,9 +233,12 @@ export default function ExternalEntryPersist({ authState.isLoading, authState.loginStatus, destination, + checkoutDestination, hasPersisted, handoffCompleted, handoffToken, + isCheckoutHandoff, + isLoginHandoff, isTopUpHandoff, psid, router, diff --git a/src/app/external-entry/page.tsx b/src/app/external-entry/page.tsx index 6dfc6319..0a93552e 100644 --- a/src/app/external-entry/page.tsx +++ b/src/app/external-entry/page.tsx @@ -8,6 +8,7 @@ * `/external-entry?target=tip` * `/external-entry?target=private-zone&character=nayeli` * `/external-entry?target=topup&handoffToken=` + * `/external-entry?target=checkout&handoffToken=` * * 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储, * 再通过 `router.replace()` 清理 URL 并跳转到最终页面。 diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index ed745726..434e4cf3 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -6,6 +6,7 @@ */ import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow"; import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs"; +import { ExternalBrowserCheckoutButton } from "@/app/_components/payment/external-browser-checkout-button"; import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character"; import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit"; import { @@ -70,6 +71,21 @@ export function SubscriptionCheckoutButton({ > {label} + {payment.payChannel === "stripe" && payment.selectedPlanId ? ( + + ) : null} {payment.errorMessage ? ( {label} + {payment.payChannel === "stripe" && payment.selectedPlanId ? ( + + ) : null} {payment.errorMessage ? ( {payment.errorMessage} diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts index c581a79b..19138893 100644 --- a/src/data/repositories/auth_repository.ts +++ b/src/data/repositories/auth_repository.ts @@ -1,6 +1,10 @@ import { ExceptionHandler } from "@/core/errors"; import type { IAuthRepository } from "@/data/repositories/interfaces"; import { + CheckoutHandoffConsumeRequestSchema, + CheckoutHandoffCreateRequestSchema, + type CheckoutHandoffCreateResponse, + type CheckoutIntent, FacebookIdentityRequestSchema, FacebookLoginRequestSchema, FbIdLoginRequestSchema, @@ -276,6 +280,30 @@ export class AuthRepository implements IAuthRepository { }); } + /** 创建订单前生成一次性外部浏览器支付交接链接。 */ + async createCheckoutHandoff( + checkoutIntent: CheckoutIntent, + ): Promise> { + return Result.wrap(() => + this.api.createCheckoutHandoff( + CheckoutHandoffCreateRequestSchema.parse(checkoutIntent), + ), + ); + } + + /** 消费支付交接凭证,保存后端签发的正式登录态并返回购买意图。 */ + async consumeCheckoutHandoff( + handoffToken: string, + ): Promise> { + return Result.wrap(async () => { + const response = await this.api.consumeCheckoutHandoff( + CheckoutHandoffConsumeRequestSchema.parse({ handoffToken }), + ); + await this._saveLoginData(response, response.loginStatus); + return response.checkoutIntent; + }); + } + /** * 刷新 token:先读本地的 refresh token,空则直接返回错误; * 调用 API 成功后写回新的 login token + refresh token。 diff --git a/src/data/repositories/interfaces/iauth_repository.ts b/src/data/repositories/interfaces/iauth_repository.ts index 4a2e55a0..305c4d3c 100644 --- a/src/data/repositories/interfaces/iauth_repository.ts +++ b/src/data/repositories/interfaces/iauth_repository.ts @@ -13,6 +13,8 @@ import type { Result } from "@/utils/result"; import type { + CheckoutHandoffCreateResponse, + CheckoutIntent, GuestLoginResponse, LoginStatus, LoginResponse, @@ -74,6 +76,16 @@ export interface IAuthRepository { /** 消费一次性充值登录凭证并建立正式会话。 */ consumeTopUpHandoff(handoffToken: string): Promise>; + /** 在创建订单前生成10分钟有效的外部浏览器支付链接。 */ + createCheckoutHandoff( + checkoutIntent: CheckoutIntent, + ): Promise>; + + /** 消费单次支付交接凭证并保存正式登录态。 */ + consumeCheckoutHandoff( + handoffToken: string, + ): Promise>; + /** 刷新 token:先读本地的 refresh token,空则返回错误。 */ refreshToken(): Promise>; diff --git a/src/data/schemas/auth/__tests__/checkout_handoff.test.ts b/src/data/schemas/auth/__tests__/checkout_handoff.test.ts new file mode 100644 index 00000000..7b1065ec --- /dev/null +++ b/src/data/schemas/auth/__tests__/checkout_handoff.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { + CheckoutHandoffConsumeResponseSchema, + CheckoutHandoffCreateRequestSchema, +} from "../checkout_handoff"; + +describe("checkout handoff schemas", () => { + it("keeps only the minimal order-free purchase intent", () => { + expect( + CheckoutHandoffCreateRequestSchema.parse({ + planId: "vip_monthly", + autoRenew: true, + recipientCharacterId: "elio", + price: 9.99, + }), + ).toEqual({ + planId: "vip_monthly", + autoRenew: true, + recipientCharacterId: "elio", + }); + }); + + it("parses the formal session and checkout intent returned after consumption", () => { + const parsed = CheckoutHandoffConsumeResponseSchema.parse({ + user: { + id: "00000000-0000-0000-0000-000000000123", + username: "checkout-user", + email: "checkout@example.com", + platform: "web", + }, + token: "access-token", + refreshToken: "refresh-token", + loginStatus: "email", + checkoutIntent: { + planId: "vip_monthly", + autoRenew: true, + }, + }); + + expect(parsed.checkoutIntent.planId).toBe("vip_monthly"); + expect(parsed.loginStatus).toBe("email"); + }); +}); diff --git a/src/data/schemas/auth/checkout_handoff.ts b/src/data/schemas/auth/checkout_handoff.ts new file mode 100644 index 00000000..f97be1d0 --- /dev/null +++ b/src/data/schemas/auth/checkout_handoff.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +import { stringOrEmpty } from "../nullable-defaults"; +import { UserSchema } from "../user/user"; + +export const CheckoutIntentSchema = z + .object({ + planId: z.string().min(1).max(120), + autoRenew: z.boolean(), + recipientCharacterId: z.string().min(1).max(80).optional(), + commercialOfferId: z.string().min(1).max(80).optional(), + chatActionId: z.uuid().optional(), + }) + .readonly(); + +export const CheckoutHandoffCreateRequestSchema = CheckoutIntentSchema; + +export const CheckoutHandoffCreateResponseSchema = z + .object({ + externalUrl: z.url(), + expiresAt: z.string().min(1), + }) + .readonly(); + +export const CheckoutHandoffConsumeRequestSchema = z + .object({ + handoffToken: z.string().min(32).max(512), + }) + .readonly(); + +export const CheckoutHandoffConsumeResponseSchema = z + .object({ + user: UserSchema, + token: z.string().min(1), + refreshToken: stringOrEmpty, + loginStatus: z.enum([ + "email", + "google", + "facebook", + "facebookMessenger", + ]), + checkoutIntent: CheckoutIntentSchema, + }) + .readonly(); + +export type CheckoutIntent = z.output; +export type CheckoutHandoffCreateRequest = z.output< + typeof CheckoutHandoffCreateRequestSchema +>; +export type CheckoutHandoffCreateResponse = z.output< + typeof CheckoutHandoffCreateResponseSchema +>; +export type CheckoutHandoffConsumeRequest = z.output< + typeof CheckoutHandoffConsumeRequestSchema +>; +export type CheckoutHandoffConsumeResponse = z.output< + typeof CheckoutHandoffConsumeResponseSchema +>; diff --git a/src/data/schemas/auth/index.ts b/src/data/schemas/auth/index.ts index 0446d39b..5a6f533f 100644 --- a/src/data/schemas/auth/index.ts +++ b/src/data/schemas/auth/index.ts @@ -3,6 +3,7 @@ */ export * from "./facebook_user_data"; +export * from "./checkout_handoff"; export * from "./login_status"; export * from "./request/facebook_identity_request"; export * from "./request/facebook_login_request"; diff --git a/src/data/services/api/api_contract.json b/src/data/services/api/api_contract.json index c78af6d7..5cd59520 100644 --- a/src/data/services/api/api_contract.json +++ b/src/data/services/api/api_contract.json @@ -6,6 +6,8 @@ "facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" }, "facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" }, "topUpHandoffConsume": { "method": "post", "path": "/api/auth/handoff/topup/consume" }, + "checkoutHandoffCreate": { "method": "post", "path": "/api/auth/handoff/checkout" }, + "checkoutHandoffConsume": { "method": "post", "path": "/api/auth/handoff/checkout/consume" }, "refresh": { "method": "post", "path": "/api/auth/refresh" }, "logout": { "method": "post", "path": "/api/auth/logout" }, "getCurrentUser": { "method": "get", "path": "/api/auth/me" }, diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 22b7e7d2..6828c1ed 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -30,6 +30,12 @@ export class ApiPath { /** 消费一次性充值登录凭证 */ static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path; + /** 创建一次性外部浏览器支付交接链接(创建订单前)。 */ + static readonly checkoutHandoffCreate = apiContract.checkoutHandoffCreate.path; + + /** 消费一次性外部浏览器支付交接凭证。 */ + static readonly checkoutHandoffConsume = apiContract.checkoutHandoffConsume.path; + /** 刷新 Token */ static readonly refresh = apiContract.refresh.path; diff --git a/src/data/services/api/auth_api.ts b/src/data/services/api/auth_api.ts index c4f67254..8da96b18 100644 --- a/src/data/services/api/auth_api.ts +++ b/src/data/services/api/auth_api.ts @@ -7,6 +7,12 @@ import { z } from "zod"; import { + CheckoutHandoffConsumeRequest, + CheckoutHandoffConsumeResponse, + CheckoutHandoffConsumeResponseSchema, + CheckoutHandoffCreateRequest, + CheckoutHandoffCreateResponse, + CheckoutHandoffCreateResponseSchema, FacebookIdentityRequest, FacebookIdentityResponse, FacebookIdentityResponseSchema, @@ -104,6 +110,32 @@ export class AuthApi { ); } + /** 在创建支付订单前生成一次性外部浏览器交接链接。 */ + async createCheckoutHandoff( + body: CheckoutHandoffCreateRequest, + ): Promise { + const env = await httpClient>( + ApiPath.checkoutHandoffCreate, + { method: "POST", body }, + ); + return CheckoutHandoffCreateResponseSchema.parse( + unwrap(env) as Record, + ); + } + + /** 消费一次性支付交接凭证并恢复正式登录态与购买意图。 */ + async consumeCheckoutHandoff( + body: CheckoutHandoffConsumeRequest, + ): Promise { + const env = await httpClient>( + ApiPath.checkoutHandoffConsume, + { method: "POST", body }, + ); + return CheckoutHandoffConsumeResponseSchema.parse( + unwrap(env) as Record, + ); + } + /** * 绑定 Facebook ASID / PSID 到当前用户 */ diff --git a/src/lib/auth/checkout_handoff.ts b/src/lib/auth/checkout_handoff.ts new file mode 100644 index 00000000..b9dc2e52 --- /dev/null +++ b/src/lib/auth/checkout_handoff.ts @@ -0,0 +1,18 @@ +import type { + CheckoutHandoffCreateResponse, + CheckoutIntent, +} from "@/data/schemas/auth"; +import { getAuthRepository } from "@/data/repositories/auth_repository"; +import type { Result } from "@/utils/result"; + +export function createCheckoutHandoff( + checkoutIntent: CheckoutIntent, +): Promise> { + return getAuthRepository().createCheckoutHandoff(checkoutIntent); +} + +export function consumeCheckoutHandoff( + handoffToken: string, +): Promise> { + return getAuthRepository().consumeCheckoutHandoff(handoffToken); +} diff --git a/src/lib/navigation/__tests__/external_entry.test.ts b/src/lib/navigation/__tests__/external_entry.test.ts index 9d98e7ba..a165beee 100644 --- a/src/lib/navigation/__tests__/external_entry.test.ts +++ b/src/lib/navigation/__tests__/external_entry.test.ts @@ -3,12 +3,38 @@ import { describe, expect, it } from "vitest"; import { getCharacterRoutes, ROUTES } from "@/router/routes"; import { + resolveCheckoutIntentDestination, resolveExternalEntryDestination, resolveExternalEntryPromotionType, resolveExternalEntryTarget, shouldBindExternalFacebookIdentity, } from "../external_entry"; +describe("checkout external browser destination", () => { + it("restores subscription intent without carrying the handoff token", () => { + expect( + resolveCheckoutIntentDestination({ + planId: "vip_monthly", + autoRenew: true, + commercialOfferId: "offer-1", + chatActionId: "00000000-0000-0000-0000-000000000123", + }), + ).toBe( + "/subscription?planId=vip_monthly&autoRenew=1&commercialOfferId=offer-1&chatActionId=00000000-0000-0000-0000-000000000123&payChannel=stripe", + ); + }); + + it("restores a character tip intent by canonical character id", () => { + expect( + resolveCheckoutIntentDestination({ + planId: "maya_coffee", + autoRenew: false, + recipientCharacterId: "maya-tan", + }), + ).toBe("/characters/maya/tip?planId=maya_coffee&payChannel=stripe"); + }); +}); + const facebookBusinessEntryCases = [ ["private-space", "elio", "chat", null, null, getCharacterRoutes("elio").chat, null], ["private-space", "maya", "chat", null, null, getCharacterRoutes("maya").chat, null], diff --git a/src/lib/navigation/external_entry.ts b/src/lib/navigation/external_entry.ts index 8dcee9d3..a67515df 100644 --- a/src/lib/navigation/external_entry.ts +++ b/src/lib/navigation/external_entry.ts @@ -4,8 +4,10 @@ import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { UserStorage } from "@/data/storage/user/user_storage"; import type { PendingChatPromotionType } from "@/data/storage/navigation"; import type { LoginStatus } from "@/data/schemas/auth"; +import type { CheckoutIntent } from "@/data/schemas/auth"; import { DEFAULT_CHARACTER_SLUG, + getCharacterById, getCharacterBySlug, } from "@/data/constants/character"; import { getCharacterRoutes, ROUTES } from "@/router/routes"; @@ -100,6 +102,30 @@ export function resolveExternalEntryDestination({ return routes.chat; } +export function resolveCheckoutIntentDestination( + intent: CheckoutIntent, +): string { + const params = new URLSearchParams({ planId: intent.planId }); + + if (intent.recipientCharacterId) { + const character = getCharacterById(intent.recipientCharacterId); + const routes = getCharacterRoutes( + character?.slug ?? DEFAULT_CHARACTER_SLUG, + ); + if (intent.chatActionId) params.set("chatActionId", intent.chatActionId); + params.set("payChannel", "stripe"); + return `${routes.tip}?${params.toString()}`; + } + + params.set("autoRenew", intent.autoRenew ? "1" : "0"); + if (intent.commercialOfferId) { + params.set("commercialOfferId", intent.commercialOfferId); + } + if (intent.chatActionId) params.set("chatActionId", intent.chatActionId); + params.set("payChannel", "stripe"); + return `${ROUTES.subscription}?${params.toString()}`; +} + export async function persistExternalEntryPayload({ deviceId, asid, @@ -138,6 +164,7 @@ function resolveTarget( return ROUTES.privateZone; } if (target === "topup") return ROUTES.subscription; + if (target === "checkout") return ROUTES.subscription; return null; } diff --git a/src/lib/payment/__tests__/payment_launch.test.ts b/src/lib/payment/__tests__/payment_launch.test.ts index d931b6c6..e7f9b148 100644 --- a/src/lib/payment/__tests__/payment_launch.test.ts +++ b/src/lib/payment/__tests__/payment_launch.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { getPaymentUrl, + getStripeCustomerSessionClientSecret, getStripeClientSecret, isEzpayPayment, launchEzpayRedirect, @@ -43,6 +44,28 @@ describe("payment launch helpers", () => { ).toBeNull(); }); + it("enables saved cards only with an explicit flag and a Customer Session secret", () => { + expect( + getStripeCustomerSessionClientSecret({ + provider: "stripe", + customerSessionClientSecret: "cuss_123_secret_456", + savedPaymentMethodsEnabled: true, + }), + ).toBe("cuss_123_secret_456"); + expect( + getStripeCustomerSessionClientSecret({ + customerSessionClientSecret: "cuss_123_secret_456", + savedPaymentMethodsEnabled: false, + }), + ).toBeNull(); + expect( + getStripeCustomerSessionClientSecret({ + customerSessionClientSecret: "invalid-customer-session", + savedPaymentMethodsEnabled: true, + }), + ).toBeNull(); + }); + it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => { expect( resolveEzpayLaunchTarget({ diff --git a/src/lib/payment/payment_launch.ts b/src/lib/payment/payment_launch.ts index 71b93286..1563a5a1 100644 --- a/src/lib/payment/payment_launch.ts +++ b/src/lib/payment/payment_launch.ts @@ -143,6 +143,27 @@ export function getStripeClientSecret( return null; } +export function getStripeCustomerSessionClientSecret( + payParams: Record, +): string | null { + const provider = payParams.provider; + const isStripeProvider = + typeof provider !== "string" || provider.toLowerCase() === "stripe"; + const enabled = payParams.savedPaymentMethodsEnabled === true; + const clientSecret = payParams.customerSessionClientSecret; + + if ( + isStripeProvider && + enabled && + typeof clientSecret === "string" && + /^cuss_[^\s]+_secret_[^\s]+$/i.test(clientSecret) + ) { + return clientSecret; + } + + return null; +} + export interface LaunchEzpayRedirectInput { orderId: string | null; paymentUrl: string; diff --git a/src/lib/payment/payment_search_params.ts b/src/lib/payment/payment_search_params.ts index 2850d3f9..c054200e 100644 --- a/src/lib/payment/payment_search_params.ts +++ b/src/lib/payment/payment_search_params.ts @@ -22,6 +22,15 @@ export function parsePaymentPayChannel( return channel === "ezpay" || channel === "stripe" ? channel : null; } +export function parsePaymentAutoRenew( + value: PaymentSearchParamValue, +): boolean | null { + const autoRenew = getFirstPaymentSearchParam(value); + if (autoRenew === "1" || autoRenew === "true") return true; + if (autoRenew === "0" || autoRenew === "false") return false; + return null; +} + export function parseSubscriptionReturnTo( value: PaymentSearchParamValue, ): AppSubscriptionReturnTo { diff --git a/src/stores/payment/__tests__/payment-catalog-flow.test.ts b/src/stores/payment/__tests__/payment-catalog-flow.test.ts index 07193cfe..97380549 100644 --- a/src/stores/payment/__tests__/payment-catalog-flow.test.ts +++ b/src/stores/payment/__tests__/payment-catalog-flow.test.ts @@ -8,7 +8,6 @@ import { import { createTestPaymentMachine, - giftCatalog, lifetimePlan, monthlyPlan, quarterlyPlan, @@ -51,7 +50,7 @@ describe("payment catalog flow", () => { actor.stop(); }); - it("restores a valid first-category product and rejects another category", async () => { + it("restores a valid tip product across categories from checkout handoff", async () => { const actor = createActor(createTestPaymentMachine()).start(); actor.send({ type: "PaymentInit", @@ -79,8 +78,9 @@ describe("payment catalog flow", () => { snapshot.context.requestedGiftPlanId === null, ); expect(actor.getSnapshot().context.selectedPlanId).toBe( - giftCatalog.plans[0]?.planId, + "tip_flowers_usd_12_99", ); + expect(actor.getSnapshot().context.selectedGiftCategory).toBe("flowers"); actor.stop(); }); diff --git a/src/stores/payment/helper/gift.ts b/src/stores/payment/helper/gift.ts index c880ab9c..e5e04189 100644 --- a/src/stores/payment/helper/gift.ts +++ b/src/stores/payment/helper/gift.ts @@ -50,15 +50,23 @@ export function hydrateGiftProductsState( const giftProducts = response.plans.filter( (product) => product.characterId === characterId, ); - const selectedGiftCategory = giftCategories[0]?.category ?? null; + const restoredProduct = restoredPlanId + ? giftProducts.find((product) => product.planId === restoredPlanId) + : null; + const selectedGiftCategory = + restoredProduct?.category ?? + (restoredCategory && + giftCategories.some((category) => category.category === restoredCategory) + ? restoredCategory + : (giftCategories[0]?.category ?? null)); const visibleProducts = selectedGiftCategory ? giftProducts.filter( (product) => product.category === selectedGiftCategory, ) : []; - const canRestoreSelection = - restoredCategory === selectedGiftCategory && - visibleProducts.some((product) => product.planId === restoredPlanId); + const canRestoreSelection = visibleProducts.some( + (product) => product.planId === restoredPlanId, + ); const selectedPlanId = canRestoreSelection ? (restoredPlanId ?? "") : (visibleProducts[0]?.planId ?? ""); diff --git a/src/stores/payment/machine/catalog-flow.ts b/src/stores/payment/machine/catalog-flow.ts index 0cd62b0d..32a99fbb 100644 --- a/src/stores/payment/machine/catalog-flow.ts +++ b/src/stores/payment/machine/catalog-flow.ts @@ -32,14 +32,18 @@ function initializeCatalogState( const catalogChanged = planCatalog !== context.planCatalog; const characterChanged = giftCharacterId !== context.giftCharacterId; const selectionChanged = - planCatalog === "tip" && - ((event.category ?? null) !== context.selectedGiftCategory || - (event.planId ?? null) !== (context.selectedPlanId || null)); + planCatalog === "tip" + ? (event.category ?? null) !== context.selectedGiftCategory || + (event.planId ?? null) !== (context.selectedPlanId || null) + : (event.planId ?? null) !== (context.selectedPlanId || null); const commercialOfferId = planCatalog === "default" ? (event.commercialOfferId ?? null) : null; const offerChanged = commercialOfferId !== context.commercialOfferId; const chatActionId = event.chatActionId ?? null; const chatActionChanged = chatActionId !== context.chatActionId; + const initialAutoRenew = + planCatalog === "tip" ? false : (event.autoRenew ?? true); + const autoRenewChanged = initialAutoRenew !== context.autoRenew; return { planCatalog, @@ -51,7 +55,12 @@ function initializeCatalogState( planCatalog === "tip" ? (event.category ?? null) : null, requestedGiftPlanId: planCatalog === "tip" ? (event.planId ?? null) : null, - ...(catalogChanged || characterChanged || selectionChanged || offerChanged || chatActionChanged + ...(catalogChanged || + characterChanged || + selectionChanged || + offerChanged || + chatActionChanged || + autoRenewChanged ? { ...resetOrderState(), plans: [], @@ -62,7 +71,7 @@ function initializeCatalogState( planCatalog === "default" ? (event.planId ?? "") : "", isFirstRecharge: false, commercialOffer: null, - autoRenew: planCatalog !== "tip", + autoRenew: initialAutoRenew, } : {}), }; diff --git a/src/stores/payment/payment-context.tsx b/src/stores/payment/payment-context.tsx index 7b3d29e2..a731436b 100644 --- a/src/stores/payment/payment-context.tsx +++ b/src/stores/payment/payment-context.tsx @@ -20,6 +20,8 @@ export interface PaymentContextState { selectedGiftCategory: string | null; isFirstRecharge: MachineContext["isFirstRecharge"]; commercialOffer: MachineContext["commercialOffer"]; + commercialOfferId: string | null; + chatActionId: string | null; selectedPlanId: string; payChannel: MachineContext["payChannel"]; autoRenew: boolean; @@ -76,6 +78,8 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState { selectedGiftCategory: state.context.selectedGiftCategory, isFirstRecharge: state.context.isFirstRecharge, commercialOffer: state.context.commercialOffer, + commercialOfferId: state.context.commercialOfferId, + chatActionId: state.context.chatActionId, selectedPlanId: state.context.selectedPlanId, payChannel: state.context.payChannel, autoRenew: state.context.autoRenew, diff --git a/src/stores/payment/payment-events.ts b/src/stores/payment/payment-events.ts index 3d75b52f..13c86a22 100644 --- a/src/stores/payment/payment-events.ts +++ b/src/stores/payment/payment-events.ts @@ -10,6 +10,7 @@ export type PaymentEvent = characterId?: string; category?: string | null; planId?: string | null; + autoRenew?: boolean; commercialOfferId?: string | null; chatActionId?: string | null; } diff --git a/src/utils/__tests__/logger-redaction.test.ts b/src/utils/__tests__/logger-redaction.test.ts new file mode 100644 index 00000000..f93faaca --- /dev/null +++ b/src/utils/__tests__/logger-redaction.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { Logger } from "../logger"; + +describe("Logger Stripe secret redaction", () => { + it("redacts nested Stripe secret fields and values before formatting", () => { + const output = Logger.formatValue({ + payParams: { + clientSecret: "pi_123_secret_card", + nested: { + customerSessionClientSecret: "cuss_123_secret_saved", + handoffToken: "one-time-checkout-token", + safe: "order-123", + }, + }, + }); + + expect(output).toContain("order-123"); + expect(output).not.toContain("secret_card"); + expect(output).not.toContain("secret_saved"); + expect(output).not.toContain("one-time-checkout-token"); + expect(output.match(/\[REDACTED\]/g)?.length).toBeGreaterThanOrEqual(3); + }); + + it("redacts a secret embedded in a serialized response string", () => { + const output = Logger.formatValue( + JSON.stringify({ customerSessionClientSecret: "cuss_1_secret_value" }), + ); + + expect(output).not.toContain("secret_value"); + expect(output).toContain("[REDACTED]"); + }); +}); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 29b0f91a..49288b70 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -107,9 +107,13 @@ export class Logger { const trimmed = value.trim(); if (trimmed.length === 0) return ""; try { - return JSON.stringify(JSON.parse(trimmed), null, 2); + return JSON.stringify( + Logger.toSerializableLogValue(JSON.parse(trimmed)), + null, + 2, + ); } catch { - return value; + return Logger.redactSensitiveString(value); } } @@ -149,28 +153,31 @@ export class Logger { } private write(level: LogLevel, args: LogArgs): void { - Logger.reportImportantProductionLog(level, this.component, args); + const safeArgs = args.map((value) => + Logger.toSerializableLogValue(value), + ); + Logger.reportImportantProductionLog(level, this.component, safeArgs); if (Logger.shouldUseBrowserConsole()) { - Logger.writeBrowserConsole(level, this.component, args); + Logger.writeBrowserConsole(level, this.component, safeArgs); return; } switch (level) { case "debug": - this.logger.debug(...Logger.normalizeLogArgs(args)); + this.logger.debug(...Logger.normalizeLogArgs(safeArgs)); return; case "info": - this.logger.info(...Logger.normalizeLogArgs(args)); + this.logger.info(...Logger.normalizeLogArgs(safeArgs)); return; case "warn": - this.logger.warn(...Logger.normalizeLogArgs(args)); + this.logger.warn(...Logger.normalizeLogArgs(safeArgs)); return; case "error": - this.logger.error(...Logger.normalizeLogArgs(args)); + this.logger.error(...Logger.normalizeLogArgs(safeArgs)); return; case "fatal": - this.logger.fatal(...Logger.normalizeLogArgs(args)); + this.logger.fatal(...Logger.normalizeLogArgs(safeArgs)); return; } } @@ -266,6 +273,10 @@ export class Logger { return value.map((item) => Logger.toSerializableLogValue(item, seen)); } + if (typeof value === "string") { + return Logger.redactSensitiveString(value); + } + if (typeof value !== "object" || value === null) { return value; } @@ -281,19 +292,39 @@ export class Logger { const output: Record = {}; for (const [key, item] of Object.entries(value)) { - output[key] = Logger.toSerializableLogValue(item, seen); + output[key] = Logger.isSensitiveLogKey(key) + ? "[REDACTED]" + : Logger.toSerializableLogValue(item, seen); } return output; } + private static isSensitiveLogKey(key: string): boolean { + const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase(); + return ( + normalized === "clientsecret" || + normalized === "customersessionclientsecret" || + normalized === "handofftoken" || + normalized === "accesstoken" || + normalized === "refreshtoken" || + normalized === "authorization" + ); + } + + private static redactSensitiveString(value: string): string { + return /_secret_/i.test(value) ? "[REDACTED]" : value; + } + private static serializeError( error: Error, seen: WeakSet, ): Record { const output: Record = { name: error.name, - message: error.message, - stack: error.stack, + message: Logger.redactSensitiveString(error.message), + stack: error.stack + ? Logger.redactSensitiveString(error.stack) + : undefined, }; if (seen.has(error)) { @@ -303,10 +334,12 @@ export class Logger { for (const key of Object.getOwnPropertyNames(error)) { if (key in output) continue; - output[key] = Logger.toSerializableLogValue( - (error as unknown as Record)[key], - seen, - ); + output[key] = Logger.isSensitiveLogKey(key) + ? "[REDACTED]" + : Logger.toSerializableLogValue( + (error as unknown as Record)[key], + seen, + ); } return output; }
{label} + {payment.payChannel === "stripe" && payment.selectedPlanId ? ( + + ) : null} {payment.errorMessage ? (
{payment.errorMessage} diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts index c581a79b..19138893 100644 --- a/src/data/repositories/auth_repository.ts +++ b/src/data/repositories/auth_repository.ts @@ -1,6 +1,10 @@ import { ExceptionHandler } from "@/core/errors"; import type { IAuthRepository } from "@/data/repositories/interfaces"; import { + CheckoutHandoffConsumeRequestSchema, + CheckoutHandoffCreateRequestSchema, + type CheckoutHandoffCreateResponse, + type CheckoutIntent, FacebookIdentityRequestSchema, FacebookLoginRequestSchema, FbIdLoginRequestSchema, @@ -276,6 +280,30 @@ export class AuthRepository implements IAuthRepository { }); } + /** 创建订单前生成一次性外部浏览器支付交接链接。 */ + async createCheckoutHandoff( + checkoutIntent: CheckoutIntent, + ): Promise> { + return Result.wrap(() => + this.api.createCheckoutHandoff( + CheckoutHandoffCreateRequestSchema.parse(checkoutIntent), + ), + ); + } + + /** 消费支付交接凭证,保存后端签发的正式登录态并返回购买意图。 */ + async consumeCheckoutHandoff( + handoffToken: string, + ): Promise> { + return Result.wrap(async () => { + const response = await this.api.consumeCheckoutHandoff( + CheckoutHandoffConsumeRequestSchema.parse({ handoffToken }), + ); + await this._saveLoginData(response, response.loginStatus); + return response.checkoutIntent; + }); + } + /** * 刷新 token:先读本地的 refresh token,空则直接返回错误; * 调用 API 成功后写回新的 login token + refresh token。 diff --git a/src/data/repositories/interfaces/iauth_repository.ts b/src/data/repositories/interfaces/iauth_repository.ts index 4a2e55a0..305c4d3c 100644 --- a/src/data/repositories/interfaces/iauth_repository.ts +++ b/src/data/repositories/interfaces/iauth_repository.ts @@ -13,6 +13,8 @@ import type { Result } from "@/utils/result"; import type { + CheckoutHandoffCreateResponse, + CheckoutIntent, GuestLoginResponse, LoginStatus, LoginResponse, @@ -74,6 +76,16 @@ export interface IAuthRepository { /** 消费一次性充值登录凭证并建立正式会话。 */ consumeTopUpHandoff(handoffToken: string): Promise>; + /** 在创建订单前生成10分钟有效的外部浏览器支付链接。 */ + createCheckoutHandoff( + checkoutIntent: CheckoutIntent, + ): Promise>; + + /** 消费单次支付交接凭证并保存正式登录态。 */ + consumeCheckoutHandoff( + handoffToken: string, + ): Promise>; + /** 刷新 token:先读本地的 refresh token,空则返回错误。 */ refreshToken(): Promise>; diff --git a/src/data/schemas/auth/__tests__/checkout_handoff.test.ts b/src/data/schemas/auth/__tests__/checkout_handoff.test.ts new file mode 100644 index 00000000..7b1065ec --- /dev/null +++ b/src/data/schemas/auth/__tests__/checkout_handoff.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { + CheckoutHandoffConsumeResponseSchema, + CheckoutHandoffCreateRequestSchema, +} from "../checkout_handoff"; + +describe("checkout handoff schemas", () => { + it("keeps only the minimal order-free purchase intent", () => { + expect( + CheckoutHandoffCreateRequestSchema.parse({ + planId: "vip_monthly", + autoRenew: true, + recipientCharacterId: "elio", + price: 9.99, + }), + ).toEqual({ + planId: "vip_monthly", + autoRenew: true, + recipientCharacterId: "elio", + }); + }); + + it("parses the formal session and checkout intent returned after consumption", () => { + const parsed = CheckoutHandoffConsumeResponseSchema.parse({ + user: { + id: "00000000-0000-0000-0000-000000000123", + username: "checkout-user", + email: "checkout@example.com", + platform: "web", + }, + token: "access-token", + refreshToken: "refresh-token", + loginStatus: "email", + checkoutIntent: { + planId: "vip_monthly", + autoRenew: true, + }, + }); + + expect(parsed.checkoutIntent.planId).toBe("vip_monthly"); + expect(parsed.loginStatus).toBe("email"); + }); +}); diff --git a/src/data/schemas/auth/checkout_handoff.ts b/src/data/schemas/auth/checkout_handoff.ts new file mode 100644 index 00000000..f97be1d0 --- /dev/null +++ b/src/data/schemas/auth/checkout_handoff.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +import { stringOrEmpty } from "../nullable-defaults"; +import { UserSchema } from "../user/user"; + +export const CheckoutIntentSchema = z + .object({ + planId: z.string().min(1).max(120), + autoRenew: z.boolean(), + recipientCharacterId: z.string().min(1).max(80).optional(), + commercialOfferId: z.string().min(1).max(80).optional(), + chatActionId: z.uuid().optional(), + }) + .readonly(); + +export const CheckoutHandoffCreateRequestSchema = CheckoutIntentSchema; + +export const CheckoutHandoffCreateResponseSchema = z + .object({ + externalUrl: z.url(), + expiresAt: z.string().min(1), + }) + .readonly(); + +export const CheckoutHandoffConsumeRequestSchema = z + .object({ + handoffToken: z.string().min(32).max(512), + }) + .readonly(); + +export const CheckoutHandoffConsumeResponseSchema = z + .object({ + user: UserSchema, + token: z.string().min(1), + refreshToken: stringOrEmpty, + loginStatus: z.enum([ + "email", + "google", + "facebook", + "facebookMessenger", + ]), + checkoutIntent: CheckoutIntentSchema, + }) + .readonly(); + +export type CheckoutIntent = z.output; +export type CheckoutHandoffCreateRequest = z.output< + typeof CheckoutHandoffCreateRequestSchema +>; +export type CheckoutHandoffCreateResponse = z.output< + typeof CheckoutHandoffCreateResponseSchema +>; +export type CheckoutHandoffConsumeRequest = z.output< + typeof CheckoutHandoffConsumeRequestSchema +>; +export type CheckoutHandoffConsumeResponse = z.output< + typeof CheckoutHandoffConsumeResponseSchema +>; diff --git a/src/data/schemas/auth/index.ts b/src/data/schemas/auth/index.ts index 0446d39b..5a6f533f 100644 --- a/src/data/schemas/auth/index.ts +++ b/src/data/schemas/auth/index.ts @@ -3,6 +3,7 @@ */ export * from "./facebook_user_data"; +export * from "./checkout_handoff"; export * from "./login_status"; export * from "./request/facebook_identity_request"; export * from "./request/facebook_login_request"; diff --git a/src/data/services/api/api_contract.json b/src/data/services/api/api_contract.json index c78af6d7..5cd59520 100644 --- a/src/data/services/api/api_contract.json +++ b/src/data/services/api/api_contract.json @@ -6,6 +6,8 @@ "facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" }, "facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" }, "topUpHandoffConsume": { "method": "post", "path": "/api/auth/handoff/topup/consume" }, + "checkoutHandoffCreate": { "method": "post", "path": "/api/auth/handoff/checkout" }, + "checkoutHandoffConsume": { "method": "post", "path": "/api/auth/handoff/checkout/consume" }, "refresh": { "method": "post", "path": "/api/auth/refresh" }, "logout": { "method": "post", "path": "/api/auth/logout" }, "getCurrentUser": { "method": "get", "path": "/api/auth/me" }, diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 22b7e7d2..6828c1ed 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -30,6 +30,12 @@ export class ApiPath { /** 消费一次性充值登录凭证 */ static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path; + /** 创建一次性外部浏览器支付交接链接(创建订单前)。 */ + static readonly checkoutHandoffCreate = apiContract.checkoutHandoffCreate.path; + + /** 消费一次性外部浏览器支付交接凭证。 */ + static readonly checkoutHandoffConsume = apiContract.checkoutHandoffConsume.path; + /** 刷新 Token */ static readonly refresh = apiContract.refresh.path; diff --git a/src/data/services/api/auth_api.ts b/src/data/services/api/auth_api.ts index c4f67254..8da96b18 100644 --- a/src/data/services/api/auth_api.ts +++ b/src/data/services/api/auth_api.ts @@ -7,6 +7,12 @@ import { z } from "zod"; import { + CheckoutHandoffConsumeRequest, + CheckoutHandoffConsumeResponse, + CheckoutHandoffConsumeResponseSchema, + CheckoutHandoffCreateRequest, + CheckoutHandoffCreateResponse, + CheckoutHandoffCreateResponseSchema, FacebookIdentityRequest, FacebookIdentityResponse, FacebookIdentityResponseSchema, @@ -104,6 +110,32 @@ export class AuthApi { ); } + /** 在创建支付订单前生成一次性外部浏览器交接链接。 */ + async createCheckoutHandoff( + body: CheckoutHandoffCreateRequest, + ): Promise { + const env = await httpClient>( + ApiPath.checkoutHandoffCreate, + { method: "POST", body }, + ); + return CheckoutHandoffCreateResponseSchema.parse( + unwrap(env) as Record, + ); + } + + /** 消费一次性支付交接凭证并恢复正式登录态与购买意图。 */ + async consumeCheckoutHandoff( + body: CheckoutHandoffConsumeRequest, + ): Promise { + const env = await httpClient>( + ApiPath.checkoutHandoffConsume, + { method: "POST", body }, + ); + return CheckoutHandoffConsumeResponseSchema.parse( + unwrap(env) as Record, + ); + } + /** * 绑定 Facebook ASID / PSID 到当前用户 */ diff --git a/src/lib/auth/checkout_handoff.ts b/src/lib/auth/checkout_handoff.ts new file mode 100644 index 00000000..b9dc2e52 --- /dev/null +++ b/src/lib/auth/checkout_handoff.ts @@ -0,0 +1,18 @@ +import type { + CheckoutHandoffCreateResponse, + CheckoutIntent, +} from "@/data/schemas/auth"; +import { getAuthRepository } from "@/data/repositories/auth_repository"; +import type { Result } from "@/utils/result"; + +export function createCheckoutHandoff( + checkoutIntent: CheckoutIntent, +): Promise> { + return getAuthRepository().createCheckoutHandoff(checkoutIntent); +} + +export function consumeCheckoutHandoff( + handoffToken: string, +): Promise> { + return getAuthRepository().consumeCheckoutHandoff(handoffToken); +} diff --git a/src/lib/navigation/__tests__/external_entry.test.ts b/src/lib/navigation/__tests__/external_entry.test.ts index 9d98e7ba..a165beee 100644 --- a/src/lib/navigation/__tests__/external_entry.test.ts +++ b/src/lib/navigation/__tests__/external_entry.test.ts @@ -3,12 +3,38 @@ import { describe, expect, it } from "vitest"; import { getCharacterRoutes, ROUTES } from "@/router/routes"; import { + resolveCheckoutIntentDestination, resolveExternalEntryDestination, resolveExternalEntryPromotionType, resolveExternalEntryTarget, shouldBindExternalFacebookIdentity, } from "../external_entry"; +describe("checkout external browser destination", () => { + it("restores subscription intent without carrying the handoff token", () => { + expect( + resolveCheckoutIntentDestination({ + planId: "vip_monthly", + autoRenew: true, + commercialOfferId: "offer-1", + chatActionId: "00000000-0000-0000-0000-000000000123", + }), + ).toBe( + "/subscription?planId=vip_monthly&autoRenew=1&commercialOfferId=offer-1&chatActionId=00000000-0000-0000-0000-000000000123&payChannel=stripe", + ); + }); + + it("restores a character tip intent by canonical character id", () => { + expect( + resolveCheckoutIntentDestination({ + planId: "maya_coffee", + autoRenew: false, + recipientCharacterId: "maya-tan", + }), + ).toBe("/characters/maya/tip?planId=maya_coffee&payChannel=stripe"); + }); +}); + const facebookBusinessEntryCases = [ ["private-space", "elio", "chat", null, null, getCharacterRoutes("elio").chat, null], ["private-space", "maya", "chat", null, null, getCharacterRoutes("maya").chat, null], diff --git a/src/lib/navigation/external_entry.ts b/src/lib/navigation/external_entry.ts index 8dcee9d3..a67515df 100644 --- a/src/lib/navigation/external_entry.ts +++ b/src/lib/navigation/external_entry.ts @@ -4,8 +4,10 @@ import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { UserStorage } from "@/data/storage/user/user_storage"; import type { PendingChatPromotionType } from "@/data/storage/navigation"; import type { LoginStatus } from "@/data/schemas/auth"; +import type { CheckoutIntent } from "@/data/schemas/auth"; import { DEFAULT_CHARACTER_SLUG, + getCharacterById, getCharacterBySlug, } from "@/data/constants/character"; import { getCharacterRoutes, ROUTES } from "@/router/routes"; @@ -100,6 +102,30 @@ export function resolveExternalEntryDestination({ return routes.chat; } +export function resolveCheckoutIntentDestination( + intent: CheckoutIntent, +): string { + const params = new URLSearchParams({ planId: intent.planId }); + + if (intent.recipientCharacterId) { + const character = getCharacterById(intent.recipientCharacterId); + const routes = getCharacterRoutes( + character?.slug ?? DEFAULT_CHARACTER_SLUG, + ); + if (intent.chatActionId) params.set("chatActionId", intent.chatActionId); + params.set("payChannel", "stripe"); + return `${routes.tip}?${params.toString()}`; + } + + params.set("autoRenew", intent.autoRenew ? "1" : "0"); + if (intent.commercialOfferId) { + params.set("commercialOfferId", intent.commercialOfferId); + } + if (intent.chatActionId) params.set("chatActionId", intent.chatActionId); + params.set("payChannel", "stripe"); + return `${ROUTES.subscription}?${params.toString()}`; +} + export async function persistExternalEntryPayload({ deviceId, asid, @@ -138,6 +164,7 @@ function resolveTarget( return ROUTES.privateZone; } if (target === "topup") return ROUTES.subscription; + if (target === "checkout") return ROUTES.subscription; return null; } diff --git a/src/lib/payment/__tests__/payment_launch.test.ts b/src/lib/payment/__tests__/payment_launch.test.ts index d931b6c6..e7f9b148 100644 --- a/src/lib/payment/__tests__/payment_launch.test.ts +++ b/src/lib/payment/__tests__/payment_launch.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { getPaymentUrl, + getStripeCustomerSessionClientSecret, getStripeClientSecret, isEzpayPayment, launchEzpayRedirect, @@ -43,6 +44,28 @@ describe("payment launch helpers", () => { ).toBeNull(); }); + it("enables saved cards only with an explicit flag and a Customer Session secret", () => { + expect( + getStripeCustomerSessionClientSecret({ + provider: "stripe", + customerSessionClientSecret: "cuss_123_secret_456", + savedPaymentMethodsEnabled: true, + }), + ).toBe("cuss_123_secret_456"); + expect( + getStripeCustomerSessionClientSecret({ + customerSessionClientSecret: "cuss_123_secret_456", + savedPaymentMethodsEnabled: false, + }), + ).toBeNull(); + expect( + getStripeCustomerSessionClientSecret({ + customerSessionClientSecret: "invalid-customer-session", + savedPaymentMethodsEnabled: true, + }), + ).toBeNull(); + }); + it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => { expect( resolveEzpayLaunchTarget({ diff --git a/src/lib/payment/payment_launch.ts b/src/lib/payment/payment_launch.ts index 71b93286..1563a5a1 100644 --- a/src/lib/payment/payment_launch.ts +++ b/src/lib/payment/payment_launch.ts @@ -143,6 +143,27 @@ export function getStripeClientSecret( return null; } +export function getStripeCustomerSessionClientSecret( + payParams: Record, +): string | null { + const provider = payParams.provider; + const isStripeProvider = + typeof provider !== "string" || provider.toLowerCase() === "stripe"; + const enabled = payParams.savedPaymentMethodsEnabled === true; + const clientSecret = payParams.customerSessionClientSecret; + + if ( + isStripeProvider && + enabled && + typeof clientSecret === "string" && + /^cuss_[^\s]+_secret_[^\s]+$/i.test(clientSecret) + ) { + return clientSecret; + } + + return null; +} + export interface LaunchEzpayRedirectInput { orderId: string | null; paymentUrl: string; diff --git a/src/lib/payment/payment_search_params.ts b/src/lib/payment/payment_search_params.ts index 2850d3f9..c054200e 100644 --- a/src/lib/payment/payment_search_params.ts +++ b/src/lib/payment/payment_search_params.ts @@ -22,6 +22,15 @@ export function parsePaymentPayChannel( return channel === "ezpay" || channel === "stripe" ? channel : null; } +export function parsePaymentAutoRenew( + value: PaymentSearchParamValue, +): boolean | null { + const autoRenew = getFirstPaymentSearchParam(value); + if (autoRenew === "1" || autoRenew === "true") return true; + if (autoRenew === "0" || autoRenew === "false") return false; + return null; +} + export function parseSubscriptionReturnTo( value: PaymentSearchParamValue, ): AppSubscriptionReturnTo { diff --git a/src/stores/payment/__tests__/payment-catalog-flow.test.ts b/src/stores/payment/__tests__/payment-catalog-flow.test.ts index 07193cfe..97380549 100644 --- a/src/stores/payment/__tests__/payment-catalog-flow.test.ts +++ b/src/stores/payment/__tests__/payment-catalog-flow.test.ts @@ -8,7 +8,6 @@ import { import { createTestPaymentMachine, - giftCatalog, lifetimePlan, monthlyPlan, quarterlyPlan, @@ -51,7 +50,7 @@ describe("payment catalog flow", () => { actor.stop(); }); - it("restores a valid first-category product and rejects another category", async () => { + it("restores a valid tip product across categories from checkout handoff", async () => { const actor = createActor(createTestPaymentMachine()).start(); actor.send({ type: "PaymentInit", @@ -79,8 +78,9 @@ describe("payment catalog flow", () => { snapshot.context.requestedGiftPlanId === null, ); expect(actor.getSnapshot().context.selectedPlanId).toBe( - giftCatalog.plans[0]?.planId, + "tip_flowers_usd_12_99", ); + expect(actor.getSnapshot().context.selectedGiftCategory).toBe("flowers"); actor.stop(); }); diff --git a/src/stores/payment/helper/gift.ts b/src/stores/payment/helper/gift.ts index c880ab9c..e5e04189 100644 --- a/src/stores/payment/helper/gift.ts +++ b/src/stores/payment/helper/gift.ts @@ -50,15 +50,23 @@ export function hydrateGiftProductsState( const giftProducts = response.plans.filter( (product) => product.characterId === characterId, ); - const selectedGiftCategory = giftCategories[0]?.category ?? null; + const restoredProduct = restoredPlanId + ? giftProducts.find((product) => product.planId === restoredPlanId) + : null; + const selectedGiftCategory = + restoredProduct?.category ?? + (restoredCategory && + giftCategories.some((category) => category.category === restoredCategory) + ? restoredCategory + : (giftCategories[0]?.category ?? null)); const visibleProducts = selectedGiftCategory ? giftProducts.filter( (product) => product.category === selectedGiftCategory, ) : []; - const canRestoreSelection = - restoredCategory === selectedGiftCategory && - visibleProducts.some((product) => product.planId === restoredPlanId); + const canRestoreSelection = visibleProducts.some( + (product) => product.planId === restoredPlanId, + ); const selectedPlanId = canRestoreSelection ? (restoredPlanId ?? "") : (visibleProducts[0]?.planId ?? ""); diff --git a/src/stores/payment/machine/catalog-flow.ts b/src/stores/payment/machine/catalog-flow.ts index 0cd62b0d..32a99fbb 100644 --- a/src/stores/payment/machine/catalog-flow.ts +++ b/src/stores/payment/machine/catalog-flow.ts @@ -32,14 +32,18 @@ function initializeCatalogState( const catalogChanged = planCatalog !== context.planCatalog; const characterChanged = giftCharacterId !== context.giftCharacterId; const selectionChanged = - planCatalog === "tip" && - ((event.category ?? null) !== context.selectedGiftCategory || - (event.planId ?? null) !== (context.selectedPlanId || null)); + planCatalog === "tip" + ? (event.category ?? null) !== context.selectedGiftCategory || + (event.planId ?? null) !== (context.selectedPlanId || null) + : (event.planId ?? null) !== (context.selectedPlanId || null); const commercialOfferId = planCatalog === "default" ? (event.commercialOfferId ?? null) : null; const offerChanged = commercialOfferId !== context.commercialOfferId; const chatActionId = event.chatActionId ?? null; const chatActionChanged = chatActionId !== context.chatActionId; + const initialAutoRenew = + planCatalog === "tip" ? false : (event.autoRenew ?? true); + const autoRenewChanged = initialAutoRenew !== context.autoRenew; return { planCatalog, @@ -51,7 +55,12 @@ function initializeCatalogState( planCatalog === "tip" ? (event.category ?? null) : null, requestedGiftPlanId: planCatalog === "tip" ? (event.planId ?? null) : null, - ...(catalogChanged || characterChanged || selectionChanged || offerChanged || chatActionChanged + ...(catalogChanged || + characterChanged || + selectionChanged || + offerChanged || + chatActionChanged || + autoRenewChanged ? { ...resetOrderState(), plans: [], @@ -62,7 +71,7 @@ function initializeCatalogState( planCatalog === "default" ? (event.planId ?? "") : "", isFirstRecharge: false, commercialOffer: null, - autoRenew: planCatalog !== "tip", + autoRenew: initialAutoRenew, } : {}), }; diff --git a/src/stores/payment/payment-context.tsx b/src/stores/payment/payment-context.tsx index 7b3d29e2..a731436b 100644 --- a/src/stores/payment/payment-context.tsx +++ b/src/stores/payment/payment-context.tsx @@ -20,6 +20,8 @@ export interface PaymentContextState { selectedGiftCategory: string | null; isFirstRecharge: MachineContext["isFirstRecharge"]; commercialOffer: MachineContext["commercialOffer"]; + commercialOfferId: string | null; + chatActionId: string | null; selectedPlanId: string; payChannel: MachineContext["payChannel"]; autoRenew: boolean; @@ -76,6 +78,8 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState { selectedGiftCategory: state.context.selectedGiftCategory, isFirstRecharge: state.context.isFirstRecharge, commercialOffer: state.context.commercialOffer, + commercialOfferId: state.context.commercialOfferId, + chatActionId: state.context.chatActionId, selectedPlanId: state.context.selectedPlanId, payChannel: state.context.payChannel, autoRenew: state.context.autoRenew, diff --git a/src/stores/payment/payment-events.ts b/src/stores/payment/payment-events.ts index 3d75b52f..13c86a22 100644 --- a/src/stores/payment/payment-events.ts +++ b/src/stores/payment/payment-events.ts @@ -10,6 +10,7 @@ export type PaymentEvent = characterId?: string; category?: string | null; planId?: string | null; + autoRenew?: boolean; commercialOfferId?: string | null; chatActionId?: string | null; } diff --git a/src/utils/__tests__/logger-redaction.test.ts b/src/utils/__tests__/logger-redaction.test.ts new file mode 100644 index 00000000..f93faaca --- /dev/null +++ b/src/utils/__tests__/logger-redaction.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { Logger } from "../logger"; + +describe("Logger Stripe secret redaction", () => { + it("redacts nested Stripe secret fields and values before formatting", () => { + const output = Logger.formatValue({ + payParams: { + clientSecret: "pi_123_secret_card", + nested: { + customerSessionClientSecret: "cuss_123_secret_saved", + handoffToken: "one-time-checkout-token", + safe: "order-123", + }, + }, + }); + + expect(output).toContain("order-123"); + expect(output).not.toContain("secret_card"); + expect(output).not.toContain("secret_saved"); + expect(output).not.toContain("one-time-checkout-token"); + expect(output.match(/\[REDACTED\]/g)?.length).toBeGreaterThanOrEqual(3); + }); + + it("redacts a secret embedded in a serialized response string", () => { + const output = Logger.formatValue( + JSON.stringify({ customerSessionClientSecret: "cuss_1_secret_value" }), + ); + + expect(output).not.toContain("secret_value"); + expect(output).toContain("[REDACTED]"); + }); +}); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 29b0f91a..49288b70 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -107,9 +107,13 @@ export class Logger { const trimmed = value.trim(); if (trimmed.length === 0) return ""; try { - return JSON.stringify(JSON.parse(trimmed), null, 2); + return JSON.stringify( + Logger.toSerializableLogValue(JSON.parse(trimmed)), + null, + 2, + ); } catch { - return value; + return Logger.redactSensitiveString(value); } } @@ -149,28 +153,31 @@ export class Logger { } private write(level: LogLevel, args: LogArgs): void { - Logger.reportImportantProductionLog(level, this.component, args); + const safeArgs = args.map((value) => + Logger.toSerializableLogValue(value), + ); + Logger.reportImportantProductionLog(level, this.component, safeArgs); if (Logger.shouldUseBrowserConsole()) { - Logger.writeBrowserConsole(level, this.component, args); + Logger.writeBrowserConsole(level, this.component, safeArgs); return; } switch (level) { case "debug": - this.logger.debug(...Logger.normalizeLogArgs(args)); + this.logger.debug(...Logger.normalizeLogArgs(safeArgs)); return; case "info": - this.logger.info(...Logger.normalizeLogArgs(args)); + this.logger.info(...Logger.normalizeLogArgs(safeArgs)); return; case "warn": - this.logger.warn(...Logger.normalizeLogArgs(args)); + this.logger.warn(...Logger.normalizeLogArgs(safeArgs)); return; case "error": - this.logger.error(...Logger.normalizeLogArgs(args)); + this.logger.error(...Logger.normalizeLogArgs(safeArgs)); return; case "fatal": - this.logger.fatal(...Logger.normalizeLogArgs(args)); + this.logger.fatal(...Logger.normalizeLogArgs(safeArgs)); return; } } @@ -266,6 +273,10 @@ export class Logger { return value.map((item) => Logger.toSerializableLogValue(item, seen)); } + if (typeof value === "string") { + return Logger.redactSensitiveString(value); + } + if (typeof value !== "object" || value === null) { return value; } @@ -281,19 +292,39 @@ export class Logger { const output: Record = {}; for (const [key, item] of Object.entries(value)) { - output[key] = Logger.toSerializableLogValue(item, seen); + output[key] = Logger.isSensitiveLogKey(key) + ? "[REDACTED]" + : Logger.toSerializableLogValue(item, seen); } return output; } + private static isSensitiveLogKey(key: string): boolean { + const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase(); + return ( + normalized === "clientsecret" || + normalized === "customersessionclientsecret" || + normalized === "handofftoken" || + normalized === "accesstoken" || + normalized === "refreshtoken" || + normalized === "authorization" + ); + } + + private static redactSensitiveString(value: string): string { + return /_secret_/i.test(value) ? "[REDACTED]" : value; + } + private static serializeError( error: Error, seen: WeakSet, ): Record { const output: Record = { name: error.name, - message: error.message, - stack: error.stack, + message: Logger.redactSensitiveString(error.message), + stack: error.stack + ? Logger.redactSensitiveString(error.stack) + : undefined, }; if (seen.has(error)) { @@ -303,10 +334,12 @@ export class Logger { for (const key of Object.getOwnPropertyNames(error)) { if (key in output) continue; - output[key] = Logger.toSerializableLogValue( - (error as unknown as Record)[key], - seen, - ); + output[key] = Logger.isSensitiveLogKey(key) + ? "[REDACTED]" + : Logger.toSerializableLogValue( + (error as unknown as Record)[key], + seen, + ); } return output; }