feat(payment): expand Stripe methods and checkout handoff
Docker Image / Build and Push Docker Image (push) Successful in 2m10s

This commit is contained in:
Codex
2026-07-28 16:27:03 +08:00
parent 2e402de15b
commit 59e4eac736
42 changed files with 1151 additions and 92 deletions
+3
View File
@@ -13,6 +13,7 @@ export interface MockCoreApisOptions {
paidImageInsufficientCreditsFlow?: boolean; paidImageInsufficientCreditsFlow?: boolean;
paidVoiceInsufficientCreditsFlow?: boolean; paidVoiceInsufficientCreditsFlow?: boolean;
topUpHandoffFlow?: boolean; topUpHandoffFlow?: boolean;
checkoutHandoffFlow?: boolean;
} }
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) { export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
@@ -23,6 +24,7 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false, paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false, paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
topUpHandoffFlow: options.topUpHandoffFlow ?? false, topUpHandoffFlow: options.topUpHandoffFlow ?? false,
checkoutHandoffFlow: options.checkoutHandoffFlow ?? false,
}; };
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false }; const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
@@ -30,6 +32,7 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow, chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
isChatSendTokenExpired: () => chatState.hasExpiredChatSend, isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
topUpHandoffFlow: chatOptions.topUpHandoffFlow, topUpHandoffFlow: chatOptions.topUpHandoffFlow,
checkoutHandoffFlow: chatOptions.checkoutHandoffFlow,
}); });
await registerCharacterMocks(page); await registerCharacterMocks(page);
await registerUserMocks(page); await registerUserMocks(page);
+10 -1
View File
@@ -1,15 +1,24 @@
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import { apiEnvelope } from "../data/common"; 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 { export interface AuthMockState {
chatSendTokenRefreshFlow: boolean; chatSendTokenRefreshFlow: boolean;
isChatSendTokenExpired: () => boolean; isChatSendTokenExpired: () => boolean;
topUpHandoffFlow: boolean; topUpHandoffFlow: boolean;
checkoutHandoffFlow: boolean;
} }
export async function registerAuthMocks(page: Page, state: AuthMockState) { 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) => { await page.route("**/api/auth/handoff/topup/consume", async (route) => {
if (!state.topUpHandoffFlow) { if (!state.topUpHandoffFlow) {
await route.continue(); await route.continue();
+12
View File
@@ -63,3 +63,15 @@ export const topUpHandoffResponse = {
loginProvider: "facebook_messenger", 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",
},
};
@@ -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);
});
@@ -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(
<ExternalBrowserCheckoutButton
checkoutIntent={{
planId: "vip_monthly",
autoRenew: true,
commercialOfferId: "offer-1",
}}
/>,
);
});
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",
);
});
});
@@ -18,6 +18,8 @@ const hiddenLaunch = {
shouldShowQrisDialog: false, shouldShowQrisDialog: false,
shouldShowStripeDialog: false, shouldShowStripeDialog: false,
stripeClientSecret: null, stripeClientSecret: null,
stripeCustomerSessionClientSecret: null,
savedPaymentMethodsEnabled: false,
}; };
describe("PaymentLaunchDialogs", () => { describe("PaymentLaunchDialogs", () => {
@@ -2,18 +2,57 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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<string, unknown> | null = null;
let capturedExpressProps: Record<string, unknown> | null = null;
let capturedPaymentProps: Record<string, unknown> | 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<string, unknown>;
}) => {
capturedElementsOptions = options;
return <>{children}</>;
},
ExpressCheckoutElement: (props: Record<string, unknown>) => {
capturedExpressProps = props;
return <div data-testid="express-checkout" />;
},
PaymentElement: (props: Record<string, unknown>) => {
capturedPaymentProps = props;
return <div data-testid="payment-element" />;
},
useElements: () => ({ submit: elementsSubmit }),
useStripe: () => ({ confirmPayment: stripeConfirmPayment }),
}));
import { StripePaymentDialog } from "../stripe-payment-dialog"; import { StripePaymentDialog } from "../stripe-payment-dialog";
describe("Stripe payment portal states", () => { describe("StripePaymentDialog", () => {
let container: HTMLDivElement; let container: HTMLDivElement;
let root: Root; let root: Root;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks();
capturedElementsOptions = null;
capturedExpressProps = null;
capturedPaymentProps = null;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true; .IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div"); container = document.createElement("div");
container.style.transform = "translateX(50%)";
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(container); root = createRoot(container);
}); });
@@ -24,40 +63,101 @@ describe("Stripe payment portal states", () => {
document.body.style.overflow = ""; document.body.style.overflow = "";
}); });
it("portals the unavailable state and keeps its explicit close action", () => { it("renders Express Checkout first and a collapsed Pay by card section last", () => {
const onClose = vi.fn();
act(() => act(() =>
root.render( root.render(
<StripePaymentDialog <StripePaymentDialog
clientSecret="client-secret" clientSecret="pi_123_secret_payment"
onClose={onClose} customerSessionClientSecret="cuss_123_secret_saved"
savedPaymentMethodsEnabled
onClose={vi.fn()}
/>, />,
), ),
); );
const dialog = document.body.querySelector('[role="alertdialog"]'); expect(capturedElementsOptions).toMatchObject({
expect(dialog).not.toBeNull(); clientSecret: "pi_123_secret_payment",
expect(container.contains(dialog)).toBe(false); customerSessionClientSecret: "cuss_123_secret_saved",
expect(dialog?.textContent).toContain("Payment unavailable"); });
expect(dialog?.getAttribute("aria-labelledby")).toBeTruthy(); expect(capturedExpressProps?.options).toMatchObject({
expect(dialog?.getAttribute("aria-describedby")).toBeTruthy(); paymentMethodOrder: [
"apple_pay",
const okButton = Array.from(dialog?.querySelectorAll("button") ?? []).find( "google_pay",
(button) => button.textContent === "OK", "link",
); "paypal",
act(() => okButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); "amazon_pay",
expect(onClose).toHaveBeenCalledOnce(); "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", () => { it("does not pass an invalid or disabled Customer Session secret", () => {
act(() => root.render(<StripePaymentDialogLoading />)); act(() =>
root.render(
<StripePaymentDialog
clientSecret="pi_123_secret_payment"
customerSessionClientSecret="invalid"
savedPaymentMethodsEnabled={false}
onClose={vi.fn()}
/>,
),
);
const dialog = document.body.querySelector('[role="dialog"]'); expect(capturedElementsOptions).not.toHaveProperty(
expect(dialog).not.toBeNull(); "customerSessionClientSecret",
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(); it("uses the same confirmation path for an Express Checkout approval", async () => {
const onConfirmed = vi.fn();
act(() =>
root.render(
<StripePaymentDialog
clientSecret="pi_123_secret_payment"
onClose={vi.fn()}
onConfirmed={onConfirmed}
/>,
),
);
const onConfirm = capturedExpressProps?.onConfirm as
| ((event: { paymentFailed: ReturnType<typeof vi.fn> }) => Promise<void>)
| 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);
}); });
}); });
@@ -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<string | null>(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 (
<div className="mt-2 w-full text-center">
<button
type="button"
className="min-h-11 w-full cursor-pointer rounded-full border border-black/15 bg-white px-4 py-2.5 text-sm font-semibold text-text-foreground disabled:cursor-not-allowed disabled:opacity-55"
disabled={disabled || isOpening}
onClick={() => void handleOpen()}
>
{isOpening
? "Opening browser..."
: "Open in browser for more payment methods"}
</button>
{errorMessage ? (
<p className="mt-2 text-sm text-[#c0392b]" role="alert">
{errorMessage}
</p>
) : null}
</div>
);
}
@@ -24,6 +24,8 @@ type PaymentLaunchDialogFlow = Pick<
| "shouldShowQrisDialog" | "shouldShowQrisDialog"
| "shouldShowStripeDialog" | "shouldShowStripeDialog"
| "stripeClientSecret" | "stripeClientSecret"
| "stripeCustomerSessionClientSecret"
| "savedPaymentMethodsEnabled"
>; >;
export interface PaymentLaunchDialogsProps { export interface PaymentLaunchDialogsProps {
@@ -64,6 +66,10 @@ export function PaymentLaunchDialogs({
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? ( {launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
<LazyStripePaymentDialog <LazyStripePaymentDialog
clientSecret={launch.stripeClientSecret} clientSecret={launch.stripeClientSecret}
customerSessionClientSecret={
launch.stripeCustomerSessionClientSecret
}
savedPaymentMethodsEnabled={launch.savedPaymentMethodsEnabled}
returnPath={stripeReturnPath} returnPath={stripeReturnPath}
onClose={launch.handleStripeClose} onClose={launch.handleStripeClose}
onConfirmed={launch.handleStripeConfirmed} onConfirmed={launch.handleStripeConfirmed}
@@ -9,6 +9,11 @@ export const stripePaymentDialogStyles = {
content: content:
"m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]", "m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]",
form: "flex flex-col gap-(--page-section-gap,18px)", form: "flex flex-col gap-(--page-section-gap,18px)",
expressSection: "w-full empty:hidden",
cardSection:
"flex w-full flex-col gap-(--spacing-sm,8px) border-t border-black/10 pt-(--page-section-gap,18px)",
cardTitle:
"m-0 text-(length:--responsive-body,16px) font-semibold text-text-foreground",
error: error:
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]", "m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
actions: "flex w-full gap-(--spacing-md,12px)", actions: "flex w-full gap-(--spacing-md,12px)",
@@ -4,19 +4,28 @@
* *
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。 * 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
*/ */
import { useId, useState, type FormEvent } from "react"; import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import { import {
Elements, Elements,
ExpressCheckoutElement,
PaymentElement, PaymentElement,
useElements, useElements,
useStripe, useStripe,
} from "@stripe/react-stripe-js"; } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js"; import {
loadStripe,
type AvailablePaymentMethods,
type StripeExpressCheckoutElementConfirmEvent,
} from "@stripe/stripe-js";
import { ModalPortal } from "@/app/_components/core/modal-portal"; import { ModalPortal } from "@/app/_components/core/modal-portal";
import { ExceptionHandler } from "@/core/errors"; import { ExceptionHandler } from "@/core/errors";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { BrowserDetector } from "@/utils/browser-detect";
import { PlatformDetector } from "@/utils/platform-detect";
import { getStripeCustomerSessionClientSecret } from "@/lib/payment/payment_launch";
import { behaviorAnalytics } from "@/lib/analytics";
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles"; import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
@@ -26,8 +35,16 @@ const stripePromise = stripePublishableKey
? loadStripe(stripePublishableKey) ? loadStripe(stripePublishableKey)
: null; : null;
type ExpressAvailabilityValue = boolean | { available: boolean } | undefined;
function isExpressMethodAvailable(value: ExpressAvailabilityValue): boolean {
return value === true || (typeof value === "object" && value.available);
}
export interface StripePaymentDialogProps { export interface StripePaymentDialogProps {
clientSecret: string; clientSecret: string;
customerSessionClientSecret?: string | null;
savedPaymentMethodsEnabled?: boolean;
returnPath?: string; returnPath?: string;
onClose: () => void; onClose: () => void;
onConfirmed?: () => void; onConfirmed?: () => void;
@@ -35,12 +52,19 @@ export interface StripePaymentDialogProps {
export function StripePaymentDialog({ export function StripePaymentDialog({
clientSecret, clientSecret,
customerSessionClientSecret = null,
savedPaymentMethodsEnabled = false,
returnPath = ROUTES.subscription + "/success", returnPath = ROUTES.subscription + "/success",
onClose, onClose,
onConfirmed, onConfirmed,
}: StripePaymentDialogProps) { }: StripePaymentDialogProps) {
const titleId = useId(); const titleId = useId();
const descriptionId = useId(); const descriptionId = useId();
const savedCardClientSecret = getStripeCustomerSessionClientSecret({
provider: "stripe",
customerSessionClientSecret,
savedPaymentMethodsEnabled,
});
if (!stripePromise) { if (!stripePromise) {
return ( return (
@@ -100,6 +124,9 @@ export function StripePaymentDialog({
stripe={stripePromise} stripe={stripePromise}
options={{ options={{
clientSecret, clientSecret,
...(savedCardClientSecret
? { customerSessionClientSecret: savedCardClientSecret }
: {}),
appearance: { appearance: {
theme: "stripe", theme: "stripe",
variables: { variables: {
@@ -132,6 +159,20 @@ function StripePaymentForm({
const elements = useElements(); const elements = useElements();
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); 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< const handleLoadError = (event: Parameters<
NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]> NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
@@ -150,38 +191,118 @@ function StripePaymentForm({
); );
}; };
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { const updateExpressAvailability = (
event.preventDefault(); availablePaymentMethods:
if (!stripe || !elements || isSubmitting) return; | 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<typeof ExpressCheckoutElement>["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); setIsSubmitting(true);
setErrorMessage(null); setErrorMessage(null);
const { error: submitError } = await elements.submit(); if (options.submitPaymentElement) {
if (submitError) { let submitError: unknown;
setErrorMessage( try {
ExceptionHandler.message(submitError, "Please check your payment info."), ({ error: submitError } = await elements.submit());
); } catch (error) {
setIsSubmitting(false); submitError = error;
return; }
if (submitError) {
setErrorMessage(
ExceptionHandler.message(
submitError,
"Please check your payment info.",
),
);
submittingRef.current = false;
setIsSubmitting(false);
return;
}
} }
const returnUrl = new URL( const returnUrl = new URL(
returnPath ?? ROUTES.subscription + "/success", returnPath ?? ROUTES.subscription + "/success",
window.location.origin, window.location.origin,
); );
const { error } = await stripe.confirmPayment({ let confirmationError: unknown;
elements, try {
confirmParams: { ({ error: confirmationError } = await stripe.confirmPayment({
return_url: returnUrl.toString(), elements,
}, confirmParams: {
redirect: "if_required", return_url: returnUrl.toString(),
}); },
redirect: "if_required",
}));
} catch (error) {
confirmationError = error;
}
if (error) { if (confirmationError) {
setErrorMessage( const message = ExceptionHandler.message(
ExceptionHandler.message(error, "Payment confirmation failed."), confirmationError,
"Payment confirmation failed.",
); );
setErrorMessage(message);
options.expressEvent?.paymentFailed({ reason: "fail", message });
submittingRef.current = false;
setIsSubmitting(false); setIsSubmitting(false);
return; return;
} }
@@ -189,9 +310,74 @@ function StripePaymentForm({
onConfirmed?.(); onConfirmed?.();
}; };
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
await confirmPayment({ submitPaymentElement: true });
};
return ( return (
<form className={styles.form} onSubmit={handleSubmit}> <form className={styles.form} onSubmit={handleSubmit}>
<PaymentElement onLoadError={handleLoadError} /> <section
className={styles.expressSection}
hidden={hasExpressPaymentMethods === false}
aria-label="Express payment methods"
>
<ExpressCheckoutElement
options={{
paymentMethodOrder: [
"apple_pay",
"google_pay",
"link",
"paypal",
"amazon_pay",
"klarna",
],
paymentMethods: {
applePay: "always",
googlePay: "always",
link: "auto",
paypal: "auto",
amazonPay: "auto",
klarna: "auto",
},
layout: {
maxColumns: expressMaxColumns,
maxRows: 6,
overflow: "never",
},
}}
onReady={(event) =>
updateExpressAvailability(event.availablePaymentMethods)
}
onAvailablePaymentMethodsChange={(event) =>
updateExpressAvailability(event.paymentMethods)
}
onLoadError={handleExpressLoadError}
onConfirm={(event) =>
void confirmPayment({
submitPaymentElement: false,
expressEvent: event,
})
}
/>
</section>
<section className={styles.cardSection} aria-labelledby="pay-by-card-title">
<h3 id="pay-by-card-title" className={styles.cardTitle}>
Pay by card
</h3>
<PaymentElement
options={{
layout: { type: "accordion", defaultCollapsed: true },
paymentMethodOrder: ["card"],
wallets: {
applePay: "never",
googlePay: "never",
link: "never",
},
}}
onLoadError={handleLoadError}
/>
</section>
{errorMessage ? ( {errorMessage ? (
<p <p
role="alert" role="alert"
@@ -4,6 +4,7 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
import { import {
getPaymentUrl, getPaymentUrl,
getStripeCustomerSessionClientSecret,
getStripeClientSecret, getStripeClientSecret,
isEzpayPayment, isEzpayPayment,
launchEzpayRedirect, launchEzpayRedirect,
@@ -59,6 +60,8 @@ export interface PaymentLaunchFlow {
shouldShowQrisDialog: boolean; shouldShowQrisDialog: boolean;
shouldShowStripeDialog: boolean; shouldShowStripeDialog: boolean;
stripeClientSecret: string | null; stripeClientSecret: string | null;
stripeCustomerSessionClientSecret: string | null;
savedPaymentMethodsEnabled: boolean;
} }
export interface QrisPaymentDetails { export interface QrisPaymentDetails {
@@ -165,6 +168,9 @@ export function usePaymentLaunchFlow({
const stripeClientSecret = payment.payParams const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams) ? getStripeClientSecret(payment.payParams)
: null; : null;
const stripeCustomerSessionClientSecret = payment.payParams
? getStripeCustomerSessionClientSecret(payment.payParams)
: null;
const ezpayLaunchTarget = const ezpayLaunchTarget =
payment.payParams && isEzpayPayment(payment.payParams) payment.payParams && isEzpayPayment(payment.payParams)
? resolveEzpayLaunchTarget(payment.payParams) ? resolveEzpayLaunchTarget(payment.payParams)
@@ -385,5 +391,8 @@ export function usePaymentLaunchFlow({
shouldShowQrisDialog, shouldShowQrisDialog,
shouldShowStripeDialog, shouldShowStripeDialog,
stripeClientSecret, stripeClientSecret,
stripeCustomerSessionClientSecret,
savedPaymentMethodsEnabled:
stripeCustomerSessionClientSecret !== null,
}; };
} }
+7
View File
@@ -22,6 +22,7 @@ export interface UsePaymentRouteFlowInput {
characterId?: string; characterId?: string;
initialCategory?: string | null; initialCategory?: string | null;
initialPlanId?: string | null; initialPlanId?: string | null;
initialAutoRenew?: boolean | null;
commercialOfferId?: string | null; commercialOfferId?: string | null;
resumeOrderId?: string | null; resumeOrderId?: string | null;
chatActionId?: string | null; chatActionId?: string | null;
@@ -44,6 +45,7 @@ export function usePaymentRouteFlow({
characterId, characterId,
initialCategory = null, initialCategory = null,
initialPlanId = null, initialPlanId = null,
initialAutoRenew = null,
commercialOfferId = null, commercialOfferId = null,
resumeOrderId = null, resumeOrderId = null,
chatActionId = null, chatActionId = null,
@@ -57,6 +59,7 @@ export function usePaymentRouteFlow({
characterId ?? "", characterId ?? "",
initialCategory ?? "", initialCategory ?? "",
initialPlanId ?? "", initialPlanId ?? "",
initialAutoRenew === null ? "" : String(initialAutoRenew),
commercialOfferId ?? "", commercialOfferId ?? "",
chatActionId ?? "", chatActionId ?? "",
].join(":"); ].join(":");
@@ -78,6 +81,9 @@ export function usePaymentRouteFlow({
...(characterId ? { characterId } : {}), ...(characterId ? { characterId } : {}),
...(initialCategory ? { category: initialCategory } : {}), ...(initialCategory ? { category: initialCategory } : {}),
...(initialPlanId ? { planId: initialPlanId } : {}), ...(initialPlanId ? { planId: initialPlanId } : {}),
...(initialAutoRenew === null
? {}
: { autoRenew: initialAutoRenew }),
...(commercialOfferId ? { commercialOfferId } : {}), ...(commercialOfferId ? { commercialOfferId } : {}),
...(chatActionId ? { chatActionId } : {}), ...(chatActionId ? { chatActionId } : {}),
}); });
@@ -89,6 +95,7 @@ export function usePaymentRouteFlow({
chatActionId, chatActionId,
initialCategory, initialCategory,
initialPlanId, initialPlanId,
initialAutoRenew,
initialPayChannel, initialPayChannel,
paymentDispatch, paymentDispatch,
]); ]);
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { import {
persistExternalEntryPayload, persistExternalEntryPayload,
resolveCheckoutIntentDestination,
resolveExternalEntryDestination, resolveExternalEntryDestination,
resolveExternalEntryPromotionType, resolveExternalEntryPromotionType,
resolveExternalEntryTarget, resolveExternalEntryTarget,
@@ -22,6 +23,7 @@ import {
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff"; import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff";
import { consumeCheckoutHandoff } from "@/lib/auth/checkout_handoff";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
import { import {
isFavoriteEntryRequest, isFavoriteEntryRequest,
@@ -61,6 +63,9 @@ export default function ExternalEntryPersist({
const [hasPersisted, setHasPersisted] = useState(false); const [hasPersisted, setHasPersisted] = useState(false);
const [handoffError, setHandoffError] = useState<string | null>(null); const [handoffError, setHandoffError] = useState<string | null>(null);
const [handoffCompleted, setHandoffCompleted] = useState(false); const [handoffCompleted, setHandoffCompleted] = useState(false);
const [checkoutDestination, setCheckoutDestination] = useState<string | null>(
null,
);
const hasNavigatedRef = useRef(false); const hasNavigatedRef = useRef(false);
const handoffStartedRef = useRef(false); const handoffStartedRef = useRef(false);
const targetRoute = resolveExternalEntryTarget({ target }); const targetRoute = resolveExternalEntryTarget({ target });
@@ -71,11 +76,14 @@ export default function ExternalEntryPersist({
mode, mode,
promotionType, 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 = const displayedHandoffError =
handoffError ?? handoffError ??
(isTopUpHandoff && !hasValue(handoffToken) (isLoginHandoff && !hasValue(handoffToken)
? "This top-up link is invalid. Please request a new link in Messenger." ? "This checkout link is invalid. Please return and request a new link."
: null); : null);
useEffect(() => { useEffect(() => {
@@ -128,7 +136,7 @@ export default function ExternalEntryPersist({
useEffect(() => { useEffect(() => {
if (hasNavigatedRef.current || !hasPersisted) return; if (hasNavigatedRef.current || !hasPersisted) return;
if (isTopUpHandoff) { if (isLoginHandoff) {
if (!authState.hasInitialized || authState.isLoading) return; if (!authState.hasInitialized || authState.isLoading) return;
if (handoffCompleted) { if (handoffCompleted) {
if ( if (
@@ -138,25 +146,60 @@ export default function ExternalEntryPersist({
return; return;
} }
hasNavigatedRef.current = true; hasNavigatedRef.current = true;
router.replace(destination); router.replace(checkoutDestination ?? destination);
return; return;
} }
if (handoffStartedRef.current) return; if (handoffStartedRef.current) return;
if (!hasValue(handoffToken)) return; if (!hasValue(handoffToken)) return;
handoffStartedRef.current = true; handoffStartedRef.current = true;
void (async () => { void (async () => {
const result = await consumeTopUpHandoff(handoffToken); if (isCheckoutHandoff) {
if (Result.isErr(result)) { const result = await consumeCheckoutHandoff(handoffToken);
log.warn("[ExternalEntryPersist] top-up handoff failed", result.error); 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.replaceState(
window.history.state, window.history.state,
"", "",
"/external-entry?target=topup", "/external-entry?target=topup",
); );
setHandoffError(
"This top-up link is invalid or has expired. Please request a new link in Messenger.",
);
return;
} }
setHandoffCompleted(true); setHandoffCompleted(true);
authDispatch({ type: "AuthInit" }); authDispatch({ type: "AuthInit" });
@@ -190,9 +233,12 @@ export default function ExternalEntryPersist({
authState.isLoading, authState.isLoading,
authState.loginStatus, authState.loginStatus,
destination, destination,
checkoutDestination,
hasPersisted, hasPersisted,
handoffCompleted, handoffCompleted,
handoffToken, handoffToken,
isCheckoutHandoff,
isLoginHandoff,
isTopUpHandoff, isTopUpHandoff,
psid, psid,
router, router,
+1
View File
@@ -8,6 +8,7 @@
* `/external-entry?target=tip` * `/external-entry?target=tip`
* `/external-entry?target=private-zone&character=nayeli` * `/external-entry?target=private-zone&character=nayeli`
* `/external-entry?target=topup&handoffToken=<opaque-token>` * `/external-entry?target=topup&handoffToken=<opaque-token>`
* `/external-entry?target=checkout&handoffToken=<opaque-token>`
* *
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储, * 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。 * 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
@@ -6,6 +6,7 @@
*/ */
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow"; import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs"; 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 { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit"; import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
import { import {
@@ -70,6 +71,21 @@ export function SubscriptionCheckoutButton({
> >
{label} {label}
</SubscriptionCtaButton> </SubscriptionCtaButton>
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
<ExternalBrowserCheckoutButton
disabled={disabled || isLoading}
checkoutIntent={{
planId: payment.selectedPlanId,
autoRenew: payment.autoRenew,
...(payment.commercialOfferId
? { commercialOfferId: payment.commercialOfferId }
: {}),
...(payment.chatActionId
? { chatActionId: payment.chatActionId }
: {}),
}}
/>
) : null}
{payment.errorMessage ? ( {payment.errorMessage ? (
<p <p
role="alert" role="alert"
+2
View File
@@ -5,6 +5,7 @@ import {
} from "@/lib/analytics/payment_analytics_context"; } from "@/lib/analytics/payment_analytics_context";
import { import {
getFirstPaymentSearchParam, getFirstPaymentSearchParam,
parsePaymentAutoRenew,
parsePaymentReturnSearchParams, parsePaymentReturnSearchParams,
parseSubscriptionReturnTo, parseSubscriptionReturnTo,
type PaymentSearchParams, type PaymentSearchParams,
@@ -57,6 +58,7 @@ export default async function SubscriptionPage({
analyticsContext={analyticsContext} analyticsContext={analyticsContext}
sourceCharacterSlug={sourceCharacterSlug} sourceCharacterSlug={sourceCharacterSlug}
initialPlanId={initialPlanId} initialPlanId={initialPlanId}
initialAutoRenew={parsePaymentAutoRenew(query.autoRenew)}
commercialOfferId={commercialOfferId} commercialOfferId={commercialOfferId}
resumeOrderId={resumeOrderId} resumeOrderId={resumeOrderId}
chatActionId={chatActionId} chatActionId={chatActionId}
@@ -53,6 +53,7 @@ export interface SubscriptionScreenProps {
analyticsContext?: PaymentAnalyticsContext; analyticsContext?: PaymentAnalyticsContext;
sourceCharacterSlug?: string; sourceCharacterSlug?: string;
initialPlanId?: string | null; initialPlanId?: string | null;
initialAutoRenew?: boolean | null;
commercialOfferId?: string | null; commercialOfferId?: string | null;
resumeOrderId?: string | null; resumeOrderId?: string | null;
chatActionId?: string | null; chatActionId?: string | null;
@@ -66,6 +67,7 @@ export function SubscriptionScreen({
analyticsContext: providedAnalyticsContext, analyticsContext: providedAnalyticsContext,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG, sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
initialPlanId = null, initialPlanId = null,
initialAutoRenew = null,
commercialOfferId = null, commercialOfferId = null,
resumeOrderId = null, resumeOrderId = null,
chatActionId = null, chatActionId = null,
@@ -103,6 +105,7 @@ export function SubscriptionScreen({
sourceCharacterSlug, sourceCharacterSlug,
initialPayChannel: paymentMethodConfig.initialPayChannel, initialPayChannel: paymentMethodConfig.initialPayChannel,
initialPlanId, initialPlanId,
initialAutoRenew,
commercialOfferId, commercialOfferId,
resumeOrderId, resumeOrderId,
chatActionId, chatActionId,
@@ -21,6 +21,7 @@ export interface UseSubscriptionPaymentFlowInput {
initialPayChannel: PayChannel; initialPayChannel: PayChannel;
sourceCharacterSlug?: string; sourceCharacterSlug?: string;
initialPlanId?: string | null; initialPlanId?: string | null;
initialAutoRenew?: boolean | null;
commercialOfferId?: string | null; commercialOfferId?: string | null;
resumeOrderId?: string | null; resumeOrderId?: string | null;
chatActionId?: string | null; chatActionId?: string | null;
@@ -33,6 +34,7 @@ export function useSubscriptionPaymentFlow({
initialPayChannel, initialPayChannel,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG, sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
initialPlanId = null, initialPlanId = null,
initialAutoRenew = null,
commercialOfferId = null, commercialOfferId = null,
resumeOrderId = null, resumeOrderId = null,
chatActionId = null, chatActionId = null,
@@ -43,6 +45,7 @@ export function useSubscriptionPaymentFlow({
paymentType: subscriptionType, paymentType: subscriptionType,
shouldResumePendingOrder, shouldResumePendingOrder,
initialPlanId, initialPlanId,
initialAutoRenew,
commercialOfferId, commercialOfferId,
resumeOrderId, resumeOrderId,
chatActionId, chatActionId,
@@ -359,6 +359,8 @@ function makePaymentState(
selectedGiftCategory: giftCategory.category, selectedGiftCategory: giftCategory.category,
isFirstRecharge: false, isFirstRecharge: false,
commercialOffer: null, commercialOffer: null,
commercialOfferId: null,
chatActionId: null,
selectedPlanId: giftPlan.planId, selectedPlanId: giftPlan.planId,
payChannel: "stripe", payChannel: "stripe",
autoRenew: false, autoRenew: false,
+14
View File
@@ -2,6 +2,7 @@
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow"; import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs"; import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
import { ExternalBrowserCheckoutButton } from "@/app/_components/payment/external-browser-checkout-button";
import { useActiveCharacter } from "@/providers/character-provider"; import { useActiveCharacter } from "@/providers/character-provider";
import { import {
usePaymentDispatch, usePaymentDispatch,
@@ -63,6 +64,19 @@ export function TipCheckoutButton({
> >
{label} {label}
</button> </button>
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
<ExternalBrowserCheckoutButton
disabled={disabled || isLoading}
checkoutIntent={{
planId: payment.selectedPlanId,
autoRenew: false,
recipientCharacterId: character.id,
...(payment.chatActionId
? { chatActionId: payment.chatActionId }
: {}),
}}
/>
) : null}
{payment.errorMessage ? ( {payment.errorMessage ? (
<p className={styles.checkoutError} role="alert"> <p className={styles.checkoutError} role="alert">
{payment.errorMessage} {payment.errorMessage}
+28
View File
@@ -1,6 +1,10 @@
import { ExceptionHandler } from "@/core/errors"; import { ExceptionHandler } from "@/core/errors";
import type { IAuthRepository } from "@/data/repositories/interfaces"; import type { IAuthRepository } from "@/data/repositories/interfaces";
import { import {
CheckoutHandoffConsumeRequestSchema,
CheckoutHandoffCreateRequestSchema,
type CheckoutHandoffCreateResponse,
type CheckoutIntent,
FacebookIdentityRequestSchema, FacebookIdentityRequestSchema,
FacebookLoginRequestSchema, FacebookLoginRequestSchema,
FbIdLoginRequestSchema, FbIdLoginRequestSchema,
@@ -276,6 +280,30 @@ export class AuthRepository implements IAuthRepository {
}); });
} }
/** 创建订单前生成一次性外部浏览器支付交接链接。 */
async createCheckoutHandoff(
checkoutIntent: CheckoutIntent,
): Promise<Result<CheckoutHandoffCreateResponse>> {
return Result.wrap(() =>
this.api.createCheckoutHandoff(
CheckoutHandoffCreateRequestSchema.parse(checkoutIntent),
),
);
}
/** 消费支付交接凭证,保存后端签发的正式登录态并返回购买意图。 */
async consumeCheckoutHandoff(
handoffToken: string,
): Promise<Result<CheckoutIntent>> {
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,空则直接返回错误; * 刷新 token:先读本地的 refresh token,空则直接返回错误;
* 调用 API 成功后写回新的 login token + refresh token。 * 调用 API 成功后写回新的 login token + refresh token。
@@ -13,6 +13,8 @@
import type { Result } from "@/utils/result"; import type { Result } from "@/utils/result";
import type { import type {
CheckoutHandoffCreateResponse,
CheckoutIntent,
GuestLoginResponse, GuestLoginResponse,
LoginStatus, LoginStatus,
LoginResponse, LoginResponse,
@@ -74,6 +76,16 @@ export interface IAuthRepository {
/** 消费一次性充值登录凭证并建立正式会话。 */ /** 消费一次性充值登录凭证并建立正式会话。 */
consumeTopUpHandoff(handoffToken: string): Promise<Result<LoginStatus>>; consumeTopUpHandoff(handoffToken: string): Promise<Result<LoginStatus>>;
/** 在创建订单前生成10分钟有效的外部浏览器支付链接。 */
createCheckoutHandoff(
checkoutIntent: CheckoutIntent,
): Promise<Result<CheckoutHandoffCreateResponse>>;
/** 消费单次支付交接凭证并保存正式登录态。 */
consumeCheckoutHandoff(
handoffToken: string,
): Promise<Result<CheckoutIntent>>;
/** 刷新 token:先读本地的 refresh token,空则返回错误。 */ /** 刷新 token:先读本地的 refresh token,空则返回错误。 */
refreshToken(): Promise<Result<RefreshTokenResponse>>; refreshToken(): Promise<Result<RefreshTokenResponse>>;
@@ -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");
});
});
+58
View File
@@ -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<typeof CheckoutIntentSchema>;
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
>;
+1
View File
@@ -3,6 +3,7 @@
*/ */
export * from "./facebook_user_data"; export * from "./facebook_user_data";
export * from "./checkout_handoff";
export * from "./login_status"; export * from "./login_status";
export * from "./request/facebook_identity_request"; export * from "./request/facebook_identity_request";
export * from "./request/facebook_login_request"; export * from "./request/facebook_login_request";
+2
View File
@@ -6,6 +6,8 @@
"facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" }, "facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" },
"facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" }, "facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" },
"topUpHandoffConsume": { "method": "post", "path": "/api/auth/handoff/topup/consume" }, "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" }, "refresh": { "method": "post", "path": "/api/auth/refresh" },
"logout": { "method": "post", "path": "/api/auth/logout" }, "logout": { "method": "post", "path": "/api/auth/logout" },
"getCurrentUser": { "method": "get", "path": "/api/auth/me" }, "getCurrentUser": { "method": "get", "path": "/api/auth/me" },
+6
View File
@@ -30,6 +30,12 @@ export class ApiPath {
/** 消费一次性充值登录凭证 */ /** 消费一次性充值登录凭证 */
static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path; static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path;
/** 创建一次性外部浏览器支付交接链接(创建订单前)。 */
static readonly checkoutHandoffCreate = apiContract.checkoutHandoffCreate.path;
/** 消费一次性外部浏览器支付交接凭证。 */
static readonly checkoutHandoffConsume = apiContract.checkoutHandoffConsume.path;
/** 刷新 Token */ /** 刷新 Token */
static readonly refresh = apiContract.refresh.path; static readonly refresh = apiContract.refresh.path;
+32
View File
@@ -7,6 +7,12 @@
import { z } from "zod"; import { z } from "zod";
import { import {
CheckoutHandoffConsumeRequest,
CheckoutHandoffConsumeResponse,
CheckoutHandoffConsumeResponseSchema,
CheckoutHandoffCreateRequest,
CheckoutHandoffCreateResponse,
CheckoutHandoffCreateResponseSchema,
FacebookIdentityRequest, FacebookIdentityRequest,
FacebookIdentityResponse, FacebookIdentityResponse,
FacebookIdentityResponseSchema, FacebookIdentityResponseSchema,
@@ -104,6 +110,32 @@ export class AuthApi {
); );
} }
/** 在创建支付订单前生成一次性外部浏览器交接链接。 */
async createCheckoutHandoff(
body: CheckoutHandoffCreateRequest,
): Promise<CheckoutHandoffCreateResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.checkoutHandoffCreate,
{ method: "POST", body },
);
return CheckoutHandoffCreateResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
/** 消费一次性支付交接凭证并恢复正式登录态与购买意图。 */
async consumeCheckoutHandoff(
body: CheckoutHandoffConsumeRequest,
): Promise<CheckoutHandoffConsumeResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.checkoutHandoffConsume,
{ method: "POST", body },
);
return CheckoutHandoffConsumeResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
/** /**
* 绑定 Facebook ASID / PSID 到当前用户 * 绑定 Facebook ASID / PSID 到当前用户
*/ */
+18
View File
@@ -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<Result<CheckoutHandoffCreateResponse>> {
return getAuthRepository().createCheckoutHandoff(checkoutIntent);
}
export function consumeCheckoutHandoff(
handoffToken: string,
): Promise<Result<CheckoutIntent>> {
return getAuthRepository().consumeCheckoutHandoff(handoffToken);
}
@@ -3,12 +3,38 @@ import { describe, expect, it } from "vitest";
import { getCharacterRoutes, ROUTES } from "@/router/routes"; import { getCharacterRoutes, ROUTES } from "@/router/routes";
import { import {
resolveCheckoutIntentDestination,
resolveExternalEntryDestination, resolveExternalEntryDestination,
resolveExternalEntryPromotionType, resolveExternalEntryPromotionType,
resolveExternalEntryTarget, resolveExternalEntryTarget,
shouldBindExternalFacebookIdentity, shouldBindExternalFacebookIdentity,
} from "../external_entry"; } 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");
});
});
describe("external entry navigation", () => { describe("external entry navigation", () => {
it("defaults to chat", () => { it("defaults to chat", () => {
expect(resolveExternalEntryTarget({})).toBe(ROUTES.chat); expect(resolveExternalEntryTarget({})).toBe(ROUTES.chat);
+27
View File
@@ -4,8 +4,10 @@ import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { UserStorage } from "@/data/storage/user/user_storage"; import { UserStorage } from "@/data/storage/user/user_storage";
import type { PendingChatPromotionType } from "@/data/storage/navigation"; import type { PendingChatPromotionType } from "@/data/storage/navigation";
import type { LoginStatus } from "@/data/schemas/auth"; import type { LoginStatus } from "@/data/schemas/auth";
import type { CheckoutIntent } from "@/data/schemas/auth";
import { import {
DEFAULT_CHARACTER_SLUG, DEFAULT_CHARACTER_SLUG,
getCharacterById,
getCharacterBySlug, getCharacterBySlug,
} from "@/data/constants/character"; } from "@/data/constants/character";
import { getCharacterRoutes, ROUTES } from "@/router/routes"; import { getCharacterRoutes, ROUTES } from "@/router/routes";
@@ -100,6 +102,30 @@ export function resolveExternalEntryDestination({
return routes.chat; 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({ export async function persistExternalEntryPayload({
deviceId, deviceId,
asid, asid,
@@ -138,6 +164,7 @@ function resolveTarget(
return ROUTES.privateZone; return ROUTES.privateZone;
} }
if (target === "topup") return ROUTES.subscription; if (target === "topup") return ROUTES.subscription;
if (target === "checkout") return ROUTES.subscription;
return null; return null;
} }
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { import {
getPaymentUrl, getPaymentUrl,
getStripeCustomerSessionClientSecret,
getStripeClientSecret, getStripeClientSecret,
isEzpayPayment, isEzpayPayment,
launchEzpayRedirect, launchEzpayRedirect,
@@ -43,6 +44,28 @@ describe("payment launch helpers", () => {
).toBeNull(); ).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", () => { it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => {
expect( expect(
resolveEzpayLaunchTarget({ resolveEzpayLaunchTarget({
+21
View File
@@ -143,6 +143,27 @@ export function getStripeClientSecret(
return null; return null;
} }
export function getStripeCustomerSessionClientSecret(
payParams: Record<string, unknown>,
): 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 { export interface LaunchEzpayRedirectInput {
orderId: string | null; orderId: string | null;
paymentUrl: string; paymentUrl: string;
+9
View File
@@ -22,6 +22,15 @@ export function parsePaymentPayChannel(
return channel === "ezpay" || channel === "stripe" ? channel : null; 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( export function parseSubscriptionReturnTo(
value: PaymentSearchParamValue, value: PaymentSearchParamValue,
): AppSubscriptionReturnTo { ): AppSubscriptionReturnTo {
@@ -8,7 +8,6 @@ import {
import { import {
createTestPaymentMachine, createTestPaymentMachine,
giftCatalog,
lifetimePlan, lifetimePlan,
monthlyPlan, monthlyPlan,
quarterlyPlan, quarterlyPlan,
@@ -51,7 +50,7 @@ describe("payment catalog flow", () => {
actor.stop(); 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(); const actor = createActor(createTestPaymentMachine()).start();
actor.send({ actor.send({
type: "PaymentInit", type: "PaymentInit",
@@ -79,8 +78,9 @@ describe("payment catalog flow", () => {
snapshot.context.requestedGiftPlanId === null, snapshot.context.requestedGiftPlanId === null,
); );
expect(actor.getSnapshot().context.selectedPlanId).toBe( expect(actor.getSnapshot().context.selectedPlanId).toBe(
giftCatalog.plans[0]?.planId, "tip_flowers_usd_12_99",
); );
expect(actor.getSnapshot().context.selectedGiftCategory).toBe("flowers");
actor.stop(); actor.stop();
}); });
+12 -4
View File
@@ -50,15 +50,23 @@ export function hydrateGiftProductsState(
const giftProducts = response.plans.filter( const giftProducts = response.plans.filter(
(product) => product.characterId === characterId, (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 const visibleProducts = selectedGiftCategory
? giftProducts.filter( ? giftProducts.filter(
(product) => product.category === selectedGiftCategory, (product) => product.category === selectedGiftCategory,
) )
: []; : [];
const canRestoreSelection = const canRestoreSelection = visibleProducts.some(
restoredCategory === selectedGiftCategory && (product) => product.planId === restoredPlanId,
visibleProducts.some((product) => product.planId === restoredPlanId); );
const selectedPlanId = canRestoreSelection const selectedPlanId = canRestoreSelection
? (restoredPlanId ?? "") ? (restoredPlanId ?? "")
: (visibleProducts[0]?.planId ?? ""); : (visibleProducts[0]?.planId ?? "");
+14 -5
View File
@@ -32,14 +32,18 @@ function initializeCatalogState(
const catalogChanged = planCatalog !== context.planCatalog; const catalogChanged = planCatalog !== context.planCatalog;
const characterChanged = giftCharacterId !== context.giftCharacterId; const characterChanged = giftCharacterId !== context.giftCharacterId;
const selectionChanged = const selectionChanged =
planCatalog === "tip" && planCatalog === "tip"
((event.category ?? null) !== context.selectedGiftCategory || ? (event.category ?? null) !== context.selectedGiftCategory ||
(event.planId ?? null) !== (context.selectedPlanId || null)); (event.planId ?? null) !== (context.selectedPlanId || null)
: (event.planId ?? null) !== (context.selectedPlanId || null);
const commercialOfferId = const commercialOfferId =
planCatalog === "default" ? (event.commercialOfferId ?? null) : null; planCatalog === "default" ? (event.commercialOfferId ?? null) : null;
const offerChanged = commercialOfferId !== context.commercialOfferId; const offerChanged = commercialOfferId !== context.commercialOfferId;
const chatActionId = event.chatActionId ?? null; const chatActionId = event.chatActionId ?? null;
const chatActionChanged = chatActionId !== context.chatActionId; const chatActionChanged = chatActionId !== context.chatActionId;
const initialAutoRenew =
planCatalog === "tip" ? false : (event.autoRenew ?? true);
const autoRenewChanged = initialAutoRenew !== context.autoRenew;
return { return {
planCatalog, planCatalog,
@@ -51,7 +55,12 @@ function initializeCatalogState(
planCatalog === "tip" ? (event.category ?? null) : null, planCatalog === "tip" ? (event.category ?? null) : null,
requestedGiftPlanId: requestedGiftPlanId:
planCatalog === "tip" ? (event.planId ?? null) : null, planCatalog === "tip" ? (event.planId ?? null) : null,
...(catalogChanged || characterChanged || selectionChanged || offerChanged || chatActionChanged ...(catalogChanged ||
characterChanged ||
selectionChanged ||
offerChanged ||
chatActionChanged ||
autoRenewChanged
? { ? {
...resetOrderState(), ...resetOrderState(),
plans: [], plans: [],
@@ -62,7 +71,7 @@ function initializeCatalogState(
planCatalog === "default" ? (event.planId ?? "") : "", planCatalog === "default" ? (event.planId ?? "") : "",
isFirstRecharge: false, isFirstRecharge: false,
commercialOffer: null, commercialOffer: null,
autoRenew: planCatalog !== "tip", autoRenew: initialAutoRenew,
} }
: {}), : {}),
}; };
+4
View File
@@ -20,6 +20,8 @@ export interface PaymentContextState {
selectedGiftCategory: string | null; selectedGiftCategory: string | null;
isFirstRecharge: MachineContext["isFirstRecharge"]; isFirstRecharge: MachineContext["isFirstRecharge"];
commercialOffer: MachineContext["commercialOffer"]; commercialOffer: MachineContext["commercialOffer"];
commercialOfferId: string | null;
chatActionId: string | null;
selectedPlanId: string; selectedPlanId: string;
payChannel: MachineContext["payChannel"]; payChannel: MachineContext["payChannel"];
autoRenew: boolean; autoRenew: boolean;
@@ -76,6 +78,8 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
selectedGiftCategory: state.context.selectedGiftCategory, selectedGiftCategory: state.context.selectedGiftCategory,
isFirstRecharge: state.context.isFirstRecharge, isFirstRecharge: state.context.isFirstRecharge,
commercialOffer: state.context.commercialOffer, commercialOffer: state.context.commercialOffer,
commercialOfferId: state.context.commercialOfferId,
chatActionId: state.context.chatActionId,
selectedPlanId: state.context.selectedPlanId, selectedPlanId: state.context.selectedPlanId,
payChannel: state.context.payChannel, payChannel: state.context.payChannel,
autoRenew: state.context.autoRenew, autoRenew: state.context.autoRenew,
+1
View File
@@ -10,6 +10,7 @@ export type PaymentEvent =
characterId?: string; characterId?: string;
category?: string | null; category?: string | null;
planId?: string | null; planId?: string | null;
autoRenew?: boolean;
commercialOfferId?: string | null; commercialOfferId?: string | null;
chatActionId?: string | null; chatActionId?: string | null;
} }
@@ -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]");
});
});
+49 -16
View File
@@ -107,9 +107,13 @@ export class Logger {
const trimmed = value.trim(); const trimmed = value.trim();
if (trimmed.length === 0) return ""; if (trimmed.length === 0) return "";
try { try {
return JSON.stringify(JSON.parse(trimmed), null, 2); return JSON.stringify(
Logger.toSerializableLogValue(JSON.parse(trimmed)),
null,
2,
);
} catch { } catch {
return value; return Logger.redactSensitiveString(value);
} }
} }
@@ -149,28 +153,31 @@ export class Logger {
} }
private write(level: LogLevel, args: LogArgs): void { 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()) { if (Logger.shouldUseBrowserConsole()) {
Logger.writeBrowserConsole(level, this.component, args); Logger.writeBrowserConsole(level, this.component, safeArgs);
return; return;
} }
switch (level) { switch (level) {
case "debug": case "debug":
this.logger.debug(...Logger.normalizeLogArgs(args)); this.logger.debug(...Logger.normalizeLogArgs(safeArgs));
return; return;
case "info": case "info":
this.logger.info(...Logger.normalizeLogArgs(args)); this.logger.info(...Logger.normalizeLogArgs(safeArgs));
return; return;
case "warn": case "warn":
this.logger.warn(...Logger.normalizeLogArgs(args)); this.logger.warn(...Logger.normalizeLogArgs(safeArgs));
return; return;
case "error": case "error":
this.logger.error(...Logger.normalizeLogArgs(args)); this.logger.error(...Logger.normalizeLogArgs(safeArgs));
return; return;
case "fatal": case "fatal":
this.logger.fatal(...Logger.normalizeLogArgs(args)); this.logger.fatal(...Logger.normalizeLogArgs(safeArgs));
return; return;
} }
} }
@@ -266,6 +273,10 @@ export class Logger {
return value.map((item) => Logger.toSerializableLogValue(item, seen)); return value.map((item) => Logger.toSerializableLogValue(item, seen));
} }
if (typeof value === "string") {
return Logger.redactSensitiveString(value);
}
if (typeof value !== "object" || value === null) { if (typeof value !== "object" || value === null) {
return value; return value;
} }
@@ -281,19 +292,39 @@ export class Logger {
const output: Record<string, unknown> = {}; const output: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) { 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; 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( private static serializeError(
error: Error, error: Error,
seen: WeakSet<object>, seen: WeakSet<object>,
): Record<string, unknown> { ): Record<string, unknown> {
const output: Record<string, unknown> = { const output: Record<string, unknown> = {
name: error.name, name: error.name,
message: error.message, message: Logger.redactSensitiveString(error.message),
stack: error.stack, stack: error.stack
? Logger.redactSensitiveString(error.stack)
: undefined,
}; };
if (seen.has(error)) { if (seen.has(error)) {
@@ -303,10 +334,12 @@ export class Logger {
for (const key of Object.getOwnPropertyNames(error)) { for (const key of Object.getOwnPropertyNames(error)) {
if (key in output) continue; if (key in output) continue;
output[key] = Logger.toSerializableLogValue( output[key] = Logger.isSensitiveLogKey(key)
(error as unknown as Record<string, unknown>)[key], ? "[REDACTED]"
seen, : Logger.toSerializableLogValue(
); (error as unknown as Record<string, unknown>)[key],
seen,
);
} }
return output; return output;
} }