feat(payment): confirm renewal only at checkout

This commit is contained in:
Codex
2026-07-29 11:26:42 +08:00
parent 196f9e39dc
commit 47c5f4098f
15 changed files with 492 additions and 240 deletions
@@ -51,6 +51,7 @@ const mocks = vi.hoisted(() => {
commercialOffer: null,
selectedPlanId: "vip_monthly",
payChannel: "stripe" as const,
autoRenew: true,
agreed: true,
currentOrderId: null,
isCreatingOrder: false,
@@ -100,7 +101,9 @@ vi.mock("@/hooks/use-has-hydrated", () => ({
}));
vi.mock("@/stores/user/user-context", () => ({
useUserState: () => ({ currentUser: { countryCode: "ID" } }),
useUserState: () => ({
currentUser: { id: "user-renewal-1", countryCode: "ID" },
}),
}));
vi.mock("@/providers/character-catalog-provider", () => ({
@@ -164,26 +167,25 @@ vi.mock("../components", () => ({
))}
</div>
),
SubscriptionCheckoutButton: ({ disabled }: { disabled: boolean }) => (
<button type="button" data-testid="checkout" disabled={disabled}>
SubscriptionCheckoutButton: ({
disabled,
renewalPlan,
renewalConsentSubjectId,
}: {
disabled: boolean;
renewalPlan: { id: string } | null;
renewalConsentSubjectId: string | null;
}) => (
<button
type="button"
data-testid="checkout"
data-renewal-plan={renewalPlan?.id ?? ""}
data-renewal-subject={renewalConsentSubjectId ?? ""}
disabled={disabled}
>
Pay and Top Up
</button>
),
SubscriptionRenewalConfirmationDialog: ({
open,
onCancel,
onConfirm,
}: {
open: boolean;
onCancel: () => void;
onConfirm: () => void;
}) =>
open ? (
<div role="dialog">
<button type="button" onClick={onCancel}>Cancel</button>
<button type="button" onClick={onConfirm}>Confirm</button>
</div>
) : null,
SubscriptionPaymentIssueDialog: () => null,
SubscriptionPaymentSuccessDialog: () => null,
}));
@@ -218,66 +220,30 @@ describe("SubscriptionScreen payment selection flow", () => {
container.remove();
});
it("confirms VIP selection before enabling the separate checkout button", () => {
it("allows immediate checkout and changes VIP plans without opening renewal confirmation", () => {
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
const checkout = container.querySelector<HTMLButtonElement>(
'[data-testid="checkout"]',
);
expect(checkout?.disabled).toBe(true);
expect(checkout?.disabled).toBe(false);
expect(checkout?.dataset.renewalPlan).toBe("vip_monthly");
expect(checkout?.dataset.renewalSubject).toBe("user-renewal-1");
act(() => clickButton(container, "vip_monthly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
);
act(() => clickButton(container, "Confirm"));
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
type: "PaymentPlanSelected",
planId: "vip_monthly",
});
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
);
expect(checkout?.disabled).toBe(false);
act(() => clickButton(container, "vip_monthly"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => clickButton(container, "vip_quarterly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(mocks.payment.selectedPlanId).toBe("vip_monthly");
});
it("keeps the original selection when VIP confirmation is cancelled", () => {
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
act(() => clickButton(container, "vip_quarterly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
act(() => clickButton(container, "Cancel"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.payment.selectedPlanId).toBe("vip_monthly");
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith({
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
type: "PaymentPlanSelected",
planId: "vip_quarterly",
});
});
it("requires VIP confirmation again after the page is remounted", () => {
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
act(() => clickButton(container, "vip_monthly"));
act(() => clickButton(container, "Confirm"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => root.unmount());
root = createRoot(container);
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
act(() => clickButton(container, "vip_monthly"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
});
it("selects a coin package without showing the renewal dialog", () => {
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
act(() => clickButton(container, "coin_1000"));
@@ -7,11 +7,9 @@ import {
} from "@/data/schemas/payment";
import {
canCheckoutSubscriptionPlan,
findSelectedSubscriptionPlan,
getDefaultSubscriptionPlanId,
getFirstRechargeOfferView,
requiresVipPlanConfirmation,
toCoinsOfferPlanViews,
toVipOfferPlanViews,
} from "../subscription-screen.helpers";
@@ -280,85 +278,4 @@ describe("subscription screen helpers", () => {
).toBeNull();
});
it("requires confirmation for each unconfirmed VIP plan", () => {
const vipPlans = [
{
id: "vip_monthly",
title: "Monthly",
price: "19.90",
currency: "usd",
originalPrice: "",
},
{
id: "vip_quarterly",
title: "Quarterly",
price: "49.90",
currency: "usd",
originalPrice: "",
},
];
expect(
requiresVipPlanConfirmation({
planId: "vip_monthly",
vipPlans,
confirmedVipPlanIds: new Set(),
}),
).toBe(true);
expect(
requiresVipPlanConfirmation({
planId: "vip_monthly",
vipPlans,
confirmedVipPlanIds: new Set(["vip_monthly"]),
}),
).toBe(false);
expect(
requiresVipPlanConfirmation({
planId: "coin_1000",
vipPlans,
confirmedVipPlanIds: new Set(),
}),
).toBe(false);
});
it("allows checkout only after the selected VIP plan is confirmed", () => {
const vipPlans = [
{
id: "vip_monthly",
title: "Monthly",
price: "19.90",
currency: "usd",
originalPrice: "",
},
];
expect(
canCheckoutSubscriptionPlan({
selectedPlanId: "vip_monthly",
vipPlans,
confirmedVipPlanIds: new Set(),
}),
).toBe(false);
expect(
canCheckoutSubscriptionPlan({
selectedPlanId: "vip_monthly",
vipPlans,
confirmedVipPlanIds: new Set(["vip_monthly"]),
}),
).toBe(true);
expect(
canCheckoutSubscriptionPlan({
selectedPlanId: "coin_1000",
vipPlans,
confirmedVipPlanIds: new Set(),
}),
).toBe(true);
expect(
canCheckoutSubscriptionPlan({
selectedPlanId: "",
vipPlans,
confirmedVipPlanIds: new Set(),
}),
).toBe(false);
});
});
@@ -0,0 +1,208 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
dispatch: vi.fn(),
resetLaunchState: vi.fn(),
payment: {
selectedPlanId: "vip_monthly",
autoRenew: true,
payChannel: "stripe" as "stripe" | "ezpay",
commercialOfferId: null,
chatActionId: null,
currentOrderId: null,
errorMessage: null,
isCreatingOrder: false,
isPollingOrder: false,
},
}));
vi.mock("@/stores/payment/payment-context", () => ({
usePaymentState: () => mocks.payment,
usePaymentDispatch: () => mocks.dispatch,
}));
vi.mock("@/app/_hooks/use-payment-launch-flow", () => ({
usePaymentLaunchFlow: () => ({
resetLaunchState: mocks.resetLaunchState,
launch: {},
}),
}));
vi.mock("@/app/_components/payment/payment-launch-dialogs", () => ({
PaymentLaunchDialogs: () => null,
}));
vi.mock("@/app/_components/payment/external-browser-checkout-button", () => ({
ExternalBrowserCheckoutButton: () => null,
}));
vi.mock("../subscription-cta-button", () => ({
SubscriptionCtaButton: ({
children,
disabled,
onClick,
}: {
children: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
}) => (
<button type="button" disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock("../subscription-renewal-confirmation-dialog", () => ({
SubscriptionRenewalConfirmationDialog: ({
open,
onCancel,
onConfirm,
}: {
open: boolean;
onCancel: () => void;
onConfirm: () => void;
}) =>
open ? (
<div role="dialog">
<button type="button" onClick={onCancel}>Cancel</button>
<button type="button" onClick={onConfirm}>Confirm</button>
</div>
) : null,
}));
import { SubscriptionCheckoutButton } from "../subscription-checkout-button";
const renewalPlan = {
id: "vip_monthly",
title: "Monthly",
price: "19.90",
currency: "usd",
originalPrice: "39.90",
};
describe("SubscriptionCheckoutButton renewal confirmation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
localStorage.clear();
mocks.dispatch.mockClear();
mocks.resetLaunchState.mockClear();
mocks.payment.selectedPlanId = "vip_monthly";
mocks.payment.autoRenew = true;
mocks.payment.payChannel = "stripe";
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("shows confirmation on the first auto-renewing VIP checkout and resumes after confirm", () => {
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(mocks.dispatch).not.toHaveBeenCalled();
act(() => clickButton(container, "Confirm"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.resetLaunchState).toHaveBeenCalledOnce();
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "PaymentCreateOrderSubmitted",
});
});
it("does not ask the same user again after confirmation", () => {
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
act(() => clickButton(container, "Confirm"));
mocks.dispatch.mockClear();
mocks.resetLaunchState.mockClear();
act(() => root.unmount());
root = createRoot(container);
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.resetLaunchState).toHaveBeenCalledOnce();
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "PaymentCreateOrderSubmitted",
});
});
it("keeps asking after cancel and isolates acknowledgement by user", () => {
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
act(() => clickButton(container, "Cancel"));
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
act(() => clickButton(container, "Confirm"));
mocks.dispatch.mockClear();
act(() => root.unmount());
root = createRoot(container);
renderButton(root, "user-2");
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(mocks.dispatch).not.toHaveBeenCalled();
});
it("skips confirmation for a non-renewing purchase", () => {
mocks.payment.autoRenew = false;
renderButton(root, "user-1", null);
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "PaymentCreateOrderSubmitted",
});
});
it("skips confirmation for one-time EzPay membership checkout", () => {
mocks.payment.payChannel = "ezpay";
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "PaymentCreateOrderSubmitted",
});
});
});
function renderButton(
root: Root,
renewalConsentSubjectId: string,
plan: typeof renewalPlan | null = renewalPlan,
): void {
act(() =>
root.render(
<SubscriptionCheckoutButton
subscriptionType="vip"
sourceCharacterSlug="elio"
renewalPlan={plan}
renewalConsentSubjectId={renewalConsentSubjectId}
/>,
),
);
}
function clickButton(root: ParentNode, label: string): void {
const button = Array.from(root.querySelectorAll("button")).find(
(item) => item.textContent?.trim() === label,
);
if (!button) throw new Error(`Expected ${label} button`);
button.click();
}
@@ -71,7 +71,14 @@ describe("subscription payment dialogs", () => {
expect(dialog?.textContent).toContain("Automatic Renewal Confirmation");
expect(dialog?.textContent).toContain("Monthly");
expect(dialog?.textContent).toContain("19.90 usd");
expect(dialog?.querySelectorAll("a")).toHaveLength(2);
expect(
Array.from(dialog?.querySelectorAll("a") ?? []).map((link) =>
link.getAttribute("href"),
),
).toEqual([
"/legal/vip-membership-benefits.html",
"/legal/automatic-renewal.html",
]);
act(() => clickButton(dialog, "Confirm"));
expect(onConfirm).toHaveBeenCalledOnce();
@@ -4,11 +4,17 @@
*
* 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。
*/
import { useState } from "react";
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 {
hasAutomaticRenewalAcknowledgement,
rememberAutomaticRenewalAcknowledgement,
} from "@/lib/payment/automatic_renewal_acknowledgement";
import {
usePaymentDispatch,
usePaymentState,
@@ -16,15 +22,19 @@ import {
import { Logger } from "@/utils/logger";
import { SubscriptionCtaButton } from "./subscription-cta-button";
import { SubscriptionRenewalConfirmationDialog } from "./subscription-renewal-confirmation-dialog";
import type { VipOfferPlanView } from "./subscription-vip-offer-section";
const log = new Logger("SubscriptionCheckoutButton");
export interface SubscriptionCheckoutButtonProps {
/** 是否可用(未选套餐或 VIP 套餐尚未确认自动续费时为 false */
/** 是否可用(未选套餐、缺少角色来源或支付处理中为 false */
disabled?: boolean;
subscriptionType: "vip" | "topup";
returnTo?: SubscriptionReturnTo;
sourceCharacterSlug?: string;
renewalPlan?: VipOfferPlanView | null;
renewalConsentSubjectId?: string | null;
}
export function SubscriptionCheckoutButton({
@@ -32,7 +42,11 @@ export function SubscriptionCheckoutButton({
subscriptionType,
returnTo = null,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
renewalPlan = null,
renewalConsentSubjectId = null,
}: SubscriptionCheckoutButtonProps) {
const [showRenewalConfirmation, setShowRenewalConfirmation] =
useState(false);
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const paymentLaunch = usePaymentLaunchFlow({
@@ -53,12 +67,32 @@ export function SubscriptionCheckoutButton({
? "Creating order..."
: readyLabel;
const handleClick = () => {
const startCheckout = () => {
if (disabled || isLoading) return;
paymentLaunch.resetLaunchState();
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
};
const handleClick = () => {
if (disabled || isLoading) return;
if (
payment.payChannel === "stripe" &&
payment.autoRenew &&
renewalPlan !== null &&
!hasAutomaticRenewalAcknowledgement(renewalConsentSubjectId)
) {
setShowRenewalConfirmation(true);
return;
}
startCheckout();
};
const handleRenewalConfirm = () => {
rememberAutomaticRenewalAcknowledgement(renewalConsentSubjectId);
setShowRenewalConfirmation(false);
startCheckout();
};
return (
<>
<SubscriptionCtaButton
@@ -105,6 +139,12 @@ export function SubscriptionCheckoutButton({
ezpayDescription="Your order has been created. Continue to the secure payment page to finish."
launch={paymentLaunch}
/>
<SubscriptionRenewalConfirmationDialog
open={showRenewalConfirmation}
plan={renewalPlan}
onCancel={() => setShowRenewalConfirmation(false)}
onConfirm={handleRenewalConfirm}
/>
</>
);
}
@@ -105,30 +105,6 @@ export function findSelectedSubscriptionPlan(input: {
return plans.find((plan) => plan.id === input.selectedPlanId) ?? null;
}
export function requiresVipPlanConfirmation(input: {
planId: string;
vipPlans: readonly VipOfferPlanView[];
confirmedVipPlanIds: ReadonlySet<string>;
}): boolean {
return (
input.vipPlans.some((plan) => plan.id === input.planId) &&
!input.confirmedVipPlanIds.has(input.planId)
);
}
export function canCheckoutSubscriptionPlan(input: {
selectedPlanId: string;
vipPlans: readonly VipOfferPlanView[];
confirmedVipPlanIds: ReadonlySet<string>;
}): boolean {
if (!input.selectedPlanId) return false;
return !requiresVipPlanConfirmation({
planId: input.selectedPlanId,
vipPlans: input.vipPlans,
confirmedVipPlanIds: input.confirmedVipPlanIds,
});
}
export function getDefaultSubscriptionPlanId(input: {
canSubscribeVip: boolean;
selectedPlanId: string;
+5 -46
View File
@@ -24,16 +24,13 @@ import {
SubscriptionCoinsOfferSection,
SubscriptionPaymentIssueDialog,
SubscriptionPaymentSuccessDialog,
SubscriptionRenewalConfirmationDialog,
SubscriptionVipOfferSection,
} from "./components";
import styles from "./components/subscription-screen.module.css";
import {
canCheckoutSubscriptionPlan,
findSelectedSubscriptionPlan,
getFirstRechargeOfferView,
getDefaultSubscriptionPlanId,
requiresVipPlanConfirmation,
toCoinsOfferPlanViews,
toVipOfferPlanViews,
type SubscriptionType,
@@ -69,10 +66,6 @@ export function SubscriptionScreen({
resumeOrderId = null,
chatActionId = null,
}: SubscriptionScreenProps) {
const [confirmedVipPlanIds, setConfirmedVipPlanIds] = useState<
ReadonlySet<string>
>(() => new Set());
const [pendingVipPlanId, setPendingVipPlanId] = useState<string | null>(null);
const [showPaymentIssueDialog, setShowPaymentIssueDialog] = useState(false);
const [paymentIssueNotice, setPaymentIssueNotice] = useState<string | null>(
null,
@@ -156,49 +149,20 @@ export function SubscriptionScreen({
const isPaymentBusy =
payment.isCreatingOrder ||
payment.isPollingOrder;
const selectedPlanIsVip = vipOfferPlans.some(
(plan) => plan.id === payment.selectedPlanId,
);
const selectedVipPlan =
vipOfferPlans.find((plan) => plan.id === payment.selectedPlanId) ?? null;
const selectedPlanIsVip = selectedVipPlan !== null;
const canActivate =
sourceCharacter !== null &&
selectedPlan !== null &&
canCheckoutSubscriptionPlan({
selectedPlanId: payment.selectedPlanId,
vipPlans: vipOfferPlans,
confirmedVipPlanIds,
}) &&
!isPaymentBusy;
const pendingVipPlan =
vipOfferPlans.find((plan) => plan.id === pendingVipPlanId) ?? null;
const handleSelectPlan = (planId: string) => {
const plan = payment.plans.find((item) => item.planId === planId);
if (plan) behaviorAnalytics.planClick(plan, analyticsContext);
if (
requiresVipPlanConfirmation({
planId,
vipPlans: vipOfferPlans,
confirmedVipPlanIds,
})
) {
setPendingVipPlanId(planId);
return;
}
paymentDispatch({ type: "PaymentPlanSelected", planId });
};
const handleConfirmVipPlan = () => {
if (!pendingVipPlanId) return;
const planId = pendingVipPlanId;
setConfirmedVipPlanIds((current) => {
const next = new Set(current);
next.add(planId);
return next;
});
paymentDispatch({ type: "PaymentPlanSelected", planId });
setPendingVipPlanId(null);
};
const handlePaymentMethodChange = (payChannel: PayChannel) => {
paymentDispatch({
type: "PaymentPayChannelChanged",
@@ -338,16 +302,11 @@ export function SubscriptionScreen({
subscriptionType={subscriptionType}
returnTo={returnTo}
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
renewalPlan={selectedVipPlan}
renewalConsentSubjectId={userState.currentUser?.id || null}
/>
</div>
<SubscriptionRenewalConfirmationDialog
open={pendingVipPlan !== null}
plan={pendingVipPlan}
onCancel={() => setPendingVipPlanId(null)}
onConfirm={handleConfirmVipPlan}
/>
{showPaymentIssueDialog ? (
<SubscriptionPaymentIssueDialog
open
+2 -2
View File
@@ -13,11 +13,11 @@ export class AppConstants {
/** 会员服务协议文档链接 */
static readonly membershipAgreementUrl =
"https://www.banlv-ai.com/cozsweet/membership-agreement.html";
"/legal/vip-membership-benefits.html";
/** 自动续费服务协议文档链接 */
static readonly autoRenewalAgreementUrl =
"https://www.banlv-ai.com/cozsweet/auto-renewal-agreement.html";
"/legal/automatic-renewal.html";
/** 开发环境 APP 标题 */
static readonly appTitleDevelopment = "Develop";
@@ -0,0 +1,33 @@
export const AUTOMATIC_RENEWAL_AGREEMENT_VERSION = "2026-07-29";
const STORAGE_PREFIX = "cozsweet.automatic-renewal-acknowledged";
function storageKey(subjectId: string | null | undefined): string | null {
const normalized = subjectId?.trim();
if (!normalized) return null;
return `${STORAGE_PREFIX}:${AUTOMATIC_RENEWAL_AGREEMENT_VERSION}:${encodeURIComponent(normalized)}`;
}
export function hasAutomaticRenewalAcknowledgement(
subjectId: string | null | undefined,
): boolean {
const key = storageKey(subjectId);
if (!key || typeof window === "undefined") return false;
try {
return window.localStorage.getItem(key) === "confirmed";
} catch {
return false;
}
}
export function rememberAutomaticRenewalAcknowledgement(
subjectId: string | null | undefined,
): void {
const key = storageKey(subjectId);
if (!key || typeof window === "undefined") return;
try {
window.localStorage.setItem(key, "confirmed");
} catch {
// Storage can be unavailable in privacy-restricted in-app browsers.
}
}