diff --git a/e2e/fixtures/api-mocks.ts b/e2e/fixtures/api-mocks.ts index f6f9f63a..8c49dcf7 100644 --- a/e2e/fixtures/api-mocks.ts +++ b/e2e/fixtures/api-mocks.ts @@ -13,7 +13,6 @@ export interface MockCoreApisOptions { paidImageInsufficientCreditsFlow?: boolean; paidVoiceInsufficientCreditsFlow?: boolean; topUpHandoffFlow?: boolean; - checkoutHandoffFlow?: boolean; } export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) { @@ -24,7 +23,6 @@ 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 }; @@ -32,7 +30,6 @@ 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); diff --git a/e2e/fixtures/api/auth.ts b/e2e/fixtures/api/auth.ts index 322cc7f3..799cdf97 100644 --- a/e2e/fixtures/api/auth.ts +++ b/e2e/fixtures/api/auth.ts @@ -1,24 +1,15 @@ import type { Page } from "@playwright/test"; import { apiEnvelope } from "../data/common"; -import { checkoutHandoffResponse, emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth"; +import { 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(); diff --git a/e2e/fixtures/data/auth.ts b/e2e/fixtures/data/auth.ts index 606c8c9b..5f5c195e 100644 --- a/e2e/fixtures/data/auth.ts +++ b/e2e/fixtures/data/auth.ts @@ -63,15 +63,3 @@ 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", - }, -}; diff --git a/e2e/fixtures/helpers/payment.ts b/e2e/fixtures/helpers/payment.ts index 6b50aeb0..83d327fa 100644 --- a/e2e/fixtures/helpers/payment.ts +++ b/e2e/fixtures/helpers/payment.ts @@ -2,17 +2,18 @@ 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).toBeEnabled(); - - await checkoutButton.click(); + await expect(checkoutButton).toBeDisabled(); + await page.getByRole("button", { name: /Monthly,/i }).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 renewalDialog.getByRole("button", { name: "Confirm" }).click(); + await checkoutButton.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 deleted file mode 100644 index e1128f9e..00000000 --- a/e2e/specs/mock/payment/checkout-handoff-external-browser.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -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, - viewport: { width: 390, height: 844 }, -}); - -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&character=nayeli&handoffToken=${encodeURIComponent(handoffToken)}`, - ); - - expect((await consumeRequest).postDataJSON()).toEqual({ handoffToken }); - await expect(page).toHaveURL( - /\/subscription\?planId=vip_monthly&autoRenew=1&character=nayeli&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&character=maya"); - await page.getByRole("button", { name: /Monthly,/i }).click(); - const externalButton = page.getByRole("button", { - name: "Open in browser for more payment methods", - }); - const paymentButton = page.getByRole("button", { name: "Pay and Top Up" }); - await expect(externalButton).toBeVisible(); - await expect(externalButton).toBeEnabled(); - await expect(paymentButton).toBeVisible(); - await expect( - page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }), - ).toHaveCount(0); - const externalBox = await externalButton.boundingBox(); - const paymentBox = await paymentButton.boundingBox(); - expect(externalBox).not.toBeNull(); - expect(paymentBox).not.toBeNull(); - expect(externalBox!.y).toBeLessThan(paymentBox!.y); - expect(paymentBox!.y + paymentBox!.height).toBeLessThanOrEqual(844); - 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); -}); diff --git a/e2e/specs/mock/payment/indonesia-qris.spec.ts b/e2e/specs/mock/payment/indonesia-qris.spec.ts index f4247b3c..f4e1b01a 100644 --- a/e2e/specs/mock/payment/indonesia-qris.spec.ts +++ b/e2e/specs/mock/payment/indonesia-qris.spec.ts @@ -232,11 +232,19 @@ 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).toBeEnabled(); + await expect(checkoutButton).toBeDisabled(); await expectCheckoutButtonLayout(page); - await expect( - page.getByRole("dialog", { name: "Automatic Renewal Confirmation" }), - ).toHaveCount(0); + + 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(); 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 deleted file mode 100644 index 929e1c0c..00000000 --- a/e2e/specs/mock/payment/legal-agreements.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index c2e55a0d..00000000 --- a/public/legal/automatic-renewal.html +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - 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 deleted file mode 100644 index a594c16a..00000000 --- a/public/legal/vip-membership-benefits.html +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - 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/_components/payment/__tests__/external-browser-checkout-button.test.tsx b/src/app/_components/payment/__tests__/external-browser-checkout-button.test.tsx deleted file mode 100644 index d0b5979a..00000000 --- a/src/app/_components/payment/__tests__/external-browser-checkout-button.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -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( - , - ); - }); - - 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&character=maya", - ); - }); -}); diff --git a/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx b/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx index db6404be..eb971f8f 100644 --- a/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx +++ b/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx @@ -18,8 +18,6 @@ const hiddenLaunch = { shouldShowQrisDialog: false, shouldShowStripeDialog: false, stripeClientSecret: null, - stripeCustomerSessionClientSecret: null, - savedPaymentMethodsEnabled: false, }; describe("PaymentLaunchDialogs", () => { diff --git a/src/app/_components/payment/__tests__/stripe-payment-dialog.test.tsx b/src/app/_components/payment/__tests__/stripe-payment-dialog.test.tsx index 65ce1029..6910ce17 100644 --- a/src/app/_components/payment/__tests__/stripe-payment-dialog.test.tsx +++ b/src/app/_components/payment/__tests__/stripe-payment-dialog.test.tsx @@ -2,57 +2,18 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const stripeConfirmPayment = vi.fn(async () => ({})); -const elementsSubmit = vi.fn(async () => ({})); -let capturedElementsOptions: Record | null = null; -let capturedExpressProps: Record | null = null; -let capturedPaymentProps: Record | 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; - }) => { - capturedElementsOptions = options; - return <>{children}; - }, - ExpressCheckoutElement: (props: Record) => { - capturedExpressProps = props; - return
; - }, - PaymentElement: (props: Record) => { - capturedPaymentProps = props; - return
; - }, - useElements: () => ({ submit: elementsSubmit }), - useStripe: () => ({ confirmPayment: stripeConfirmPayment }), -})); - +import { StripePaymentDialogLoading } from "../lazy-stripe-payment-dialog"; import { StripePaymentDialog } from "../stripe-payment-dialog"; -describe("StripePaymentDialog", () => { +describe("Stripe payment portal states", () => { 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); }); @@ -63,129 +24,40 @@ describe("StripePaymentDialog", () => { document.body.style.overflow = ""; }); - it("renders express methods and wallets before card with English Stripe copy", () => { + it("portals the unavailable state and keeps its explicit close action", () => { + const onClose = vi.fn(); + act(() => root.render( , ), ); - expect(capturedElementsOptions).toMatchObject({ - clientSecret: "pi_123_secret_payment", - customerSessionClientSecret: "cuss_123_secret_saved", - locale: "en", - }); - expect(capturedExpressProps?.options).toMatchObject({ - paymentMethodOrder: [ - "link", - "paypal", - "amazon_pay", - "klarna", - ], - paymentMethods: { - applePay: "never", - googlePay: "never", - link: "auto", - paypal: "auto", - amazonPay: "auto", - klarna: "auto", - }, - }); - expect(capturedPaymentProps?.options).toMatchObject({ - layout: { type: "accordion", defaultCollapsed: true }, - paymentMethodOrder: ["wechat_pay", "alipay", "card"], - wallets: { applePay: "auto", googlePay: "auto", link: "never" }, - }); - const text = document.body.textContent ?? ""; - expect(text).toContain("Choose a payment method"); - const express = document.body.querySelector('[data-testid="express-checkout"]'); - const card = document.body.querySelector('[data-testid="payment-element"]'); - expect(express).not.toBeNull(); - expect(card).not.toBeNull(); - expect( - express!.compareDocumentPosition(card!) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); + const 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(); }); - it("keeps Payment Element wallet fallbacks when Express Checkout is unavailable", () => { - act(() => - root.render( - , - ), - ); + it("portals the lazy loading state", () => { + act(() => root.render()); - const onReady = capturedExpressProps?.onReady as - | ((event: { availablePaymentMethods: undefined }) => void) - | undefined; - expect(onReady).toBeTypeOf("function"); - act(() => onReady?.({ availablePaymentMethods: undefined })); - - const expressSection = document.body.querySelector( - '[aria-label="Express payment methods"]', - ); - expect(expressSection).toHaveProperty("hidden", true); - expect( - document.body.querySelector('[data-testid="payment-element"]'), - ).not.toBeNull(); - expect(capturedPaymentProps?.options).toMatchObject({ - wallets: { applePay: "auto", googlePay: "auto", link: "never" }, - paymentMethodOrder: ["wechat_pay", "alipay", "card"], - }); - }); - - it("does not pass an invalid or disabled Customer Session secret", () => { - act(() => - root.render( - , - ), - ); - - expect(capturedElementsOptions).not.toHaveProperty( - "customerSessionClientSecret", - ); - }); - - it("uses the same confirmation path for an Express Checkout approval", async () => { - const onConfirmed = vi.fn(); - act(() => - root.render( - , - ), - ); - - const onConfirm = capturedExpressProps?.onConfirm as - | ((event: { paymentFailed: ReturnType }) => Promise) - | 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); + 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(); }); }); diff --git a/src/app/_components/payment/external-browser-checkout-button.tsx b/src/app/_components/payment/external-browser-checkout-button.tsx deleted file mode 100644 index fb9a797a..00000000 --- a/src/app/_components/payment/external-browser-checkout-button.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import { useState, useSyncExternalStore } from "react"; - -import type { CheckoutIntent } from "@/data/schemas/auth"; -import { - DEFAULT_CHARACTER_SLUG, - getCharacterBySlug, -} from "@/data/constants/character"; -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; - characterSlug?: string; -} - -export function ExternalBrowserCheckoutButton({ - checkoutIntent, - disabled = false, - characterSlug = DEFAULT_CHARACTER_SLUG, -}: ExternalBrowserCheckoutButtonProps) { - const isFacebookBrowser = useSyncExternalStore( - () => () => undefined, - () => BrowserDetector.isFacebookInAppBrowser(), - () => false, - ); - const [isOpening, setIsOpening] = useState(false); - const [errorMessage, setErrorMessage] = useState(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; - } - try { - const verifiedCharacter = - getCharacterBySlug(characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG; - const externalUrl = new URL( - result.data.externalUrl, - window.location.origin, - ); - externalUrl.searchParams.set("character", verifiedCharacter); - UrlLauncherUtil.openUrlWithExternalBrowser(externalUrl.toString()); - } catch (error) { - setErrorMessage( - ExceptionHandler.message( - error, - "Could not open this checkout in your browser. Please try again.", - ), - ); - setIsOpening(false); - } - }; - - return ( -
- - {errorMessage ? ( -

- {errorMessage} -

- ) : null} -
- ); -} diff --git a/src/app/_components/payment/payment-launch-dialogs.tsx b/src/app/_components/payment/payment-launch-dialogs.tsx index dbbade8b..d30620dc 100644 --- a/src/app/_components/payment/payment-launch-dialogs.tsx +++ b/src/app/_components/payment/payment-launch-dialogs.tsx @@ -24,8 +24,6 @@ type PaymentLaunchDialogFlow = Pick< | "shouldShowQrisDialog" | "shouldShowStripeDialog" | "stripeClientSecret" - | "stripeCustomerSessionClientSecret" - | "savedPaymentMethodsEnabled" >; export interface PaymentLaunchDialogsProps { @@ -66,10 +64,6 @@ export function PaymentLaunchDialogs({ {launch.shouldShowStripeDialog && launch.stripeClientSecret ? ( void; onConfirmed?: () => void; @@ -52,19 +35,12 @@ 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 ( @@ -124,10 +100,6 @@ export function StripePaymentDialog({ stripe={stripePromise} options={{ clientSecret, - locale: "en", - ...(savedCardClientSecret - ? { customerSessionClientSecret: savedCardClientSecret } - : {}), appearance: { theme: "stripe", variables: { @@ -160,20 +132,6 @@ function StripePaymentForm({ const elements = useElements(); const [errorMessage, setErrorMessage] = useState(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["onLoadError"]> @@ -192,118 +150,38 @@ function StripePaymentForm({ ); }; - 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 handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!stripe || !elements || isSubmitting) return; - const handleExpressLoadError = (event: Parameters< - NonNullable< - React.ComponentProps["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); - 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 { error: submitError } = await elements.submit(); + if (submitError) { + setErrorMessage( + ExceptionHandler.message(submitError, "Please check your payment info."), + ); + setIsSubmitting(false); + return; } const returnUrl = new URL( returnPath ?? ROUTES.subscription + "/success", window.location.origin, ); - let confirmationError: unknown; - try { - ({ error: confirmationError } = await stripe.confirmPayment({ - elements, - confirmParams: { - return_url: returnUrl.toString(), - }, - redirect: "if_required", - })); - } catch (error) { - confirmationError = error; - } + const { error } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: returnUrl.toString(), + }, + redirect: "if_required", + }); - if (confirmationError) { - const message = ExceptionHandler.message( - confirmationError, - "Payment confirmation failed.", + if (error) { + setErrorMessage( + ExceptionHandler.message(error, "Payment confirmation failed."), ); - setErrorMessage(message); - options.expressEvent?.paymentFailed({ reason: "fail", message }); - submittingRef.current = false; setIsSubmitting(false); return; } @@ -311,75 +189,9 @@ function StripePaymentForm({ onConfirmed?.(); }; - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - await confirmPayment({ submitPaymentElement: true }); - }; - return (
- -
-

- Choose a payment method -

- -
+ {errorMessage ? (

(null); const [handoffCompleted, setHandoffCompleted] = useState(false); - const [checkoutDestination, setCheckoutDestination] = useState( - null, - ); const hasNavigatedRef = useRef(false); const handoffStartedRef = useRef(false); const targetRoute = resolveExternalEntryTarget({ target }); @@ -76,14 +71,11 @@ export default function ExternalEntryPersist({ mode, promotionType, }); - const normalizedTarget = target?.trim().toLowerCase() ?? ""; - const isTopUpHandoff = normalizedTarget === "topup"; - const isCheckoutHandoff = normalizedTarget === "checkout"; - const isLoginHandoff = isTopUpHandoff || isCheckoutHandoff; + const isTopUpHandoff = targetRoute === ROUTES.subscription; const displayedHandoffError = handoffError ?? - (isLoginHandoff && !hasValue(handoffToken) - ? "This checkout link is invalid. Please return and request a new link." + (isTopUpHandoff && !hasValue(handoffToken) + ? "This top-up link is invalid. Please request a new link in Messenger." : null); useEffect(() => { @@ -136,7 +128,7 @@ export default function ExternalEntryPersist({ useEffect(() => { if (hasNavigatedRef.current || !hasPersisted) return; - if (isLoginHandoff) { + if (isTopUpHandoff) { if (!authState.hasInitialized || authState.isLoading) return; if (handoffCompleted) { if ( @@ -146,60 +138,25 @@ export default function ExternalEntryPersist({ return; } hasNavigatedRef.current = true; - router.replace(checkoutDestination ?? destination); + router.replace(destination); return; } if (handoffStartedRef.current) return; if (!hasValue(handoffToken)) return; handoffStartedRef.current = true; void (async () => { - if (isCheckoutHandoff) { - const result = await consumeCheckoutHandoff(handoffToken); - if (Result.isErr(result)) { - log.warn( - "[ExternalEntryPersist] checkout handoff failed", - result.error, - ); - window.history.replaceState( - window.history.state, - "", - "/external-entry?target=checkout", - ); - setHandoffError( - "This checkout link is invalid, expired, or already used. Please return and request a new link.", - ); - return; - } - window.history.replaceState( - window.history.state, - "", - "/external-entry?target=checkout", - ); - setCheckoutDestination( - resolveCheckoutIntentDestination(result.data, character), - ); - } 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; - } + 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; } setHandoffCompleted(true); authDispatch({ type: "AuthInit" }); @@ -234,12 +191,9 @@ export default function ExternalEntryPersist({ authState.loginStatus, character, destination, - checkoutDestination, hasPersisted, handoffCompleted, handoffToken, - isCheckoutHandoff, - isLoginHandoff, isTopUpHandoff, psid, router, diff --git a/src/app/external-entry/page.tsx b/src/app/external-entry/page.tsx index 0a93552e..6dfc6319 100644 --- a/src/app/external-entry/page.tsx +++ b/src/app/external-entry/page.tsx @@ -8,7 +8,6 @@ * `/external-entry?target=tip` * `/external-entry?target=private-zone&character=nayeli` * `/external-entry?target=topup&handoffToken=` - * `/external-entry?target=checkout&handoffToken=` * * 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储, * 再通过 `router.replace()` 清理 URL 并跳转到最终页面。 diff --git a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx index c6babe9d..0da25150 100644 --- a/src/app/subscription/__tests__/subscription-screen-flow.test.tsx +++ b/src/app/subscription/__tests__/subscription-screen-flow.test.tsx @@ -51,7 +51,6 @@ const mocks = vi.hoisted(() => { commercialOffer: null, selectedPlanId: "vip_monthly", payChannel: "stripe" as const, - autoRenew: true, agreed: true, currentOrderId: null, isCreatingOrder: false, @@ -101,9 +100,7 @@ vi.mock("@/hooks/use-has-hydrated", () => ({ })); vi.mock("@/stores/user/user-context", () => ({ - useUserState: () => ({ - currentUser: { id: "user-renewal-1", countryCode: "ID" }, - }), + useUserState: () => ({ currentUser: { countryCode: "ID" } }), })); vi.mock("@/providers/character-catalog-provider", () => ({ @@ -167,25 +164,26 @@ vi.mock("../components", () => ({ ))}

), - SubscriptionCheckoutButton: ({ - disabled, - renewalPlan, - renewalConsentSubjectId, - }: { - disabled: boolean; - renewalPlan: { id: string } | null; - renewalConsentSubjectId: string | null; - }) => ( - ), + SubscriptionRenewalConfirmationDialog: ({ + open, + onCancel, + onConfirm, + }: { + open: boolean; + onCancel: () => void; + onConfirm: () => void; + }) => + open ? ( +
+ + +
+ ) : null, SubscriptionPaymentIssueDialog: () => null, SubscriptionPaymentSuccessDialog: () => null, })); @@ -220,30 +218,66 @@ describe("SubscriptionScreen payment selection flow", () => { container.remove(); }); - it("allows immediate checkout and changes VIP plans without opening renewal confirmation", () => { + it("confirms VIP selection before enabling the separate checkout button", () => { act(() => root.render()); const checkout = container.querySelector( '[data-testid="checkout"]', ); - expect(checkout?.disabled).toBe(false); - expect(checkout?.dataset.renewalPlan).toBe("vip_monthly"); - expect(checkout?.dataset.renewalSubject).toBe("user-renewal-1"); + expect(checkout?.disabled).toBe(true); 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.paymentDispatch).toHaveBeenCalledWith({ + expect(mocks.payment.selectedPlanId).toBe("vip_monthly"); + expect(mocks.paymentDispatch).not.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 c40e01a1..ae3f3b46 100644 --- a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts +++ b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts @@ -7,9 +7,11 @@ import { } from "@/data/schemas/payment"; import { + canCheckoutSubscriptionPlan, findSelectedSubscriptionPlan, getDefaultSubscriptionPlanId, getFirstRechargeOfferView, + requiresVipPlanConfirmation, toCoinsOfferPlanViews, toVipOfferPlanViews, } from "../subscription-screen.helpers"; @@ -278,4 +280,85 @@ 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 deleted file mode 100644 index 1b6229ba..00000000 --- a/src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx +++ /dev/null @@ -1,244 +0,0 @@ -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(), - handleQrisResume: vi.fn(), - hasHiddenQrisPayment: false, - 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: () => ({ - handleQrisResume: mocks.handleQrisResume, - hasHiddenQrisPayment: mocks.hasHiddenQrisPayment, - 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.handleQrisResume.mockClear(); - mocks.hasHiddenQrisPayment = false; - mocks.resetLaunchState.mockClear(); - mocks.payment.selectedPlanId = "vip_monthly"; - mocks.payment.autoRenew = true; - mocks.payment.payChannel = "stripe"; - mocks.payment.isCreatingOrder = false; - mocks.payment.isPollingOrder = false; - 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", - }); - }); - - it("reopens a hidden QRIS order without creating another order", () => { - mocks.payment.payChannel = "ezpay"; - mocks.payment.isPollingOrder = true; - mocks.hasHiddenQrisPayment = true; - - act(() => - root.render( - , - ), - ); - - const resumeButton = getButton(container, "Resume QRIS payment"); - expect(resumeButton.disabled).toBe(false); - act(() => resumeButton.click()); - - expect(mocks.handleQrisResume).toHaveBeenCalledOnce(); - expect(mocks.resetLaunchState).not.toHaveBeenCalled(); - expect(mocks.dispatch).not.toHaveBeenCalled(); - }); -}); - -function renderButton( - root: Root, - renewalConsentSubjectId: string, - plan: typeof renewalPlan | null = renewalPlan, -): void { - act(() => - root.render( - , - ), - ); -} - -function clickButton(root: ParentNode, label: string): void { - getButton(root, label).click(); -} - -function getButton(root: ParentNode, label: string): HTMLButtonElement { - const button = Array.from(root.querySelectorAll("button")).find( - (item) => item.textContent?.trim() === label, - ); - if (!button) throw new Error(`Expected ${label} button`); - return button; -} 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 6a48db5a..f9c69c8e 100644 --- a/src/app/subscription/components/__tests__/subscription-payment-dialogs.test.tsx +++ b/src/app/subscription/components/__tests__/subscription-payment-dialogs.test.tsx @@ -71,14 +71,7 @@ describe("subscription payment dialogs", () => { expect(dialog?.textContent).toContain("Automatic Renewal Confirmation"); expect(dialog?.textContent).toContain("Monthly"); expect(dialog?.textContent).toContain("19.90 usd"); - expect( - Array.from(dialog?.querySelectorAll("a") ?? []).map((link) => - link.getAttribute("href"), - ), - ).toEqual([ - "/legal/vip-membership-benefits.html", - "/legal/automatic-renewal.html", - ]); + expect(dialog?.querySelectorAll("a")).toHaveLength(2); 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 8b98d794..af39cf12 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -4,17 +4,10 @@ * * 包装 `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, @@ -22,19 +15,15 @@ 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 { - /** 是否可用(未选套餐、缺少角色来源或支付处理中为 false) */ + /** 是否可用(未选套餐或 VIP 套餐尚未确认自动续费时为 false) */ disabled?: boolean; subscriptionType: "vip" | "topup"; returnTo?: SubscriptionReturnTo; sourceCharacterSlug?: string; - renewalPlan?: VipOfferPlanView | null; - renewalConsentSubjectId?: string | null; } export function SubscriptionCheckoutButton({ @@ -42,11 +31,7 @@ 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({ @@ -71,54 +56,17 @@ export function SubscriptionCheckoutButton({ ? "Creating order..." : readyLabel; - const startCheckout = () => { - if (disabled || isLoading) return; - paymentLaunch.resetLaunchState(); - paymentDispatch({ type: "PaymentCreateOrderSubmitted" }); - }; - const handleClick = () => { if (isResumingQris) { paymentLaunch.handleQrisResume(); return; } if (disabled || isLoading) return; - if ( - payment.payChannel === "stripe" && - payment.autoRenew && - renewalPlan !== null && - !hasAutomaticRenewalAcknowledgement(renewalConsentSubjectId) - ) { - setShowRenewalConfirmation(true); - return; - } - startCheckout(); + paymentLaunch.resetLaunchState(); + paymentDispatch({ type: "PaymentCreateOrderSubmitted" }); }; - - const handleRenewalConfirm = () => { - rememberAutomaticRenewalAcknowledgement(renewalConsentSubjectId); - setShowRenewalConfirmation(false); - startCheckout(); - }; - return ( <> - {payment.payChannel === "stripe" && payment.selectedPlanId ? ( - - ) : null} - setShowRenewalConfirmation(false)} - onConfirm={handleRenewalConfirm} - /> ); } diff --git a/src/app/subscription/page.tsx b/src/app/subscription/page.tsx index edc8ea82..ae3a4d97 100644 --- a/src/app/subscription/page.tsx +++ b/src/app/subscription/page.tsx @@ -5,7 +5,6 @@ import { } from "@/lib/analytics/payment_analytics_context"; import { getFirstPaymentSearchParam, - parsePaymentAutoRenew, parsePaymentReturnSearchParams, parseSubscriptionReturnTo, type PaymentSearchParams, @@ -56,7 +55,6 @@ export default async function SubscriptionPage({ analyticsContext={analyticsContext} sourceCharacterSlug={sourceCharacterSlug} initialPlanId={initialPlanId} - initialAutoRenew={parsePaymentAutoRenew(query.autoRenew)} commercialOfferId={commercialOfferId} resumeOrderId={resumeOrderId} chatActionId={chatActionId} diff --git a/src/app/subscription/subscription-screen.helpers.ts b/src/app/subscription/subscription-screen.helpers.ts index 4bbd264e..bc243dba 100644 --- a/src/app/subscription/subscription-screen.helpers.ts +++ b/src/app/subscription/subscription-screen.helpers.ts @@ -105,6 +105,30 @@ 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 3da7a059..14effc46 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -24,13 +24,16 @@ 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, @@ -47,7 +50,6 @@ export interface SubscriptionScreenProps { analyticsContext?: PaymentAnalyticsContext; sourceCharacterSlug?: string | null; initialPlanId?: string | null; - initialAutoRenew?: boolean | null; commercialOfferId?: string | null; resumeOrderId?: string | null; chatActionId?: string | null; @@ -61,11 +63,14 @@ export function SubscriptionScreen({ analyticsContext: providedAnalyticsContext, sourceCharacterSlug = null, initialPlanId = null, - initialAutoRenew = null, commercialOfferId = null, 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, @@ -95,7 +100,6 @@ export function SubscriptionScreen({ sourceCharacterSlug: sourceCharacter?.slug ?? null, initialPayChannel: paymentMethodConfig.initialPayChannel, initialPlanId, - initialAutoRenew, commercialOfferId, resumeOrderId, chatActionId, @@ -149,20 +153,49 @@ export function SubscriptionScreen({ const isPaymentBusy = payment.isCreatingOrder || payment.isPollingOrder; - const selectedVipPlan = - vipOfferPlans.find((plan) => plan.id === payment.selectedPlanId) ?? null; - const selectedPlanIsVip = selectedVipPlan !== null; + const selectedPlanIsVip = vipOfferPlans.some( + (plan) => plan.id === payment.selectedPlanId, + ); 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", @@ -304,11 +337,16 @@ 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 ? ( {label} - {payment.payChannel === "stripe" && payment.selectedPlanId ? ( - - ) : null} {payment.errorMessage ? (

{payment.errorMessage} diff --git a/src/core/app_constants.ts b/src/core/app_constants.ts index c03f8062..d0895f06 100644 --- a/src/core/app_constants.ts +++ b/src/core/app_constants.ts @@ -13,11 +13,11 @@ export class AppConstants { /** 会员服务协议文档链接 */ static readonly membershipAgreementUrl = - "/legal/vip-membership-benefits.html"; + "https://www.banlv-ai.com/cozsweet/membership-agreement.html"; /** 自动续费服务协议文档链接 */ static readonly autoRenewalAgreementUrl = - "/legal/automatic-renewal.html"; + "https://www.banlv-ai.com/cozsweet/auto-renewal-agreement.html"; /** 开发环境 APP 标题 */ static readonly appTitleDevelopment = "Develop"; diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts index 19138893..c581a79b 100644 --- a/src/data/repositories/auth_repository.ts +++ b/src/data/repositories/auth_repository.ts @@ -1,10 +1,6 @@ import { ExceptionHandler } from "@/core/errors"; import type { IAuthRepository } from "@/data/repositories/interfaces"; import { - CheckoutHandoffConsumeRequestSchema, - CheckoutHandoffCreateRequestSchema, - type CheckoutHandoffCreateResponse, - type CheckoutIntent, FacebookIdentityRequestSchema, FacebookLoginRequestSchema, FbIdLoginRequestSchema, @@ -280,30 +276,6 @@ export class AuthRepository implements IAuthRepository { }); } - /** 创建订单前生成一次性外部浏览器支付交接链接。 */ - async createCheckoutHandoff( - checkoutIntent: CheckoutIntent, - ): Promise> { - return Result.wrap(() => - this.api.createCheckoutHandoff( - CheckoutHandoffCreateRequestSchema.parse(checkoutIntent), - ), - ); - } - - /** 消费支付交接凭证,保存后端签发的正式登录态并返回购买意图。 */ - async consumeCheckoutHandoff( - handoffToken: string, - ): Promise> { - 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。 diff --git a/src/data/repositories/interfaces/iauth_repository.ts b/src/data/repositories/interfaces/iauth_repository.ts index 305c4d3c..4a2e55a0 100644 --- a/src/data/repositories/interfaces/iauth_repository.ts +++ b/src/data/repositories/interfaces/iauth_repository.ts @@ -13,8 +13,6 @@ import type { Result } from "@/utils/result"; import type { - CheckoutHandoffCreateResponse, - CheckoutIntent, GuestLoginResponse, LoginStatus, LoginResponse, @@ -76,16 +74,6 @@ export interface IAuthRepository { /** 消费一次性充值登录凭证并建立正式会话。 */ consumeTopUpHandoff(handoffToken: string): Promise>; - /** 在创建订单前生成10分钟有效的外部浏览器支付链接。 */ - createCheckoutHandoff( - checkoutIntent: CheckoutIntent, - ): Promise>; - - /** 消费单次支付交接凭证并保存正式登录态。 */ - consumeCheckoutHandoff( - handoffToken: string, - ): Promise>; - /** 刷新 token:先读本地的 refresh token,空则返回错误。 */ refreshToken(): Promise>; diff --git a/src/data/schemas/auth/__tests__/checkout_handoff.test.ts b/src/data/schemas/auth/__tests__/checkout_handoff.test.ts deleted file mode 100644 index 7b1065ec..00000000 --- a/src/data/schemas/auth/__tests__/checkout_handoff.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -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"); - }); -}); diff --git a/src/data/schemas/auth/checkout_handoff.ts b/src/data/schemas/auth/checkout_handoff.ts deleted file mode 100644 index f97be1d0..00000000 --- a/src/data/schemas/auth/checkout_handoff.ts +++ /dev/null @@ -1,58 +0,0 @@ -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; -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 ->; diff --git a/src/data/schemas/auth/index.ts b/src/data/schemas/auth/index.ts index 5a6f533f..0446d39b 100644 --- a/src/data/schemas/auth/index.ts +++ b/src/data/schemas/auth/index.ts @@ -3,7 +3,6 @@ */ 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"; diff --git a/src/data/services/api/api_contract.json b/src/data/services/api/api_contract.json index 5cd59520..c78af6d7 100644 --- a/src/data/services/api/api_contract.json +++ b/src/data/services/api/api_contract.json @@ -6,8 +6,6 @@ "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" }, diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 6828c1ed..22b7e7d2 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -30,12 +30,6 @@ 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; diff --git a/src/data/services/api/auth_api.ts b/src/data/services/api/auth_api.ts index 8da96b18..c4f67254 100644 --- a/src/data/services/api/auth_api.ts +++ b/src/data/services/api/auth_api.ts @@ -7,12 +7,6 @@ import { z } from "zod"; import { - CheckoutHandoffConsumeRequest, - CheckoutHandoffConsumeResponse, - CheckoutHandoffConsumeResponseSchema, - CheckoutHandoffCreateRequest, - CheckoutHandoffCreateResponse, - CheckoutHandoffCreateResponseSchema, FacebookIdentityRequest, FacebookIdentityResponse, FacebookIdentityResponseSchema, @@ -110,32 +104,6 @@ export class AuthApi { ); } - /** 在创建支付订单前生成一次性外部浏览器交接链接。 */ - async createCheckoutHandoff( - body: CheckoutHandoffCreateRequest, - ): Promise { - const env = await httpClient>( - ApiPath.checkoutHandoffCreate, - { method: "POST", body }, - ); - return CheckoutHandoffCreateResponseSchema.parse( - unwrap(env) as Record, - ); - } - - /** 消费一次性支付交接凭证并恢复正式登录态与购买意图。 */ - async consumeCheckoutHandoff( - body: CheckoutHandoffConsumeRequest, - ): Promise { - const env = await httpClient>( - ApiPath.checkoutHandoffConsume, - { method: "POST", body }, - ); - return CheckoutHandoffConsumeResponseSchema.parse( - unwrap(env) as Record, - ); - } - /** * 绑定 Facebook ASID / PSID 到当前用户 */ diff --git a/src/lib/auth/checkout_handoff.ts b/src/lib/auth/checkout_handoff.ts deleted file mode 100644 index b9dc2e52..00000000 --- a/src/lib/auth/checkout_handoff.ts +++ /dev/null @@ -1,18 +0,0 @@ -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> { - return getAuthRepository().createCheckoutHandoff(checkoutIntent); -} - -export function consumeCheckoutHandoff( - handoffToken: string, -): Promise> { - return getAuthRepository().consumeCheckoutHandoff(handoffToken); -} diff --git a/src/lib/navigation/__tests__/external_entry.test.ts b/src/lib/navigation/__tests__/external_entry.test.ts index 408b2b2a..9d98e7ba 100644 --- a/src/lib/navigation/__tests__/external_entry.test.ts +++ b/src/lib/navigation/__tests__/external_entry.test.ts @@ -3,41 +3,12 @@ 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", - }, - "nayeli", - ), - ).toBe( - "/subscription?planId=vip_monthly&autoRenew=1&character=nayeli&commercialOfferId=offer-1&chatActionId=00000000-0000-0000-0000-000000000123&payChannel=stripe", - ); - }); - - it("restores a character tip intent by canonical character id", () => { - expect( - resolveCheckoutIntentDestination({ - planId: "maya_coffee", - autoRenew: false, - recipientCharacterId: "maya-tan", - }), - ).toBe("/characters/maya/tip?planId=maya_coffee&payChannel=stripe"); - }); -}); - const facebookBusinessEntryCases = [ ["private-space", "elio", "chat", null, null, getCharacterRoutes("elio").chat, null], ["private-space", "maya", "chat", null, null, getCharacterRoutes("maya").chat, null], diff --git a/src/lib/navigation/external_entry.ts b/src/lib/navigation/external_entry.ts index ed7af1a8..8dcee9d3 100644 --- a/src/lib/navigation/external_entry.ts +++ b/src/lib/navigation/external_entry.ts @@ -4,10 +4,8 @@ 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"; @@ -102,34 +100,6 @@ export function resolveExternalEntryDestination({ return routes.chat; } -export function resolveCheckoutIntentDestination( - intent: CheckoutIntent, - sourceCharacterSlug?: string | null, -): 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"); - const characterSlug = - getCharacterBySlug(sourceCharacterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG; - params.set("character", characterSlug); - 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, @@ -168,7 +138,6 @@ function resolveTarget( return ROUTES.privateZone; } if (target === "topup") return ROUTES.subscription; - if (target === "checkout") return ROUTES.subscription; return null; } diff --git a/src/lib/payment/__tests__/payment_launch.test.ts b/src/lib/payment/__tests__/payment_launch.test.ts index e7f9b148..d931b6c6 100644 --- a/src/lib/payment/__tests__/payment_launch.test.ts +++ b/src/lib/payment/__tests__/payment_launch.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest"; import { getPaymentUrl, - getStripeCustomerSessionClientSecret, getStripeClientSecret, isEzpayPayment, launchEzpayRedirect, @@ -44,28 +43,6 @@ 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({ diff --git a/src/lib/payment/automatic_renewal_acknowledgement.ts b/src/lib/payment/automatic_renewal_acknowledgement.ts deleted file mode 100644 index c69f0071..00000000 --- a/src/lib/payment/automatic_renewal_acknowledgement.ts +++ /dev/null @@ -1,33 +0,0 @@ -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. - } -} diff --git a/src/lib/payment/payment_launch.ts b/src/lib/payment/payment_launch.ts index 1563a5a1..71b93286 100644 --- a/src/lib/payment/payment_launch.ts +++ b/src/lib/payment/payment_launch.ts @@ -143,27 +143,6 @@ export function getStripeClientSecret( return null; } -export function getStripeCustomerSessionClientSecret( - payParams: Record, -): 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; diff --git a/src/lib/payment/payment_search_params.ts b/src/lib/payment/payment_search_params.ts index c054200e..2850d3f9 100644 --- a/src/lib/payment/payment_search_params.ts +++ b/src/lib/payment/payment_search_params.ts @@ -22,15 +22,6 @@ 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 { diff --git a/src/stores/payment/__tests__/payment-catalog-flow.test.ts b/src/stores/payment/__tests__/payment-catalog-flow.test.ts index 97380549..07193cfe 100644 --- a/src/stores/payment/__tests__/payment-catalog-flow.test.ts +++ b/src/stores/payment/__tests__/payment-catalog-flow.test.ts @@ -8,6 +8,7 @@ import { import { createTestPaymentMachine, + giftCatalog, lifetimePlan, monthlyPlan, quarterlyPlan, @@ -50,7 +51,7 @@ describe("payment catalog flow", () => { actor.stop(); }); - it("restores a valid tip product across categories from checkout handoff", async () => { + it("restores a valid first-category product and rejects another category", async () => { const actor = createActor(createTestPaymentMachine()).start(); actor.send({ type: "PaymentInit", @@ -78,9 +79,8 @@ describe("payment catalog flow", () => { snapshot.context.requestedGiftPlanId === null, ); expect(actor.getSnapshot().context.selectedPlanId).toBe( - "tip_flowers_usd_12_99", + giftCatalog.plans[0]?.planId, ); - expect(actor.getSnapshot().context.selectedGiftCategory).toBe("flowers"); actor.stop(); }); diff --git a/src/stores/payment/helper/gift.ts b/src/stores/payment/helper/gift.ts index e5e04189..c880ab9c 100644 --- a/src/stores/payment/helper/gift.ts +++ b/src/stores/payment/helper/gift.ts @@ -50,23 +50,15 @@ export function hydrateGiftProductsState( const giftProducts = response.plans.filter( (product) => product.characterId === characterId, ); - 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 selectedGiftCategory = giftCategories[0]?.category ?? null; const visibleProducts = selectedGiftCategory ? giftProducts.filter( (product) => product.category === selectedGiftCategory, ) : []; - const canRestoreSelection = visibleProducts.some( - (product) => product.planId === restoredPlanId, - ); + const canRestoreSelection = + restoredCategory === selectedGiftCategory && + visibleProducts.some((product) => product.planId === restoredPlanId); const selectedPlanId = canRestoreSelection ? (restoredPlanId ?? "") : (visibleProducts[0]?.planId ?? ""); diff --git a/src/stores/payment/machine/catalog-flow.ts b/src/stores/payment/machine/catalog-flow.ts index bd4a1c4e..e62050eb 100644 --- a/src/stores/payment/machine/catalog-flow.ts +++ b/src/stores/payment/machine/catalog-flow.ts @@ -38,18 +38,14 @@ function initializeCatalogState( giftCharacterId !== context.giftCharacterId || supportCharacterId !== context.supportCharacterId; const selectionChanged = - planCatalog === "tip" - ? (event.category ?? null) !== context.selectedGiftCategory || - (event.planId ?? null) !== (context.selectedPlanId || null) - : (event.planId ?? null) !== (context.selectedPlanId || null); + planCatalog === "tip" && + ((event.category ?? null) !== context.selectedGiftCategory || + (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, @@ -62,12 +58,7 @@ function initializeCatalogState( planCatalog === "tip" ? (event.category ?? null) : null, requestedGiftPlanId: planCatalog === "tip" ? (event.planId ?? null) : null, - ...(catalogChanged || - characterChanged || - selectionChanged || - offerChanged || - chatActionChanged || - autoRenewChanged + ...(catalogChanged || characterChanged || selectionChanged || offerChanged || chatActionChanged ? { ...resetOrderState(), plans: [], @@ -78,7 +69,7 @@ function initializeCatalogState( planCatalog === "default" ? (event.planId ?? "") : "", isFirstRecharge: false, commercialOffer: null, - autoRenew: initialAutoRenew, + autoRenew: planCatalog !== "tip", } : {}), }; diff --git a/src/stores/payment/payment-context.tsx b/src/stores/payment/payment-context.tsx index a731436b..7b3d29e2 100644 --- a/src/stores/payment/payment-context.tsx +++ b/src/stores/payment/payment-context.tsx @@ -20,8 +20,6 @@ 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; @@ -78,8 +76,6 @@ 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, diff --git a/src/stores/payment/payment-events.ts b/src/stores/payment/payment-events.ts index 13c86a22..3d75b52f 100644 --- a/src/stores/payment/payment-events.ts +++ b/src/stores/payment/payment-events.ts @@ -10,7 +10,6 @@ export type PaymentEvent = characterId?: string; category?: string | null; planId?: string | null; - autoRenew?: boolean; commercialOfferId?: string | null; chatActionId?: string | null; } diff --git a/src/utils/__tests__/logger-redaction.test.ts b/src/utils/__tests__/logger-redaction.test.ts deleted file mode 100644 index f93faaca..00000000 --- a/src/utils/__tests__/logger-redaction.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -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]"); - }); -}); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 49288b70..29b0f91a 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -107,13 +107,9 @@ export class Logger { const trimmed = value.trim(); if (trimmed.length === 0) return ""; try { - return JSON.stringify( - Logger.toSerializableLogValue(JSON.parse(trimmed)), - null, - 2, - ); + return JSON.stringify(JSON.parse(trimmed), null, 2); } catch { - return Logger.redactSensitiveString(value); + return value; } } @@ -153,31 +149,28 @@ export class Logger { } private write(level: LogLevel, args: LogArgs): void { - const safeArgs = args.map((value) => - Logger.toSerializableLogValue(value), - ); - Logger.reportImportantProductionLog(level, this.component, safeArgs); + Logger.reportImportantProductionLog(level, this.component, args); if (Logger.shouldUseBrowserConsole()) { - Logger.writeBrowserConsole(level, this.component, safeArgs); + Logger.writeBrowserConsole(level, this.component, args); return; } switch (level) { case "debug": - this.logger.debug(...Logger.normalizeLogArgs(safeArgs)); + this.logger.debug(...Logger.normalizeLogArgs(args)); return; case "info": - this.logger.info(...Logger.normalizeLogArgs(safeArgs)); + this.logger.info(...Logger.normalizeLogArgs(args)); return; case "warn": - this.logger.warn(...Logger.normalizeLogArgs(safeArgs)); + this.logger.warn(...Logger.normalizeLogArgs(args)); return; case "error": - this.logger.error(...Logger.normalizeLogArgs(safeArgs)); + this.logger.error(...Logger.normalizeLogArgs(args)); return; case "fatal": - this.logger.fatal(...Logger.normalizeLogArgs(safeArgs)); + this.logger.fatal(...Logger.normalizeLogArgs(args)); return; } } @@ -273,10 +266,6 @@ 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; } @@ -292,39 +281,19 @@ export class Logger { const output: Record = {}; for (const [key, item] of Object.entries(value)) { - output[key] = Logger.isSensitiveLogKey(key) - ? "[REDACTED]" - : Logger.toSerializableLogValue(item, seen); + output[key] = 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, ): Record { const output: Record = { name: error.name, - message: Logger.redactSensitiveString(error.message), - stack: error.stack - ? Logger.redactSensitiveString(error.stack) - : undefined, + message: error.message, + stack: error.stack, }; if (seen.has(error)) { @@ -334,12 +303,10 @@ export class Logger { for (const key of Object.getOwnPropertyNames(error)) { if (key in output) continue; - output[key] = Logger.isSensitiveLogKey(key) - ? "[REDACTED]" - : Logger.toSerializableLogValue( - (error as unknown as Record)[key], - seen, - ); + output[key] = Logger.toSerializableLogValue( + (error as unknown as Record)[key], + seen, + ); } return output; }