Compare commits

...

2 Commits

Author SHA1 Message Date
Codex 9121b50e02 feat(payment): add chat support discounts and gratitude
Docker Image / Build and Push Docker Image (push) Successful in 3m1s
(cherry picked from commit ef9b79bc83)
2026-07-28 18:36:52 +08:00
Codex e43912eebf feat(payment): expand Stripe methods and checkout handoff 2026-07-28 18:36:51 +08:00
73 changed files with 1919 additions and 148 deletions
+3
View File
@@ -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);
+10 -1
View File
@@ -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();
+12
View File
@@ -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);
});
@@ -173,6 +173,7 @@ async function expectQrisOrder(
expect(request.postDataJSON()).toMatchObject({
planId,
payChannel: "ezpay",
recipientCharacterId: "elio",
});
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
@@ -211,6 +212,10 @@ test("Indonesia VIP defaults to QRIS and completes after status polling", async
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=vip");
await expect(page.getByText("Choose who you want to support")).toBeVisible();
await page.getByRole("button", { name: "Elio", exact: true }).click();
await expect(page.getByText("Supporting Elio")).toBeVisible();
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
"aria-pressed",
"true",
@@ -251,6 +256,7 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup");
await page.getByRole("button", { name: "Elio", exact: true }).click();
await expectCheckoutButtonLayout(page);
const orderId = await expectQrisOrder(
@@ -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();
const okButton = Array.from(dialog?.querySelectorAll("button") ?? []).find(
(button) => button.textContent === "OK",
);
act(() => okButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onClose).toHaveBeenCalledOnce();
expect(capturedElementsOptions).toMatchObject({
clientSecret: "pi_123_secret_payment",
customerSessionClientSecret: "cuss_123_secret_saved",
});
expect(capturedExpressProps?.options).toMatchObject({
paymentMethodOrder: [
"apple_pay",
"google_pay",
"link",
"paypal",
"amazon_pay",
"klarna",
],
paymentMethods: {
applePay: "always",
googlePay: "always",
link: "auto",
paypal: "auto",
amazonPay: "auto",
klarna: "auto",
},
});
expect(capturedPaymentProps?.options).toMatchObject({
layout: { type: "accordion", defaultCollapsed: true },
paymentMethodOrder: ["card"],
wallets: { applePay: "never", googlePay: "never", link: "never" },
});
const text = document.body.textContent ?? "";
expect(text).toContain("Pay by card");
const express = document.body.querySelector('[data-testid="express-checkout"]');
const card = document.body.querySelector('[data-testid="payment-element"]');
expect(express).not.toBeNull();
expect(card).not.toBeNull();
expect(
express!.compareDocumentPosition(card!) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("portals the lazy loading state", () => {
act(() => root.render(<StripePaymentDialogLoading />));
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()}
/>,
),
);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).not.toBeNull();
expect(container.contains(dialog)).toBe(false);
expect(dialog?.textContent).toContain("Preparing secure payment");
expect(dialog?.textContent).toContain("Loading payment methods...");
expect(dialog?.querySelector('[aria-busy="true"]')).not.toBeNull();
expect(capturedElementsOptions).not.toHaveProperty(
"customerSessionClientSecret",
);
});
it("uses the same confirmation path for an Express Checkout approval", async () => {
const onConfirmed = vi.fn();
act(() =>
root.render(
<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"
| "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 (submitError) {
setErrorMessage(
ExceptionHandler.message(submitError, "Please check your payment info."),
);
setIsSubmitting(false);
return;
if (options.submitPaymentElement) {
let submitError: unknown;
try {
({ error: submitError } = await elements.submit());
} catch (error) {
submitError = error;
}
if (submitError) {
setErrorMessage(
ExceptionHandler.message(
submitError,
"Please check your payment info.",
),
);
submittingRef.current = false;
setIsSubmitting(false);
return;
}
}
const returnUrl = new URL(
returnPath ?? ROUTES.subscription + "/success",
window.location.origin,
);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl.toString(),
},
redirect: "if_required",
});
let confirmationError: unknown;
try {
({ error: confirmationError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl.toString(),
},
redirect: "if_required",
}));
} catch (error) {
confirmationError = error;
}
if (error) {
setErrorMessage(
ExceptionHandler.message(error, "Payment confirmation failed."),
if (confirmationError) {
const message = ExceptionHandler.message(
confirmationError,
"Payment confirmation failed.",
);
setErrorMessage(message);
options.expressEvent?.paymentFailed({ reason: "fail", message });
submittingRef.current = false;
setIsSubmitting(false);
return;
}
@@ -189,9 +310,74 @@ function StripePaymentForm({
onConfirmed?.();
};
const handleSubmit = async (event: FormEvent<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,
};
}
+7
View File
@@ -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,
]);
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { deriveChatSupportCta } from "../chat-support-cta";
describe("deriveChatSupportCta", () => {
const now = Date.parse("2026-07-28T00:00:00.000Z");
it("shows first recharge ahead of a weaker role offer without inventing a timer", () => {
expect(
deriveChatSupportCta({
isFirstRecharge: true,
commercialOffer: {
enabled: true,
commercialOfferId: "offer-1",
characterId: "elio",
planId: "vip_annual",
discountPercent: 30,
pricePercent: 70,
expiresAt: "2026-07-29T00:00:00.000Z",
triggerReason: "price_objection",
message: "I got this for you.",
},
nowMs: now,
}),
).toMatchObject({
kind: "firstRecharge",
label: "Support me · 50% OFF · First recharge",
commercialOfferId: null,
expiresAt: null,
});
});
it("shows a server-anchored 24-hour role offer countdown", () => {
expect(
deriveChatSupportCta({
isFirstRecharge: false,
commercialOffer: {
enabled: true,
commercialOfferId: "offer-1",
characterId: "maya-tan",
planId: "vip_annual",
discountPercent: 30,
pricePercent: 70,
expiresAt: "2026-07-29T00:00:00.000Z",
triggerReason: "price_objection",
message: "I got this for you.",
},
nowMs: now,
}),
).toMatchObject({
kind: "commercialOffer",
label: "Support me · 30% OFF · 23:59:59",
commercialOfferId: "offer-1",
expiresAt: "2026-07-29T00:00:00.000Z",
});
});
it("returns to Support me after an offer expires or is absent", () => {
expect(
deriveChatSupportCta({
isFirstRecharge: false,
commercialOffer: null,
nowMs: now,
}).label,
).toBe("Support me");
expect(
deriveChatSupportCta({
isFirstRecharge: false,
commercialOffer: {
enabled: true,
commercialOfferId: "offer-1",
characterId: "elio",
planId: "vip_annual",
discountPercent: 30,
pricePercent: 70,
expiresAt: "2026-07-27T00:00:00.000Z",
triggerReason: "price_objection",
message: "I got this for you.",
},
nowMs: now,
}).label,
).toBe("Support me");
});
});
+16 -10
View File
@@ -33,8 +33,8 @@ import {
ChatHeader,
ChatInputBar,
ChatInsufficientCreditsBanner,
ChatSupportButton,
ChatUnlockDialogs,
FirstRechargeOfferBanner,
FullscreenImageViewer,
PwaInstallOverlay,
} from "./components";
@@ -46,7 +46,8 @@ import {
import {
deriveIsGuest,
} from "./chat-screen.helpers";
import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner";
import { useChatSupportCta } from "./hooks/use-chat-support-cta";
import { useChatCommercialMessages } from "./hooks/use-chat-commercial-messages";
import { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator";
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
@@ -130,10 +131,15 @@ export function ChatScreen() {
upgradeReason: state.upgradeReason,
paymentGuidance: state.paymentGuidance,
});
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
const supportCta = useChatSupportCta({
characterId: character.id,
historyLoaded: state.historyLoaded,
loginStatus: authState.loginStatus,
});
useChatCommercialMessages({
characterId: character.id,
loginStatus: authState.loginStatus,
});
const shouldShowPwaInstall =
state.historyLoaded && state.historyMessages.length >= 10;
useChatGuestLogin({
@@ -422,13 +428,13 @@ export function ChatScreen() {
<ChatHeader
isGuest={isGuest}
offerBanner={
<FirstRechargeOfferBanner
visible={firstRechargeOfferBanner.visible}
discountPercent={firstRechargeOfferBanner.discountPercent}
onClick={firstRechargeOfferBanner.claim}
onClose={firstRechargeOfferBanner.close}
variant="compact"
/>
supportCta.visible ? (
<ChatSupportButton
kind={supportCta.kind}
label={supportCta.label}
onClick={supportCta.open}
/>
) : null
}
/>
+63
View File
@@ -0,0 +1,63 @@
import type { CommercialOfferSummary } from "@/data/schemas/payment";
export type ChatSupportCtaKind =
| "support"
| "firstRecharge"
| "commercialOffer";
export interface ChatSupportCtaView {
kind: ChatSupportCtaKind;
label: string;
commercialOfferId: string | null;
planId: string | null;
expiresAt: string | null;
}
export function deriveChatSupportCta(input: {
isFirstRecharge: boolean;
commercialOffer: CommercialOfferSummary | null;
nowMs: number;
}): ChatSupportCtaView {
if (input.isFirstRecharge) {
return {
kind: "firstRecharge",
label: "Support me · 50% OFF · First recharge",
commercialOfferId: null,
planId: null,
expiresAt: null,
};
}
const offer = input.commercialOffer;
const expiresAtMs = offer ? Date.parse(offer.expiresAt) : Number.NaN;
if (offer?.enabled && Number.isFinite(expiresAtMs) && expiresAtMs > input.nowMs) {
return {
kind: "commercialOffer",
label: `Support me · ${offer.discountPercent}% OFF · ${formatOfferCountdown(expiresAtMs, input.nowMs)}`,
commercialOfferId: offer.commercialOfferId,
planId: offer.planId,
expiresAt: offer.expiresAt,
};
}
return {
kind: "support",
label: "Support me",
commercialOfferId: null,
planId: null,
expiresAt: null,
};
}
export function formatOfferCountdown(expiresAtMs: number, nowMs: number): string {
const totalSeconds = Math.max(
0,
Math.ceil((expiresAtMs - nowMs) / 1000) - 1,
);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return [hours, minutes, seconds]
.map((value) => String(value).padStart(2, "0"))
.join(":");
}
@@ -0,0 +1,32 @@
"use client";
import { Heart, Sparkles } from "lucide-react";
import type { ChatSupportCtaKind } from "../chat-support-cta";
export interface ChatSupportButtonProps {
kind: ChatSupportCtaKind;
label: string;
onClick: () => void;
}
export function ChatSupportButton({
kind,
label,
onClick,
}: ChatSupportButtonProps) {
const Icon = kind === "support" ? Heart : Sparkles;
return (
<button
type="button"
data-analytics-key="chat.support_character"
data-support-state={kind}
className="inline-flex min-h-9 max-w-full items-center justify-center gap-1.5 rounded-full border border-[rgba(255,255,255,0.22)] bg-[linear-gradient(135deg,rgba(246,87,160,0.96),rgba(255,119,84,0.94))] px-3 py-1.5 text-center text-[12px] font-extrabold leading-tight text-white shadow-[0_8px_24px_rgba(246,87,160,0.3)] backdrop-blur-md transition active:scale-95"
aria-label={label}
onClick={onClick}
>
<Icon className="shrink-0" size={14} strokeWidth={2.5} aria-hidden="true" />
<span className="whitespace-normal text-balance">{label}</span>
</button>
);
}
+1
View File
@@ -12,6 +12,7 @@ export * from "./chat-insufficient-credits-banner";
export * from "./chat-input-bar";
export * from "./chat-input-text-field";
export * from "./chat-send-button";
export * from "./chat-support-button";
export * from "./chat-unlock-dialogs";
export * from "./date-header";
export * from "./first-recharge-offer-banner";
@@ -0,0 +1,38 @@
"use client";
import { useEffect } from "react";
import { createChatWebSocket } from "@/core/net/chat-websocket";
import type { LoginStatus } from "@/data/schemas/auth";
import { getSessionAccessToken } from "@/lib/auth/auth_session";
import { useChatDispatch } from "@/stores/chat/chat-context";
export function useChatCommercialMessages(input: {
characterId: string;
loginStatus: LoginStatus;
}) {
const chatDispatch = useChatDispatch();
useEffect(() => {
if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") {
return;
}
let disposed = false;
let socket: ReturnType<typeof createChatWebSocket> | null = null;
void getSessionAccessToken().then((token) => {
if (disposed || !token) return;
socket = createChatWebSocket(token);
socket.onCommercialMessage = (message) => {
if (message.characterId !== input.characterId) return;
chatDispatch({ type: "ChatCommercialMessageReceived", message });
};
socket.connect();
});
return () => {
disposed = true;
socket?.disconnect();
};
}, [chatDispatch, input.characterId, input.loginStatus]);
}
@@ -0,0 +1,97 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { shallowEqual } from "@xstate/react";
import type { LoginStatus } from "@/data/schemas/auth";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
usePaymentDispatch,
usePaymentSelector,
} from "@/stores/payment/payment-context";
import { useUserSelector } from "@/stores/user/user-context";
import { deriveChatSupportCta } from "../chat-support-cta";
export function useChatSupportCta(input: {
characterId: string;
historyLoaded: boolean;
loginStatus: LoginStatus;
}) {
const navigator = useAppNavigator();
const paymentDispatch = usePaymentDispatch();
const initializedCharacterRef = useRef<string | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const payment = usePaymentSelector(
(state) => ({
commercialOffer: state.context.commercialOffer,
isFirstRecharge: state.context.isFirstRecharge,
supportCharacterId: state.context.supportCharacterId,
}),
shallowEqual,
);
const countryCode = useUserSelector(
(state) => state.context.currentUser?.countryCode,
);
const isAuthenticated =
input.loginStatus !== "notLoggedIn" && input.loginStatus !== "guest";
useEffect(() => {
if (!isAuthenticated) return;
if (initializedCharacterRef.current === input.characterId) return;
initializedCharacterRef.current = input.characterId;
paymentDispatch({
type: "PaymentInit",
characterId: input.characterId,
payChannel: getDefaultPayChannelForCountryCode(countryCode),
});
}, [countryCode, input.characterId, isAuthenticated, paymentDispatch]);
useEffect(() => {
if (!payment.commercialOffer) return;
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [payment.commercialOffer]);
const cta = useMemo(
() =>
deriveChatSupportCta({
isFirstRecharge:
isAuthenticated && payment.supportCharacterId === input.characterId
? payment.isFirstRecharge
: false,
commercialOffer:
isAuthenticated && payment.supportCharacterId === input.characterId
? payment.commercialOffer
: null,
nowMs,
}),
[
input.characterId,
isAuthenticated,
nowMs,
payment.commercialOffer,
payment.isFirstRecharge,
payment.supportCharacterId,
],
);
const open = () => {
navigator.openSubscription({
type: cta.planId?.startsWith("dol_") ? "topup" : "vip",
returnTo: "chat",
...(cta.planId ? { planId: cta.planId } : {}),
...(cta.commercialOfferId
? { commercialOfferId: cta.commercialOfferId }
: {}),
analytics: {
entryPoint: "chat_offer_banner",
triggerReason:
cta.kind === "support" ? "vip_cta" : "commercial_offer",
},
});
};
return { ...cta, visible: input.historyLoaded, open };
}
@@ -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,25 +146,60 @@ export default function ExternalEntryPersist({
return;
}
hasNavigatedRef.current = true;
router.replace(destination);
router.replace(checkoutDestination ?? destination);
return;
}
if (handoffStartedRef.current) return;
if (!hasValue(handoffToken)) return;
handoffStartedRef.current = true;
void (async () => {
const result = await consumeTopUpHandoff(handoffToken);
if (Result.isErr(result)) {
log.warn("[ExternalEntryPersist] top-up handoff failed", result.error);
if (isCheckoutHandoff) {
const result = await consumeCheckoutHandoff(handoffToken);
if (Result.isErr(result)) {
log.warn(
"[ExternalEntryPersist] checkout handoff failed",
result.error,
);
window.history.replaceState(
window.history.state,
"",
"/external-entry?target=checkout",
);
setHandoffError(
"This checkout link is invalid, expired, or already used. Please return and request a new link.",
);
return;
}
window.history.replaceState(
window.history.state,
"",
"/external-entry?target=checkout",
);
setCheckoutDestination(
resolveCheckoutIntentDestination(result.data),
);
} else {
const result = await consumeTopUpHandoff(handoffToken);
if (Result.isErr(result)) {
log.warn(
"[ExternalEntryPersist] top-up handoff failed",
result.error,
);
window.history.replaceState(
window.history.state,
"",
"/external-entry?target=topup",
);
setHandoffError(
"This top-up link is invalid or has expired. Please request a new link in Messenger.",
);
return;
}
window.history.replaceState(
window.history.state,
"",
"/external-entry?target=topup",
);
setHandoffError(
"This top-up link is invalid or has expired. Please request a new link in Messenger.",
);
return;
}
setHandoffCompleted(true);
authDispatch({ type: "AuthInit" });
@@ -190,9 +233,12 @@ export default function ExternalEntryPersist({
authState.isLoading,
authState.loginStatus,
destination,
checkoutDestination,
hasPersisted,
handoffCompleted,
handoffToken,
isCheckoutHandoff,
isLoginHandoff,
isTopUpHandoff,
psid,
router,
+1
View File
@@ -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 并跳转到最终页面。
@@ -34,14 +34,14 @@ describe("SubscriptionPage", () => {
);
});
it("uses Elio for missing source navigation context", async () => {
it("requires an explicit selection when source navigation has no character", async () => {
const page = await SubscriptionPage({
searchParams: Promise.resolve({}),
});
renderToStaticMarkup(page);
expect(mocks.screenProps).toHaveBeenLastCalledWith(
expect.objectContaining({ sourceCharacterSlug: "elio" }),
expect.objectContaining({ sourceCharacterSlug: null }),
);
});
});
@@ -3,6 +3,16 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const characters = [
{ id: "elio", slug: "elio", displayName: "Elio Silvestri", shortName: "Elio" },
{ id: "maya-tan", slug: "maya", displayName: "Maya Tan", shortName: "Maya" },
{
id: "nayeli-cervantes",
slug: "nayeli",
displayName: "Nayeli Cervantes",
shortName: "Nayeli",
},
];
const payment = {
status: "ready",
plans: [
@@ -52,7 +62,17 @@ const mocks = vi.hoisted(() => {
payment.selectedPlanId = event.planId;
}
});
return { payment, paymentDispatch, planClick: vi.fn() };
return {
characterCatalog: {
characters,
getBySlug: (slug: string | null) =>
characters.find((character) => character.slug === slug) ?? null,
},
payment,
paymentDispatch,
paymentFlowInput: vi.fn(),
planClick: vi.fn(),
};
});
vi.mock("@/app/_components", () => ({
@@ -83,6 +103,10 @@ vi.mock("@/stores/user/user-context", () => ({
useUserState: () => ({ currentUser: { countryCode: "ID" } }),
}));
vi.mock("@/providers/character-catalog-provider", () => ({
useCharacterCatalog: () => mocks.characterCatalog,
}));
vi.mock("@/lib/payment/payment_method", () => ({
getPaymentMethodConfig: () => ({
initialPayChannel: "stripe",
@@ -97,13 +121,16 @@ vi.mock("@/lib/analytics", () => ({
}));
vi.mock("../use-subscription-payment-flow", () => ({
useSubscriptionPaymentFlow: () => ({
payment: mocks.payment,
paymentDispatch: mocks.paymentDispatch,
showPaymentSuccessDialog: false,
handleBackClick: vi.fn(),
handlePaymentSuccessClose: vi.fn(),
}),
useSubscriptionPaymentFlow: (input: unknown) => {
mocks.paymentFlowInput(input);
return {
payment: mocks.payment,
paymentDispatch: mocks.paymentDispatch,
showPaymentSuccessDialog: false,
handleBackClick: vi.fn(),
handlePaymentSuccessClose: vi.fn(),
};
},
}));
vi.mock("../components", () => ({
@@ -179,6 +206,7 @@ describe("SubscriptionScreen payment selection flow", () => {
}
mocks.payment.selectedPlanId = "vip_monthly";
mocks.paymentDispatch.mockClear();
mocks.paymentFlowInput.mockClear();
mocks.planClick.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
@@ -298,6 +326,36 @@ describe("SubscriptionScreen payment selection flow", () => {
"Your first recharge price is already applied",
);
});
it("requires a support character selection when opened without a character", () => {
mocks.payment.selectedPlanId = "coin_1000";
act(() =>
root.render(
<SubscriptionScreen
subscriptionType="topup"
sourceCharacterSlug={null}
/>,
),
);
const checkout = container.querySelector<HTMLButtonElement>(
'[data-testid="checkout"]',
);
expect(container.textContent).toContain("Choose who you want to support");
expect(checkout?.disabled).toBe(true);
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
expect.objectContaining({ sourceCharacterSlug: null }),
);
act(() => clickButton(container, "Maya"));
expect(container.textContent).toContain("Supporting Maya");
expect(checkout?.disabled).toBe(false);
expect(mocks.paymentFlowInput).toHaveBeenLastCalledWith(
expect.objectContaining({ sourceCharacterSlug: "maya" }),
);
});
});
function clickButton(root: ParentNode, label: string): void {
@@ -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"
@@ -61,6 +61,52 @@
box-shadow: 0 12px 30px rgba(22, 101, 52, 0.12);
}
.supportCharacterSelector {
margin-top: var(--page-section-gap, 14px);
padding: 14px 16px;
border: 1px solid rgba(246, 87, 160, 0.2);
border-radius: var(--responsive-card-radius-sm, 22px);
background: rgba(255, 255, 255, 0.9);
color: #4a3340;
}
.supportCharacterSelector h2 {
margin: 0;
color: #24151d;
font-size: var(--responsive-card-title, 17px);
}
.supportCharacterSelector p {
margin: 6px 0 10px;
font-size: var(--responsive-caption, 13px);
line-height: 1.4;
}
.supportCharacterOptions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.supportCharacterOption,
.supportCharacterOptionActive {
border: 1px solid rgba(246, 87, 160, 0.24);
border-radius: 999px;
padding: 7px 12px;
background: #fff;
color: #8e315e;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 800;
}
.supportCharacterOptionActive {
border-color: #f657a0;
background: #f657a0;
color: #fff;
}
.characterSupportBanner {
margin-top: var(--page-section-gap, 14px);
padding: 14px 16px;
+3 -3
View File
@@ -5,12 +5,12 @@ import {
} from "@/lib/analytics/payment_analytics_context";
import {
getFirstPaymentSearchParam,
parsePaymentAutoRenew,
parsePaymentReturnSearchParams,
parseSubscriptionReturnTo,
type PaymentSearchParams,
} from "@/lib/payment/payment_search_params";
import {
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
@@ -30,8 +30,7 @@ export default async function SubscriptionPage({
getFirstPaymentSearchParam(query.type),
);
const sourceCharacterSlug =
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ??
DEFAULT_CHARACTER_SLUG;
getCharacterBySlug(getFirstPaymentSearchParam(query.character))?.slug ?? null;
const initialPlanId = getFirstPaymentSearchParam(query.planId);
const commercialOfferId = getFirstPaymentSearchParam(
query.commercialOfferId,
@@ -57,6 +56,7 @@ export default async function SubscriptionPage({
analyticsContext={analyticsContext}
sourceCharacterSlug={sourceCharacterSlug}
initialPlanId={initialPlanId}
initialAutoRenew={parsePaymentAutoRenew(query.autoRenew)}
commercialOfferId={commercialOfferId}
resumeOrderId={resumeOrderId}
chatActionId={chatActionId}
+48 -9
View File
@@ -9,10 +9,10 @@ import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics
import type { PayChannel } from "@/data/schemas/payment";
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
import {
DEFAULT_CHARACTER,
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
import {
behaviorAnalytics,
getDefaultPaymentAnalyticsContext,
@@ -51,8 +51,9 @@ export interface SubscriptionScreenProps {
returnTo?: SubscriptionReturnTo;
initialPayChannel?: PayChannel | null;
analyticsContext?: PaymentAnalyticsContext;
sourceCharacterSlug?: string;
sourceCharacterSlug?: string | null;
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,
@@ -78,9 +80,15 @@ export function SubscriptionScreen({
const [paymentIssueNotice, setPaymentIssueNotice] = useState<string | null>(
null,
);
const characterCatalog = useCharacterCatalog();
const [selectedSupportCharacterSlug, setSelectedSupportCharacterSlug] =
useState<string | null>(() =>
getCharacterBySlug(sourceCharacterSlug)?.slug ?? null,
);
const userState = useUserState();
const sourceCharacter =
getCharacterBySlug(sourceCharacterSlug) ?? DEFAULT_CHARACTER;
const sourceCharacter = characterCatalog.getBySlug(
selectedSupportCharacterSlug,
);
const hasHydrated = useHasHydrated();
const countryCode = userState.currentUser?.countryCode;
const paymentMethodConfig = getPaymentMethodConfig({
@@ -100,9 +108,10 @@ export function SubscriptionScreen({
subscriptionType,
shouldResumePendingOrder,
returnTo,
sourceCharacterSlug,
sourceCharacterSlug: sourceCharacter?.slug ?? null,
initialPayChannel: paymentMethodConfig.initialPayChannel,
initialPlanId,
initialAutoRenew,
commercialOfferId,
resumeOrderId,
chatActionId,
@@ -160,6 +169,7 @@ export function SubscriptionScreen({
(plan) => plan.id === payment.selectedPlanId,
);
const canActivate =
sourceCharacter !== null &&
selectedPlan !== null &&
canCheckoutSubscriptionPlan({
selectedPlanId: payment.selectedPlanId,
@@ -268,7 +278,36 @@ export function SubscriptionScreen({
</p>
) : null}
{chatActionId ? (
<section className={styles.supportCharacterSelector}>
<h2>
{sourceCharacter
? `Supporting ${sourceCharacter.shortName}`
: "Choose who you want to support"}
</h2>
<p>
Your VIP or credit purchase supports the character you choose, and
they will thank you after the payment and benefits are confirmed.
</p>
<div className={styles.supportCharacterOptions}>
{characterCatalog.characters.map((character) => (
<button
key={character.id}
type="button"
className={
sourceCharacter?.id === character.id
? styles.supportCharacterOptionActive
: styles.supportCharacterOption
}
aria-pressed={sourceCharacter?.id === character.id}
onClick={() => setSelectedSupportCharacterSlug(character.slug)}
>
{character.shortName}
</button>
))}
</div>
</section>
{chatActionId && sourceCharacter ? (
<section
className={styles.characterSupportBanner}
aria-label={`Support ${sourceCharacter.displayName}`}
@@ -284,7 +323,7 @@ export function SubscriptionScreen({
</section>
) : null}
{payment.commercialOffer && !hasFirstRechargeOffer ? (
{payment.commercialOffer && !hasFirstRechargeOffer && sourceCharacter ? (
<section
className={styles.firstRechargeBanner}
aria-label={`${sourceCharacter.shortName} private offer`}
@@ -336,7 +375,7 @@ export function SubscriptionScreen({
disabled={!canActivate}
subscriptionType={subscriptionType}
returnTo={returnTo}
sourceCharacterSlug={sourceCharacterSlug}
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
/>
</div>
@@ -361,7 +400,7 @@ export function SubscriptionScreen({
orderId={payment.currentOrderId}
payChannel={payment.payChannel}
countryCode={countryCode}
characterId={sourceCharacterSlug}
characterId={sourceCharacter?.id ?? ""}
onClose={() => setShowPaymentIssueDialog(false)}
onSubmitted={setPaymentIssueNotice}
/>
@@ -5,7 +5,11 @@ import { useRouter } from "next/navigation";
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
import type { PayChannel } from "@/data/schemas/payment";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
import {
DEFAULT_CHARACTER,
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
import {
consumeSubscriptionExitUrl,
resolveSubscriptionSuccessExitUrl,
@@ -19,8 +23,9 @@ export interface UseSubscriptionPaymentFlowInput {
shouldResumePendingOrder: boolean;
returnTo: SubscriptionReturnTo;
initialPayChannel: PayChannel;
sourceCharacterSlug?: string;
sourceCharacterSlug?: string | null;
initialPlanId?: string | null;
initialAutoRenew?: boolean | null;
commercialOfferId?: string | null;
resumeOrderId?: string | null;
chatActionId?: string | null;
@@ -33,16 +38,21 @@ export function useSubscriptionPaymentFlow({
initialPayChannel,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
initialPlanId = null,
initialAutoRenew = null,
commercialOfferId = null,
resumeOrderId = null,
chatActionId = null,
}: UseSubscriptionPaymentFlowInput) {
const router = useRouter();
const supportCharacter = getCharacterBySlug(sourceCharacterSlug);
const exitCharacterSlug = supportCharacter?.slug ?? DEFAULT_CHARACTER.slug;
const { payment, paymentDispatch } = usePaymentRouteFlow({
initialPayChannel,
paymentType: subscriptionType,
shouldResumePendingOrder,
characterId: supportCharacter?.id,
initialPlanId,
initialAutoRenew,
commercialOfferId,
resumeOrderId,
chatActionId,
@@ -62,7 +72,7 @@ export function useSubscriptionPaymentFlow({
const handleBackClick = () => {
void (async () => {
router.replace(
await consumeSubscriptionExitUrl(returnTo, sourceCharacterSlug),
await consumeSubscriptionExitUrl(returnTo, exitCharacterSlug),
);
})();
};
@@ -74,7 +84,7 @@ export function useSubscriptionPaymentFlow({
router.replace(
await resolveSubscriptionSuccessExitUrl(
returnTo,
sourceCharacterSlug,
exitCharacterSlug,
),
);
})();
@@ -359,6 +359,8 @@ function makePaymentState(
selectedGiftCategory: giftCategory.category,
isFirstRecharge: false,
commercialOffer: null,
commercialOfferId: null,
chatActionId: null,
selectedPlanId: giftPlan.planId,
payChannel: "stripe",
autoRenew: false,
+14
View File
@@ -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}
@@ -45,6 +45,27 @@ describe("ChatWebSocket payment guidance", () => {
expect(onPaymentGuidance).not.toHaveBeenCalled();
});
it("delivers a persisted commercial thank-you as a live role message", () => {
const socket = new ChatWebSocket("ws://example.test/chat", "token");
const onCommercialMessage = vi.fn();
socket.onCommercialMessage = onCommercialMessage;
receive(socket, {
type: "commercial_message",
messageId: "message-1",
message: "Thank you for supporting me.",
characterId: "maya-tan",
createdAt: "2026-07-28T00:00:00.000Z",
});
expect(onCommercialMessage).toHaveBeenCalledWith({
messageId: "message-1",
message: "Thank you for supporting me.",
characterId: "maya-tan",
createdAt: "2026-07-28T00:00:00.000Z",
});
});
});
function receive(socket: ChatWebSocket, payload: unknown): void {
+35
View File
@@ -37,6 +37,13 @@ export interface PaywallStatusPayload {
reason: string | null;
}
export interface CommercialMessagePayload {
messageId: string;
message: string;
characterId: string;
createdAt: string;
}
export class ChatWebSocket {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -52,6 +59,7 @@ export class ChatWebSocket {
onCommercialAction: ((action: CommercialAction) => void) | null = null;
onChatAction: ((action: ChatAction) => void) | null = null;
onPaymentGuidance: ((guidance: PaymentGuidance) => void) | null = null;
onCommercialMessage: ((message: CommercialMessagePayload) => void) | null = null;
onError: ((errorMessage: string) => void) | null = null;
constructor(
@@ -149,6 +157,10 @@ export class ChatWebSocket {
done?: boolean;
audioUrl?: string;
error?: string;
messageId?: string;
message?: string;
characterId?: string;
createdAt?: string;
data?: Record<string, unknown> & {
image?: {
type?: string | null;
@@ -163,6 +175,10 @@ export class ChatWebSocket {
ruleId?: string;
kind?: string;
orderId?: string | null;
messageId?: string;
message?: string;
characterId?: string;
createdAt?: string;
};
};
try {
@@ -217,6 +233,25 @@ export class ChatWebSocket {
if (guidance.success) this.onPaymentGuidance?.(guidance.data);
break;
}
case "commercial_message": {
const commercialMessage = payload.data?.messageId
? payload.data
: payload;
if (
commercialMessage?.messageId &&
commercialMessage.message &&
commercialMessage.characterId &&
commercialMessage.createdAt
) {
this.onCommercialMessage?.({
messageId: commercialMessage.messageId,
message: commercialMessage.message,
characterId: commercialMessage.characterId,
createdAt: commercialMessage.createdAt,
});
}
break;
}
case "error":
this.onError?.(payload.error ?? "Unknown error");
break;
+28
View File
@@ -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>>;
@@ -13,7 +13,10 @@ import type { Result } from "@/utils/result";
export interface IPaymentRepository {
/** 获取套餐列表。 */
getPlans(commercialOfferId?: string): Promise<Result<PaymentPlansResponse>>;
getPlans(
commercialOfferId?: string,
supportCharacterId?: string,
): Promise<Result<PaymentPlansResponse>>;
/** 获取本地缓存套餐列表。 */
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
+11 -3
View File
@@ -24,10 +24,18 @@ export class PaymentRepository implements IPaymentRepository {
constructor(private readonly api: PaymentApi) {}
/** 获取套餐列表。 */
async getPlans(commercialOfferId?: string): Promise<Result<PaymentPlansResponse>> {
async getPlans(
commercialOfferId?: string,
supportCharacterId?: string,
): Promise<Result<PaymentPlansResponse>> {
return Result.wrap(async () => {
const response = await this.api.getPlans(commercialOfferId);
if (!commercialOfferId) await PaymentPlansStorage.setPlans(response);
const response = await this.api.getPlans(
commercialOfferId,
supportCharacterId,
);
if (!commercialOfferId && !supportCharacterId) {
await PaymentPlansStorage.setPlans(response);
}
return response;
});
}
@@ -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 "./checkout_handoff";
export * from "./login_status";
export * from "./request/facebook_identity_request";
export * from "./request/facebook_login_request";
@@ -195,9 +195,11 @@ describe("PaymentPlansResponse", () => {
it("parses one user-bound commercial offer and its discounted plan", () => {
const response = PaymentPlansResponseSchema.parse({
commercialOffer: {
enabled: true,
commercialOfferId: "offer-1",
supportCharacterId: "maya-tan",
commercialOffer: {
enabled: true,
commercialOfferId: "offer-1",
characterId: "maya-tan",
planId: "vip_annual",
discountPercent: 30,
pricePercent: 70,
@@ -225,6 +227,8 @@ describe("PaymentPlansResponse", () => {
});
expect(response.commercialOffer?.commercialOfferId).toBe("offer-1");
expect(response.supportCharacterId).toBe("maya-tan");
expect(response.commercialOffer?.characterId).toBe("maya-tan");
expect(response.plans[0]).toMatchObject({
planId: "vip_annual",
commercialOfferId: "offer-1",
@@ -24,6 +24,7 @@ export const CommercialOfferSummarySchema = z
.object({
enabled: booleanOrFalse,
commercialOfferId: z.string().min(1),
characterId: stringOrEmpty,
planId: z.string().min(1),
discountPercent: numberOrZero,
pricePercent: numberOrZero,
@@ -36,6 +37,7 @@ export const CommercialOfferSummarySchema = z
export const PaymentPlansResponseSchema = z
.object({
isFirstRecharge: booleanOrFalse,
supportCharacterId: stringOrEmpty,
firstRechargeOffer: schemaOrNull(FirstRechargeOfferSchema),
commercialOffer: schemaOrNull(CommercialOfferSummarySchema),
plans: arrayOrEmpty(PaymentPlanSchema),
+2
View File
@@ -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" },
+6
View File
@@ -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;
+32
View File
@@ -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 到当前用户
*/
+12 -2
View File
@@ -26,9 +26,19 @@ import { ApiEnvelope, unwrap } from "./response_helper";
export class PaymentApi {
/** 获取 VIP 与 Top-up 套餐列表。 */
async getPlans(commercialOfferId?: string): Promise<PaymentPlansResponse> {
async getPlans(
commercialOfferId?: string,
supportCharacterId?: string,
): Promise<PaymentPlansResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentPlans, {
...(commercialOfferId ? { query: { commercialOfferId } } : {}),
...(commercialOfferId || supportCharacterId
? {
query: {
...(commercialOfferId ? { commercialOfferId } : {}),
...(supportCharacterId ? { supportCharacterId } : {}),
},
}
: {}),
});
return PaymentPlansResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
@@ -17,6 +17,17 @@ describe("payment analytics context", () => {
entryPoint: "chat_unlock",
triggerReason: "ad_landing",
});
expect(
parsePaymentAnalyticsContext({
entryPoint: "chat_offer_banner",
triggerReason: "commercial_offer",
subscriptionType: "vip",
}),
).toEqual({
entryPoint: "chat_offer_banner",
triggerReason: "commercial_offer",
});
});
it("falls back independently for invalid query values", () => {
@@ -7,6 +7,7 @@ export type PaymentAnalyticsTriggerReason =
| "insufficient_credits"
| "profile_recharge"
| "vip_cta"
| "commercial_offer"
| "ad_landing"
| "manual_recharge"
| "unknown";
@@ -47,6 +48,7 @@ const TRIGGER_REASONS = new Set<PaymentAnalyticsTriggerReason>([
"insufficient_credits",
"profile_recharge",
"vip_cta",
"commercial_offer",
"ad_landing",
"manual_recharge",
"unknown",
+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 {
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");
});
});
describe("external entry navigation", () => {
it("defaults to 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 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({
+21
View File
@@ -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;
+9
View File
@@ -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 {
+2
View File
@@ -20,6 +20,8 @@ export interface OpenSubscriptionInput {
replace?: boolean;
analytics?: PaymentAnalyticsContext;
chatActionId?: string;
planId?: string;
commercialOfferId?: string;
}
export interface StartMessageUnlockInput {
+4
View File
@@ -80,6 +80,8 @@ export function useGlobalAppNavigator(): GlobalAppNavigator {
replace: shouldReplace = false,
analytics = getDefaultPaymentAnalyticsContext(type),
chatActionId,
planId,
commercialOfferId,
}: OpenGlobalSubscriptionInput): void => {
const target = ROUTE_BUILDERS.subscription(type, {
payChannel,
@@ -87,6 +89,8 @@ export function useGlobalAppNavigator(): GlobalAppNavigator {
sourceCharacterSlug,
analytics,
chatActionId,
planId,
commercialOfferId,
});
behaviorAnalytics.rechargeModalOpen(analytics);
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { appendCommercialMessage } from "../helper/commercial-message";
describe("chat commercial message", () => {
const incoming = {
messageId: "payment-thanks-1",
message: "Thank you for supporting me.",
characterId: "maya-tan",
createdAt: "2026-07-28T08:30:00.000Z",
};
it("appends one persisted payment thank-you to the matching character chat", () => {
const messages = appendCommercialMessage([], "maya-tan", incoming);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
remoteId: "payment-thanks-1",
content: "Thank you for supporting me.",
isFromAI: true,
});
});
it("ignores duplicate delivery and a message for another character", () => {
const messages = appendCommercialMessage([], "maya-tan", incoming);
expect(appendCommercialMessage(messages, "maya-tan", incoming)).toEqual(
messages,
);
expect(appendCommercialMessage(messages, "elio", incoming)).toEqual(
messages,
);
});
});
+9
View File
@@ -28,6 +28,15 @@ export type ChatEvent =
}
| { type: "ChatOlderHistoryLoadFailed"; error: unknown }
| { type: "ChatHistoryRefreshRequested" }
| {
type: "ChatCommercialMessageReceived";
message: {
messageId: string;
message: string;
characterId: string;
createdAt: string;
};
}
// Chat domain events.
| { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: string }
@@ -0,0 +1,30 @@
import { createRemoteUiMessageIdentity, type UiMessage } from "../ui-message";
import { todayString } from "@/utils/date";
export interface IncomingCommercialMessage {
messageId: string;
message: string;
characterId: string;
createdAt: string;
}
export function appendCommercialMessage(
messages: readonly UiMessage[],
activeCharacterId: string,
incoming: IncomingCommercialMessage,
): UiMessage[] {
if (incoming.characterId !== activeCharacterId) return [...messages];
if (messages.some((message) => message.remoteId === incoming.messageId)) {
return [...messages];
}
const createdAt = new Date(incoming.createdAt);
return [
...messages,
{
...createRemoteUiMessageIdentity(incoming.messageId, true),
content: incoming.message,
isFromAI: true,
date: todayString(Number.isNaN(createdAt.getTime()) ? new Date() : createdAt),
},
];
}
+16
View File
@@ -1,4 +1,5 @@
import { createChatPromotionState } from "../helper/promotion";
import { appendCommercialMessage } from "../helper/commercial-message";
import { shouldPromptUnlockHistory } from "../helper/unlock";
import { createInitialChatState } from "../chat-state";
import {
@@ -53,6 +54,19 @@ const injectPromotionAction = unlockMachineSetup.assign(({ event }) => {
const clearPromotionAction = unlockMachineSetup.assign({ promotion: null });
const appendCommercialMessageAction = unlockMachineSetup.assign(
({ context, event }) => {
if (event.type !== "ChatCommercialMessageReceived") return {};
return {
messages: appendCommercialMessage(
context.messages,
context.characterId,
event.message,
),
};
},
);
export const chatMachineSetup = unlockMachineSetup.extend({
actions: {
startGuestSession: startGuestSessionAction,
@@ -60,6 +74,7 @@ export const chatMachineSetup = unlockMachineSetup.extend({
clearChatSession: clearChatSessionAction,
injectPromotion: injectPromotionAction,
clearPromotion: clearPromotionAction,
appendCommercialMessage: appendCommercialMessageAction,
},
});
@@ -258,6 +273,7 @@ export const chatRootStateConfig = chatMachineSetup.createStateConfig({
on: {
ChatPromotionInjected: { actions: "injectPromotion" },
ChatPromotionCleared: { actions: "clearPromotion" },
ChatCommercialMessageReceived: { actions: "appendCommercialMessage" },
},
states: {
idle: idleState,
@@ -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();
});
@@ -105,6 +105,26 @@ describe("payment order flow", () => {
actor.stop();
});
it("binds a default VIP or credit order to the selected support character", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
createTestPaymentMachine({ createOrderSpy }),
).start();
actor.send({ type: "PaymentInit", characterId: "maya-tan" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
expect(createOrderSpy).toHaveBeenCalledWith({
planId: "vip_monthly",
payChannel: "stripe",
autoRenew: true,
recipientCharacterId: "maya-tan",
});
actor.stop();
});
it("carries the originating chat action into order creation", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
+9 -1
View File
@@ -136,12 +136,20 @@ export function consumeFirstRechargeState(
context: PaymentState,
): Pick<
PaymentState,
"plans" | "isFirstRecharge" | "selectedPlanId" | "autoRenew" | "errorMessage"
| "plans"
| "isFirstRecharge"
| "commercialOffer"
| "commercialOfferId"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
> {
const plans = context.plans.map(consumeFirstRechargePlan);
return {
...refreshPlansState(plans, context.selectedPlanId),
isFirstRecharge: false,
commercialOffer: null,
commercialOfferId: null,
};
}
+12 -4
View File
@@ -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 ?? "");
+7 -4
View File
@@ -8,9 +8,9 @@ import type { PaymentPlanCatalog } from "../../payment-state";
export const loadCachedPaymentPlansActor = fromPromise<
PaymentPlansResponse | null,
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null }
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null; supportCharacterId?: string | null }
>(async ({ input }) => {
if (input.catalog === "tip" || input.commercialOfferId) return null;
if (input.catalog === "tip" || input.commercialOfferId || input.supportCharacterId) return null;
const result = await getPaymentRepository().getCachedPlans();
if (Result.isErr(result)) throw result.error;
return result.data;
@@ -18,13 +18,16 @@ export const loadCachedPaymentPlansActor = fromPromise<
export const refreshPaymentPlansActor = fromPromise<
PaymentPlansResponse,
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null }
{ catalog: PaymentPlanCatalog; commercialOfferId?: string | null; supportCharacterId?: string | null }
>(async ({ input }) => {
const paymentRepo = getPaymentRepository();
if (input.catalog === "tip") {
throw new Error("Gift catalogs must use the gift products actor.");
}
const result = await paymentRepo.getPlans(input.commercialOfferId ?? undefined);
const result = await paymentRepo.getPlans(
input.commercialOfferId ?? undefined,
input.supportCharacterId ?? undefined,
);
if (Result.isErr(result)) throw result.error;
return result.data;
});
+28 -9
View File
@@ -29,29 +29,45 @@ function initializeCatalogState(
planCatalog === "tip"
? (event.characterId ?? context.giftCharacterId)
: null;
const supportCharacterId =
planCatalog === "default"
? (event.characterId ?? context.supportCharacterId)
: null;
const catalogChanged = planCatalog !== context.planCatalog;
const characterChanged = giftCharacterId !== context.giftCharacterId;
const characterChanged =
giftCharacterId !== context.giftCharacterId ||
supportCharacterId !== context.supportCharacterId;
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,
...(event.payChannel ? { payChannel: event.payChannel } : {}),
giftCharacterId,
supportCharacterId,
commercialOfferId,
chatActionId,
requestedGiftCategory:
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 +78,7 @@ function initializeCatalogState(
planCatalog === "default" ? (event.planId ?? "") : "",
isFirstRecharge: false,
commercialOffer: null,
autoRenew: planCatalog !== "tip",
autoRenew: initialAutoRenew,
}
: {}),
};
@@ -219,6 +235,7 @@ export const loadingCachedPlansState =
input: ({ context }) => ({
catalog: context.planCatalog,
commercialOfferId: context.commercialOfferId,
supportCharacterId: context.supportCharacterId,
}),
onDone: [
{
@@ -235,9 +252,10 @@ export const loadingCachedPlansState =
export const loadingPlansState = catalogMachineSetup.createStateConfig({
invoke: {
src: "refreshPlans",
input: ({ context }) => ({
catalog: context.planCatalog,
commercialOfferId: context.commercialOfferId,
input: ({ context }) => ({
catalog: context.planCatalog,
commercialOfferId: context.commercialOfferId,
supportCharacterId: context.supportCharacterId,
}),
onDone: {
target: "ready",
@@ -256,6 +274,7 @@ export const refreshingPlansState = catalogMachineSetup.createStateConfig({
input: ({ context }) => ({
catalog: context.planCatalog,
commercialOfferId: context.commercialOfferId,
supportCharacterId: context.supportCharacterId,
}),
onDone: {
target: "ready",
+8 -2
View File
@@ -192,8 +192,14 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({
payChannel: context.payChannel,
autoRenew: context.autoRenew,
...(event.type === "PaymentCreateOrderSubmitted" &&
event.recipientCharacterId
? { recipientCharacterId: event.recipientCharacterId }
(event.recipientCharacterId || context.supportCharacterId || context.giftCharacterId)
? {
recipientCharacterId:
event.recipientCharacterId ||
context.supportCharacterId ||
context.giftCharacterId ||
undefined,
}
: {}),
...(context.commercialOfferId
? { commercialOfferId: context.commercialOfferId }
+4
View File
@@ -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,
+1
View File
@@ -10,6 +10,7 @@ export type PaymentEvent =
characterId?: string;
category?: string | null;
planId?: string | null;
autoRenew?: boolean;
commercialOfferId?: string | null;
chatActionId?: string | null;
}
+2
View File
@@ -17,6 +17,7 @@ export interface PaymentState {
giftCategories: readonly GiftCategory[];
giftProducts: readonly GiftProduct[];
giftCharacterId: string | null;
supportCharacterId: string | null;
selectedGiftCategory: string | null;
requestedGiftCategory: string | null;
requestedGiftPlanId: string | null;
@@ -45,6 +46,7 @@ export const initialState: PaymentState = {
giftCategories: [],
giftProducts: [],
giftCharacterId: null,
supportCharacterId: null,
selectedGiftCategory: null,
requestedGiftCategory: null,
requestedGiftPlanId: 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();
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,10 +334,12 @@ export class Logger {
for (const key of Object.getOwnPropertyNames(error)) {
if (key in output) continue;
output[key] = Logger.toSerializableLogValue(
(error as unknown as Record<string, unknown>)[key],
seen,
);
output[key] = Logger.isSensitiveLogKey(key)
? "[REDACTED]"
: Logger.toSerializableLogValue(
(error as unknown as Record<string, unknown>)[key],
seen,
);
}
return output;
}