From 47c5f4098ff594c3d435c3c2722242c9b9db051b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Jul 2026 11:26:42 +0800 Subject: [PATCH] feat(payment): confirm renewal only at checkout --- e2e/fixtures/helpers/payment.ts | 9 +- .../checkout-handoff-external-browser.spec.ts | 13 +- e2e/specs/mock/payment/indonesia-qris.spec.ts | 16 +- .../mock/payment/legal-agreements.spec.ts | 23 ++ public/legal/automatic-renewal.html | 65 ++++++ public/legal/vip-membership-benefits.html | 66 ++++++ .../subscription-screen-flow.test.tsx | 84 +++---- .../subscription-screen.helpers.test.ts | 83 ------- .../subscription-checkout-button.test.tsx | 208 ++++++++++++++++++ .../subscription-payment-dialogs.test.tsx | 9 +- .../subscription-checkout-button.tsx | 44 +++- .../subscription-screen.helpers.ts | 24 -- src/app/subscription/subscription-screen.tsx | 51 +---- src/core/app_constants.ts | 4 +- .../automatic_renewal_acknowledgement.ts | 33 +++ 15 files changed, 492 insertions(+), 240 deletions(-) create mode 100644 e2e/specs/mock/payment/legal-agreements.spec.ts create mode 100644 public/legal/automatic-renewal.html create mode 100644 public/legal/vip-membership-benefits.html create mode 100644 src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx create mode 100644 src/lib/payment/automatic_renewal_acknowledgement.ts diff --git a/e2e/fixtures/helpers/payment.ts b/e2e/fixtures/helpers/payment.ts index 83d327fa..6b50aeb0 100644 --- a/e2e/fixtures/helpers/payment.ts +++ b/e2e/fixtures/helpers/payment.ts @@ -2,18 +2,17 @@ import { expect, type Page } from "@playwright/test"; export async function completeVipPayment(page: Page) { const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" }); - await expect(checkoutButton).toBeDisabled(); - await page.getByRole("button", { name: /Monthly,/i }).click(); + await expect(checkoutButton).toBeEnabled(); + + await checkoutButton.click(); const renewalDialog = page.getByRole("dialog", { name: "Automatic Renewal Confirmation", }); await expect(renewalDialog).toBeVisible(); - await renewalDialog.getByRole("button", { name: "Confirm" }).click(); - await expect(checkoutButton).toBeEnabled(); const createOrderRequestPromise = page.waitForRequest("**/api/payment/create-order"); const orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**"); - await checkoutButton.click(); + await renewalDialog.getByRole("button", { name: "Confirm" }).click(); const createOrderRequest = await createOrderRequestPromise; expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" }); await orderStatusRequestPromise; diff --git a/e2e/specs/mock/payment/checkout-handoff-external-browser.spec.ts b/e2e/specs/mock/payment/checkout-handoff-external-browser.spec.ts index 8098251f..9fab3307 100644 --- a/e2e/specs/mock/payment/checkout-handoff-external-browser.spec.ts +++ b/e2e/specs/mock/payment/checkout-handoff-external-browser.spec.ts @@ -74,16 +74,17 @@ test("offers the Facebook external-browser path before creating a Stripe order", }); await seedEmailSession(page); - await page.goto("/subscription?type=vip&payChannel=stripe"); + await page.goto( + "/subscription?type=vip&payChannel=stripe&character=elio", + ); 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(); + await expect(externalButton).toBeEnabled(); + await expect( + page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }), + ).toHaveCount(0); const handoffRequest = page.waitForRequest("**/api/auth/handoff/checkout"); await externalButton.click(); expect((await handoffRequest).postDataJSON()).toMatchObject({ diff --git a/e2e/specs/mock/payment/indonesia-qris.spec.ts b/e2e/specs/mock/payment/indonesia-qris.spec.ts index 479ec104..a25a364e 100644 --- a/e2e/specs/mock/payment/indonesia-qris.spec.ts +++ b/e2e/specs/mock/payment/indonesia-qris.spec.ts @@ -223,19 +223,11 @@ test("Indonesia VIP defaults to QRIS and completes after status polling", async "true", ); const checkoutButton = page.getByRole("button", { name: "Pay and Top Up" }); - await expect(checkoutButton).toBeDisabled(); - await expectCheckoutButtonLayout(page); - - await page.getByRole("button", { name: /Monthly, 195900 IDR/i }).click(); - const renewalDialog = page.getByRole("dialog", { - name: "Automatic Renewal Confirmation", - }); - await expect(renewalDialog).toBeVisible(); - expect(payment.getCreateOrderCount()).toBe(0); - - await renewalDialog.getByRole("button", { name: "Confirm" }).click(); - await expect(renewalDialog).toBeHidden(); await expect(checkoutButton).toBeEnabled(); + await expectCheckoutButtonLayout(page); + await expect( + page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }), + ).toHaveCount(0); expect(payment.getCreateOrderCount()).toBe(0); const orderId = await expectQrisOrder( diff --git a/e2e/specs/mock/payment/legal-agreements.spec.ts b/e2e/specs/mock/payment/legal-agreements.spec.ts new file mode 100644 index 00000000..929e1c0c --- /dev/null +++ b/e2e/specs/mock/payment/legal-agreements.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from "@playwright/test"; + +test("serves the VIP membership benefits agreement from the same site", async ({ + page, +}) => { + const response = await page.goto("/legal/vip-membership-benefits.html"); + + expect(response?.status()).toBe(200); + await expect( + page.getByRole("heading", { name: "VIP Membership Benefits Agreement" }), + ).toBeVisible(); +}); + +test("serves the automatic renewal agreement from the same site", async ({ + page, +}) => { + const response = await page.goto("/legal/automatic-renewal.html"); + + expect(response?.status()).toBe(200); + await expect( + page.getByRole("heading", { name: "Automatic Renewal Agreement" }), + ).toBeVisible(); +}); diff --git a/public/legal/automatic-renewal.html b/public/legal/automatic-renewal.html new file mode 100644 index 00000000..c2e55a0d --- /dev/null +++ b/public/legal/automatic-renewal.html @@ -0,0 +1,65 @@ + + + + + + + Automatic Renewal Agreement | Cozsweet + + + +
+

Automatic Renewal Agreement

+

Effective date: July 29, 2026

+ +

+ By selecting Confirm before payment, you authorize Cozsweet and its payment provider to renew the selected membership automatically under the terms shown below. +

+ +

1. When renewal applies

+

+ Automatic renewal applies only when the selected membership is identified as auto-renewing in the purchase flow. One-time credit purchases, tips, gifts, lifetime plans, and payment methods explicitly shown as one-time purchases do not automatically renew. +

+ +

2. Billing frequency

+

+ The plan renews at the end of the billing period shown at checkout, such as monthly, quarterly, or annually. Renewal continues until cancelled. Your payment method may be charged shortly before or on the start of the next period as permitted by the payment provider. +

+ +

3. Renewal amount

+

+ The initial charge and renewal amount are displayed before confirmation. A promotion may reduce the first charge without reducing later renewals. Unless another renewal amount is shown, renewal uses the applicable regular price for the selected plan, together with any taxes or fees required by law. +

+ +

4. Confirmation

+

+ Cozsweet asks for renewal confirmation when an account first attempts to pay for an automatically renewing membership in the current agreement version. Cancelling the confirmation does not create an order. Confirming records the acknowledgement in the current browser and continues to payment. +

+ +

5. Cancellation

+

+ You may cancel future renewal using a cancellation option made available in the product or by using the support/payment-issue path before the next renewal is processed. Cancellation takes effect for future billing; membership already paid for normally remains available until the end of the current term. +

+ +

6. Failed or disputed payments

+

+ A failed renewal can interrupt membership benefits. If you see a duplicate charge, a long-pending payment, or missing benefits, do not repeatedly submit payment. Use Payment issue? on the purchase page and keep the returned Feedback ID. Never provide passwords, full card numbers, security codes, verification codes, or access tokens. +

+ +

7. Material changes

+

+ If the renewal terms materially change, Cozsweet will update this agreement and request a new acknowledgement when required. The version effective for a payment is the version linked from its confirmation dialog. +

+
+ + diff --git a/public/legal/vip-membership-benefits.html b/public/legal/vip-membership-benefits.html new file mode 100644 index 00000000..a594c16a --- /dev/null +++ b/public/legal/vip-membership-benefits.html @@ -0,0 +1,66 @@ + + + + + + + VIP Membership Benefits Agreement | Cozsweet + + + +
+

VIP Membership Benefits Agreement

+

Effective date: July 29, 2026

+ +

+ This agreement explains the VIP membership displayed on the Cozsweet purchase page. The plan name, price, currency, duration, credits, and benefits shown at checkout are the terms that apply to your purchase. +

+ +

1. Membership benefits

+

+ VIP may include the chat access, credits, private content access, or other benefits displayed for the selected plan. Benefits are available only for the membership period and account shown at checkout. A membership does not guarantee that every feature or item is free; some content can still require credits or a separate purchase when clearly shown in the product. +

+ +

2. Plan term and account

+

+ Membership is attached to the Cozsweet account used for payment. The selected monthly, quarterly, annual, lifetime, or other plan term is shown before payment. Do not share payment credentials or account access with another person. +

+ +

3. Automatic renewal

+

+ A plan marked as automatically renewing continues until cancelled. Before the first automatically renewing purchase, Cozsweet displays a separate confirmation. Please also read the Automatic Renewal Agreement. +

+ +

4. Prices and promotions

+

+ The checkout page is the source of truth for the current amount and currency. A first-payment or limited promotion can apply only to the initial charge; the renewal amount may be the regular price displayed in the renewal notice. Cozsweet does not ask you to pay a price that is not shown in the checkout flow. +

+ +

5. Payment and access

+

+ Benefits are activated only after payment is confirmed and the order is fulfilled. A pending or failed payment does not activate membership. If payment is duplicated, remains pending, or benefits are missing, do not submit repeated payments; use the Payment issue? entry on the purchase page and retain the returned Feedback ID. +

+ +

6. Cancellation, refunds, and applicable rights

+

+ Cancellation stops future renewal but does not normally remove benefits already paid for during the current term. Refund rights depend on applicable law, the payment provider, and the circumstances of the order. Use the product support or payment-issue path for review; never send a password, card number, security code, verification code, or access token. +

+ +

7. Changes

+

+ If a material membership term changes, the updated version and effective date will be made available before it applies to a new purchase or renewal where required. +

+
+ + diff --git a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx index 0da25150..c6babe9d 100644 --- a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx +++ b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx @@ -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", () => ({ ))} ), - SubscriptionCheckoutButton: ({ disabled }: { disabled: boolean }) => ( - ), - SubscriptionRenewalConfirmationDialog: ({ - open, - onCancel, - onConfirm, - }: { - open: boolean; - onCancel: () => void; - onConfirm: () => void; - }) => - open ? ( -
- - -
- ) : 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()); const checkout = container.querySelector( '[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()); - 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()); - act(() => clickButton(container, "vip_monthly")); - act(() => clickButton(container, "Confirm")); - expect(container.querySelector('[role="dialog"]')).toBeNull(); - - act(() => root.unmount()); - root = createRoot(container); - act(() => root.render()); - 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()); act(() => clickButton(container, "coin_1000")); diff --git a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts index ae3f3b46..c40e01a1 100644 --- a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts +++ b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts @@ -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); - }); }); diff --git a/src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx b/src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx new file mode 100644 index 00000000..b0a3f64d --- /dev/null +++ b/src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx @@ -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; + }) => ( + + ), +})); + +vi.mock("../subscription-renewal-confirmation-dialog", () => ({ + SubscriptionRenewalConfirmationDialog: ({ + open, + onCancel, + onConfirm, + }: { + open: boolean; + onCancel: () => void; + onConfirm: () => void; + }) => + open ? ( +
+ + +
+ ) : 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( + , + ), + ); +} + +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(); +} diff --git a/src/app/subscription/components/__tests__/subscription-payment-dialogs.test.tsx b/src/app/subscription/components/__tests__/subscription-payment-dialogs.test.tsx index f9c69c8e..6a48db5a 100644 --- a/src/app/subscription/components/__tests__/subscription-payment-dialogs.test.tsx +++ b/src/app/subscription/components/__tests__/subscription-payment-dialogs.test.tsx @@ -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(); diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index 434e4cf3..a6b2372f 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -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 ( <> + setShowRenewalConfirmation(false)} + onConfirm={handleRenewalConfirm} + /> ); } diff --git a/src/app/subscription/subscription-screen.helpers.ts b/src/app/subscription/subscription-screen.helpers.ts index bc243dba..4bbd264e 100644 --- a/src/app/subscription/subscription-screen.helpers.ts +++ b/src/app/subscription/subscription-screen.helpers.ts @@ -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; -}): 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; -}): 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; diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 135a933e..cad7124b 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -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 - >(() => new Set()); - const [pendingVipPlanId, setPendingVipPlanId] = useState(null); const [showPaymentIssueDialog, setShowPaymentIssueDialog] = useState(false); const [paymentIssueNotice, setPaymentIssueNotice] = useState( 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} /> - setPendingVipPlanId(null)} - onConfirm={handleConfirmVipPlan} - /> - {showPaymentIssueDialog ? (