feat(payment): expand Stripe methods and checkout handoff
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
shouldShowStripeDialog: false,
|
||||
stripeClientSecret: null,
|
||||
stripeCustomerSessionClientSecret: null,
|
||||
savedPaymentMethodsEnabled: false,
|
||||
};
|
||||
|
||||
describe("PaymentLaunchDialogs", () => {
|
||||
|
||||
@@ -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<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";
|
||||
|
||||
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(
|
||||
<StripePaymentDialog
|
||||
clientSecret="client-secret"
|
||||
onClose={onClose}
|
||||
clientSecret="pi_123_secret_payment"
|
||||
customerSessionClientSecret="cuss_123_secret_saved"
|
||||
savedPaymentMethodsEnabled
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
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();
|
||||
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();
|
||||
});
|
||||
|
||||
const okButton = Array.from(dialog?.querySelectorAll("button") ?? []).find(
|
||||
(button) => button.textContent === "OK",
|
||||
it("does not pass an invalid or disabled Customer Session secret", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<StripePaymentDialog
|
||||
clientSecret="pi_123_secret_payment"
|
||||
customerSessionClientSecret="invalid"
|
||||
savedPaymentMethodsEnabled={false}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(capturedElementsOptions).not.toHaveProperty(
|
||||
"customerSessionClientSecret",
|
||||
);
|
||||
act(() => okButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("portals the lazy loading state", () => {
|
||||
act(() => root.render(<StripePaymentDialogLoading />));
|
||||
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 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();
|
||||
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"
|
||||
| "shouldShowStripeDialog"
|
||||
| "stripeClientSecret"
|
||||
| "stripeCustomerSessionClientSecret"
|
||||
| "savedPaymentMethodsEnabled"
|
||||
>;
|
||||
|
||||
export interface PaymentLaunchDialogsProps {
|
||||
@@ -64,6 +66,10 @@ export function PaymentLaunchDialogs({
|
||||
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
||||
<LazyStripePaymentDialog
|
||||
clientSecret={launch.stripeClientSecret}
|
||||
customerSessionClientSecret={
|
||||
launch.stripeCustomerSessionClientSecret
|
||||
}
|
||||
savedPaymentMethodsEnabled={launch.savedPaymentMethodsEnabled}
|
||||
returnPath={stripeReturnPath}
|
||||
onClose={launch.handleStripeClose}
|
||||
onConfirmed={launch.handleStripeConfirmed}
|
||||
|
||||
@@ -9,6 +9,11 @@ export const stripePaymentDialogStyles = {
|
||||
content:
|
||||
"m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]",
|
||||
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:
|
||||
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
|
||||
actions: "flex w-full gap-(--spacing-md,12px)",
|
||||
|
||||
@@ -4,19 +4,28 @@
|
||||
*
|
||||
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
||||
*/
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Elements,
|
||||
ExpressCheckoutElement,
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} 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 { ExceptionHandler } from "@/core/errors";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
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";
|
||||
|
||||
@@ -26,8 +35,16 @@ const stripePromise = stripePublishableKey
|
||||
? loadStripe(stripePublishableKey)
|
||||
: null;
|
||||
|
||||
type ExpressAvailabilityValue = boolean | { available: boolean } | undefined;
|
||||
|
||||
function isExpressMethodAvailable(value: ExpressAvailabilityValue): boolean {
|
||||
return value === true || (typeof value === "object" && value.available);
|
||||
}
|
||||
|
||||
export interface StripePaymentDialogProps {
|
||||
clientSecret: string;
|
||||
customerSessionClientSecret?: string | null;
|
||||
savedPaymentMethodsEnabled?: boolean;
|
||||
returnPath?: string;
|
||||
onClose: () => 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<string | null>(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<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
|
||||
@@ -150,38 +191,118 @@ function StripePaymentForm({
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
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<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);
|
||||
setErrorMessage(null);
|
||||
|
||||
const { error: submitError } = await elements.submit();
|
||||
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."),
|
||||
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({
|
||||
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<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
await confirmPayment({ submitPaymentElement: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<p
|
||||
role="alert"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
getPaymentUrl,
|
||||
getStripeCustomerSessionClientSecret,
|
||||
getStripeClientSecret,
|
||||
isEzpayPayment,
|
||||
launchEzpayRedirect,
|
||||
@@ -59,6 +60,8 @@ export interface PaymentLaunchFlow {
|
||||
shouldShowQrisDialog: boolean;
|
||||
shouldShowStripeDialog: boolean;
|
||||
stripeClientSecret: string | null;
|
||||
stripeCustomerSessionClientSecret: string | null;
|
||||
savedPaymentMethodsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface QrisPaymentDetails {
|
||||
@@ -165,6 +168,9 @@ export function usePaymentLaunchFlow({
|
||||
const stripeClientSecret = payment.payParams
|
||||
? getStripeClientSecret(payment.payParams)
|
||||
: null;
|
||||
const stripeCustomerSessionClientSecret = payment.payParams
|
||||
? getStripeCustomerSessionClientSecret(payment.payParams)
|
||||
: null;
|
||||
const ezpayLaunchTarget =
|
||||
payment.payParams && isEzpayPayment(payment.payParams)
|
||||
? resolveEzpayLaunchTarget(payment.payParams)
|
||||
@@ -385,5 +391,8 @@ export function usePaymentLaunchFlow({
|
||||
shouldShowQrisDialog,
|
||||
shouldShowStripeDialog,
|
||||
stripeClientSecret,
|
||||
stripeCustomerSessionClientSecret,
|
||||
savedPaymentMethodsEnabled:
|
||||
stripeCustomerSessionClientSecret !== null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface UsePaymentRouteFlowInput {
|
||||
characterId?: string;
|
||||
initialCategory?: string | null;
|
||||
initialPlanId?: string | null;
|
||||
initialAutoRenew?: boolean | null;
|
||||
commercialOfferId?: string | null;
|
||||
resumeOrderId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
@@ -44,6 +45,7 @@ export function usePaymentRouteFlow({
|
||||
characterId,
|
||||
initialCategory = null,
|
||||
initialPlanId = null,
|
||||
initialAutoRenew = null,
|
||||
commercialOfferId = null,
|
||||
resumeOrderId = null,
|
||||
chatActionId = null,
|
||||
@@ -57,6 +59,7 @@ export function usePaymentRouteFlow({
|
||||
characterId ?? "",
|
||||
initialCategory ?? "",
|
||||
initialPlanId ?? "",
|
||||
initialAutoRenew === null ? "" : String(initialAutoRenew),
|
||||
commercialOfferId ?? "",
|
||||
chatActionId ?? "",
|
||||
].join(":");
|
||||
@@ -78,6 +81,9 @@ export function usePaymentRouteFlow({
|
||||
...(characterId ? { characterId } : {}),
|
||||
...(initialCategory ? { category: initialCategory } : {}),
|
||||
...(initialPlanId ? { planId: initialPlanId } : {}),
|
||||
...(initialAutoRenew === null
|
||||
? {}
|
||||
: { autoRenew: initialAutoRenew }),
|
||||
...(commercialOfferId ? { commercialOfferId } : {}),
|
||||
...(chatActionId ? { chatActionId } : {}),
|
||||
});
|
||||
@@ -89,6 +95,7 @@ export function usePaymentRouteFlow({
|
||||
chatActionId,
|
||||
initialCategory,
|
||||
initialPlanId,
|
||||
initialAutoRenew,
|
||||
initialPayChannel,
|
||||
paymentDispatch,
|
||||
]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
persistExternalEntryPayload,
|
||||
resolveCheckoutIntentDestination,
|
||||
resolveExternalEntryDestination,
|
||||
resolveExternalEntryPromotionType,
|
||||
resolveExternalEntryTarget,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||
import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff";
|
||||
import { consumeCheckoutHandoff } from "@/lib/auth/checkout_handoff";
|
||||
import { Result } from "@/utils/result";
|
||||
import {
|
||||
isFavoriteEntryRequest,
|
||||
@@ -61,6 +63,9 @@ export default function ExternalEntryPersist({
|
||||
const [hasPersisted, setHasPersisted] = useState(false);
|
||||
const [handoffError, setHandoffError] = useState<string | null>(null);
|
||||
const [handoffCompleted, setHandoffCompleted] = useState(false);
|
||||
const [checkoutDestination, setCheckoutDestination] = useState<string | null>(
|
||||
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,16 +146,45 @@ 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 () => {
|
||||
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);
|
||||
log.warn(
|
||||
"[ExternalEntryPersist] top-up handoff failed",
|
||||
result.error,
|
||||
);
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
@@ -158,6 +195,12 @@ export default function ExternalEntryPersist({
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
"/external-entry?target=topup",
|
||||
);
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* `/external-entry?target=tip`
|
||||
* `/external-entry?target=private-zone&character=nayeli`
|
||||
* `/external-entry?target=topup&handoffToken=<opaque-token>`
|
||||
* `/external-entry?target=checkout&handoffToken=<opaque-token>`
|
||||
*
|
||||
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
||||
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
|
||||
|
||||
@@ -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}
|
||||
</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 ? (
|
||||
<p
|
||||
role="alert"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@/lib/analytics/payment_analytics_context";
|
||||
import {
|
||||
getFirstPaymentSearchParam,
|
||||
parsePaymentAutoRenew,
|
||||
parsePaymentReturnSearchParams,
|
||||
parseSubscriptionReturnTo,
|
||||
type PaymentSearchParams,
|
||||
@@ -57,6 +58,7 @@ export default async function SubscriptionPage({
|
||||
analyticsContext={analyticsContext}
|
||||
sourceCharacterSlug={sourceCharacterSlug}
|
||||
initialPlanId={initialPlanId}
|
||||
initialAutoRenew={parsePaymentAutoRenew(query.autoRenew)}
|
||||
commercialOfferId={commercialOfferId}
|
||||
resumeOrderId={resumeOrderId}
|
||||
chatActionId={chatActionId}
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface SubscriptionScreenProps {
|
||||
analyticsContext?: PaymentAnalyticsContext;
|
||||
sourceCharacterSlug?: string;
|
||||
initialPlanId?: string | null;
|
||||
initialAutoRenew?: boolean | null;
|
||||
commercialOfferId?: string | null;
|
||||
resumeOrderId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
@@ -66,6 +67,7 @@ export function SubscriptionScreen({
|
||||
analyticsContext: providedAnalyticsContext,
|
||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
initialPlanId = null,
|
||||
initialAutoRenew = null,
|
||||
commercialOfferId = null,
|
||||
resumeOrderId = null,
|
||||
chatActionId = null,
|
||||
@@ -103,6 +105,7 @@ export function SubscriptionScreen({
|
||||
sourceCharacterSlug,
|
||||
initialPayChannel: paymentMethodConfig.initialPayChannel,
|
||||
initialPlanId,
|
||||
initialAutoRenew,
|
||||
commercialOfferId,
|
||||
resumeOrderId,
|
||||
chatActionId,
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface UseSubscriptionPaymentFlowInput {
|
||||
initialPayChannel: PayChannel;
|
||||
sourceCharacterSlug?: string;
|
||||
initialPlanId?: string | null;
|
||||
initialAutoRenew?: boolean | null;
|
||||
commercialOfferId?: string | null;
|
||||
resumeOrderId?: string | null;
|
||||
chatActionId?: string | null;
|
||||
@@ -33,6 +34,7 @@ export function useSubscriptionPaymentFlow({
|
||||
initialPayChannel,
|
||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
initialPlanId = null,
|
||||
initialAutoRenew = null,
|
||||
commercialOfferId = null,
|
||||
resumeOrderId = null,
|
||||
chatActionId = null,
|
||||
@@ -43,6 +45,7 @@ export function useSubscriptionPaymentFlow({
|
||||
paymentType: subscriptionType,
|
||||
shouldResumePendingOrder,
|
||||
initialPlanId,
|
||||
initialAutoRenew,
|
||||
commercialOfferId,
|
||||
resumeOrderId,
|
||||
chatActionId,
|
||||
|
||||
@@ -359,6 +359,8 @@ function makePaymentState(
|
||||
selectedGiftCategory: giftCategory.category,
|
||||
isFirstRecharge: false,
|
||||
commercialOffer: null,
|
||||
commercialOfferId: null,
|
||||
chatActionId: null,
|
||||
selectedPlanId: giftPlan.planId,
|
||||
payChannel: "stripe",
|
||||
autoRenew: false,
|
||||
|
||||
@@ -2,6 +2,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 { useActiveCharacter } from "@/providers/character-provider";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
@@ -63,6 +64,19 @@ export function TipCheckoutButton({
|
||||
>
|
||||
{label}
|
||||
</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 ? (
|
||||
<p className={styles.checkoutError} role="alert">
|
||||
{payment.errorMessage}
|
||||
|
||||
@@ -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<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,空则直接返回错误;
|
||||
* 调用 API 成功后写回新的 login token + refresh token。
|
||||
|
||||
@@ -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<Result<LoginStatus>>;
|
||||
|
||||
/** 在创建订单前生成10分钟有效的外部浏览器支付链接。 */
|
||||
createCheckoutHandoff(
|
||||
checkoutIntent: CheckoutIntent,
|
||||
): Promise<Result<CheckoutHandoffCreateResponse>>;
|
||||
|
||||
/** 消费单次支付交接凭证并保存正式登录态。 */
|
||||
consumeCheckoutHandoff(
|
||||
handoffToken: string,
|
||||
): Promise<Result<CheckoutIntent>>;
|
||||
|
||||
/** 刷新 token:先读本地的 refresh token,空则返回错误。 */
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<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 到当前用户
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -143,6 +143,27 @@ export function getStripeClientSecret(
|
||||
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 {
|
||||
orderId: string | null;
|
||||
paymentUrl: string;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ?? "");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,7 @@ export type PaymentEvent =
|
||||
characterId?: string;
|
||||
category?: string | null;
|
||||
planId?: string | null;
|
||||
autoRenew?: boolean;
|
||||
commercialOfferId?: 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]");
|
||||
});
|
||||
});
|
||||
+46
-13
@@ -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<string, unknown> = {};
|
||||
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<object>,
|
||||
): Record<string, unknown> {
|
||||
const output: Record<string, unknown> = {
|
||||
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,7 +334,9 @@ export class Logger {
|
||||
|
||||
for (const key of Object.getOwnPropertyNames(error)) {
|
||||
if (key in output) continue;
|
||||
output[key] = Logger.toSerializableLogValue(
|
||||
output[key] = Logger.isSensitiveLogKey(key)
|
||||
? "[REDACTED]"
|
||||
: Logger.toSerializableLogValue(
|
||||
(error as unknown as Record<string, unknown>)[key],
|
||||
seen,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user