feat(payment): expand Stripe methods and checkout handoff
Docker Image / Build and Push Docker Image (push) Successful in 2m10s
Docker Image / Build and Push Docker Image (push) Successful in 2m10s
This commit is contained in:
@@ -13,6 +13,7 @@ export interface MockCoreApisOptions {
|
|||||||
paidImageInsufficientCreditsFlow?: boolean;
|
paidImageInsufficientCreditsFlow?: boolean;
|
||||||
paidVoiceInsufficientCreditsFlow?: boolean;
|
paidVoiceInsufficientCreditsFlow?: boolean;
|
||||||
topUpHandoffFlow?: boolean;
|
topUpHandoffFlow?: boolean;
|
||||||
|
checkoutHandoffFlow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
|
||||||
@@ -23,6 +24,7 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
|
|||||||
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
|
paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
|
||||||
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
|
paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
|
||||||
topUpHandoffFlow: options.topUpHandoffFlow ?? false,
|
topUpHandoffFlow: options.topUpHandoffFlow ?? false,
|
||||||
|
checkoutHandoffFlow: options.checkoutHandoffFlow ?? false,
|
||||||
};
|
};
|
||||||
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
|
const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
|
|||||||
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
|
chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
|
||||||
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
|
isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
|
||||||
topUpHandoffFlow: chatOptions.topUpHandoffFlow,
|
topUpHandoffFlow: chatOptions.topUpHandoffFlow,
|
||||||
|
checkoutHandoffFlow: chatOptions.checkoutHandoffFlow,
|
||||||
});
|
});
|
||||||
await registerCharacterMocks(page);
|
await registerCharacterMocks(page);
|
||||||
await registerUserMocks(page);
|
await registerUserMocks(page);
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
import { apiEnvelope } from "../data/common";
|
import { apiEnvelope } from "../data/common";
|
||||||
import { emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth";
|
import { checkoutHandoffResponse, emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, topUpHandoffResponse } from "../data/auth";
|
||||||
|
|
||||||
export interface AuthMockState {
|
export interface AuthMockState {
|
||||||
chatSendTokenRefreshFlow: boolean;
|
chatSendTokenRefreshFlow: boolean;
|
||||||
isChatSendTokenExpired: () => boolean;
|
isChatSendTokenExpired: () => boolean;
|
||||||
topUpHandoffFlow: boolean;
|
topUpHandoffFlow: boolean;
|
||||||
|
checkoutHandoffFlow: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAuthMocks(page: Page, state: AuthMockState) {
|
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) => {
|
await page.route("**/api/auth/handoff/topup/consume", async (route) => {
|
||||||
if (!state.topUpHandoffFlow) {
|
if (!state.topUpHandoffFlow) {
|
||||||
await route.continue();
|
await route.continue();
|
||||||
|
|||||||
@@ -63,3 +63,15 @@ export const topUpHandoffResponse = {
|
|||||||
loginProvider: "facebook_messenger",
|
loginProvider: "facebook_messenger",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const checkoutHandoffResponse = {
|
||||||
|
token: "e2e-checkout-token",
|
||||||
|
refreshToken: "e2e-checkout-refresh-token",
|
||||||
|
loginStatus: "email",
|
||||||
|
user: e2eEmailUser,
|
||||||
|
checkoutIntent: {
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "13ec8a10-58d7-4d24-b66b-8db5699a1aa8",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import {
|
||||||
|
clearBrowserState,
|
||||||
|
seedEmailSession,
|
||||||
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
const facebookAndroidUserAgent =
|
||||||
|
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " +
|
||||||
|
"(KHTML, like Gecko) Version/4.0 Chrome/126.0.0.0 Mobile Safari/537.36 " +
|
||||||
|
"[FB_IAB/FB4A;FBAV/566.0.0.48.73;]";
|
||||||
|
const handoffToken = "checkout-handoff-token-that-is-at-least-32-characters";
|
||||||
|
|
||||||
|
test.use({ userAgent: facebookAndroidUserAgent });
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("consumes the handoff, removes its token, and does not create an order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockCoreApis(page, { checkoutHandoffFlow: true });
|
||||||
|
let createOrderRequests = 0;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.url().includes("/api/payment/create-order")) {
|
||||||
|
createOrderRequests += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const consumeRequest = page.waitForRequest(
|
||||||
|
"**/api/auth/handoff/checkout/consume",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`/external-entry?target=checkout&handoffToken=${encodeURIComponent(handoffToken)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await consumeRequest).postDataJSON()).toEqual({ handoffToken });
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
/\/subscription\?planId=vip_monthly&autoRenew=1&commercialOfferId=.*&payChannel=stripe/,
|
||||||
|
);
|
||||||
|
expect(page.url()).not.toContain("handoffToken");
|
||||||
|
await expect.poll(() => createOrderRequests).toBe(0);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
loginProvider: localStorage.getItem("cozsweet:login_provider"),
|
||||||
|
loginToken: localStorage.getItem("cozsweet:login_token"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.toEqual({ loginProvider: "email", loginToken: "e2e-checkout-token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offers the Facebook external-browser path before creating a Stripe order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockCoreApis(page);
|
||||||
|
let createOrderRequests = 0;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.url().includes("/api/payment/create-order")) {
|
||||||
|
createOrderRequests += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.route("**/api/auth/handoff/checkout", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
externalUrl:
|
||||||
|
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque-token",
|
||||||
|
expiresAt: "2026-07-28T16:00:00+00:00",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedEmailSession(page);
|
||||||
|
await page.goto("/subscription?type=vip&payChannel=stripe");
|
||||||
|
await page.getByRole("button", { name: /Monthly,/i }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("dialog", { name: "Automatic Renewal Confirmation" })
|
||||||
|
.getByRole("button", { name: "Confirm" })
|
||||||
|
.click();
|
||||||
|
const externalButton = page.getByRole("button", {
|
||||||
|
name: "Open in browser for more payment methods",
|
||||||
|
});
|
||||||
|
await expect(externalButton).toBeVisible();
|
||||||
|
const handoffRequest = page.waitForRequest("**/api/auth/handoff/checkout");
|
||||||
|
await externalButton.click();
|
||||||
|
expect((await handoffRequest).postDataJSON()).toMatchObject({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
});
|
||||||
|
expect(createOrderRequests).toBe(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { createCheckoutHandoff, openUrlWithExternalBrowser } = vi.hoisted(
|
||||||
|
() => ({
|
||||||
|
createCheckoutHandoff: vi.fn(),
|
||||||
|
openUrlWithExternalBrowser: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/checkout_handoff", () => ({ createCheckoutHandoff }));
|
||||||
|
vi.mock("@/utils/url-launcher-util", () => ({
|
||||||
|
UrlLauncherUtil: { openUrlWithExternalBrowser },
|
||||||
|
}));
|
||||||
|
vi.mock("@/utils/browser-detect", () => ({
|
||||||
|
BrowserDetector: { isFacebookInAppBrowser: () => true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ExternalBrowserCheckoutButton } from "../external-browser-checkout-button";
|
||||||
|
|
||||||
|
describe("ExternalBrowserCheckoutButton", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
createCheckoutHandoff.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
externalUrl:
|
||||||
|
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque",
|
||||||
|
expiresAt: "2026-07-28T16:00:00+00:00",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates only a handoff before opening the external browser", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<ExternalBrowserCheckoutButton
|
||||||
|
checkoutIntent={{
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = container.querySelector("button");
|
||||||
|
expect(button?.textContent).toContain(
|
||||||
|
"Open in browser for more payment methods",
|
||||||
|
);
|
||||||
|
await act(async () => button?.click());
|
||||||
|
|
||||||
|
expect(createCheckoutHandoff).toHaveBeenCalledWith({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
});
|
||||||
|
expect(openUrlWithExternalBrowser).toHaveBeenCalledWith(
|
||||||
|
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,8 @@ const hiddenLaunch = {
|
|||||||
shouldShowQrisDialog: false,
|
shouldShowQrisDialog: false,
|
||||||
shouldShowStripeDialog: false,
|
shouldShowStripeDialog: false,
|
||||||
stripeClientSecret: null,
|
stripeClientSecret: null,
|
||||||
|
stripeCustomerSessionClientSecret: null,
|
||||||
|
savedPaymentMethodsEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("PaymentLaunchDialogs", () => {
|
describe("PaymentLaunchDialogs", () => {
|
||||||
|
|||||||
@@ -2,18 +2,57 @@ import { act } from "react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { StripePaymentDialogLoading } from "../lazy-stripe-payment-dialog";
|
const stripeConfirmPayment = vi.fn(async () => ({}));
|
||||||
|
const elementsSubmit = vi.fn(async () => ({}));
|
||||||
|
let capturedElementsOptions: Record<string, unknown> | null = null;
|
||||||
|
let capturedExpressProps: Record<string, unknown> | null = null;
|
||||||
|
let capturedPaymentProps: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = "pk_test_checkout";
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@stripe/stripe-js", () => ({
|
||||||
|
loadStripe: vi.fn(() => Promise.resolve({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@stripe/react-stripe-js", () => ({
|
||||||
|
Elements: ({
|
||||||
|
children,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
capturedElementsOptions = options;
|
||||||
|
return <>{children}</>;
|
||||||
|
},
|
||||||
|
ExpressCheckoutElement: (props: Record<string, unknown>) => {
|
||||||
|
capturedExpressProps = props;
|
||||||
|
return <div data-testid="express-checkout" />;
|
||||||
|
},
|
||||||
|
PaymentElement: (props: Record<string, unknown>) => {
|
||||||
|
capturedPaymentProps = props;
|
||||||
|
return <div data-testid="payment-element" />;
|
||||||
|
},
|
||||||
|
useElements: () => ({ submit: elementsSubmit }),
|
||||||
|
useStripe: () => ({ confirmPayment: stripeConfirmPayment }),
|
||||||
|
}));
|
||||||
|
|
||||||
import { StripePaymentDialog } from "../stripe-payment-dialog";
|
import { StripePaymentDialog } from "../stripe-payment-dialog";
|
||||||
|
|
||||||
describe("Stripe payment portal states", () => {
|
describe("StripePaymentDialog", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
capturedElementsOptions = null;
|
||||||
|
capturedExpressProps = null;
|
||||||
|
capturedPaymentProps = null;
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
container.style.transform = "translateX(50%)";
|
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
});
|
});
|
||||||
@@ -24,40 +63,101 @@ describe("Stripe payment portal states", () => {
|
|||||||
document.body.style.overflow = "";
|
document.body.style.overflow = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
it("portals the unavailable state and keeps its explicit close action", () => {
|
it("renders Express Checkout first and a collapsed Pay by card section last", () => {
|
||||||
const onClose = vi.fn();
|
|
||||||
|
|
||||||
act(() =>
|
act(() =>
|
||||||
root.render(
|
root.render(
|
||||||
<StripePaymentDialog
|
<StripePaymentDialog
|
||||||
clientSecret="client-secret"
|
clientSecret="pi_123_secret_payment"
|
||||||
onClose={onClose}
|
customerSessionClientSecret="cuss_123_secret_saved"
|
||||||
|
savedPaymentMethodsEnabled
|
||||||
|
onClose={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const dialog = document.body.querySelector('[role="alertdialog"]');
|
expect(capturedElementsOptions).toMatchObject({
|
||||||
expect(dialog).not.toBeNull();
|
clientSecret: "pi_123_secret_payment",
|
||||||
expect(container.contains(dialog)).toBe(false);
|
customerSessionClientSecret: "cuss_123_secret_saved",
|
||||||
expect(dialog?.textContent).toContain("Payment unavailable");
|
});
|
||||||
expect(dialog?.getAttribute("aria-labelledby")).toBeTruthy();
|
expect(capturedExpressProps?.options).toMatchObject({
|
||||||
expect(dialog?.getAttribute("aria-describedby")).toBeTruthy();
|
paymentMethodOrder: [
|
||||||
|
"apple_pay",
|
||||||
const okButton = Array.from(dialog?.querySelectorAll("button") ?? []).find(
|
"google_pay",
|
||||||
(button) => button.textContent === "OK",
|
"link",
|
||||||
);
|
"paypal",
|
||||||
act(() => okButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
"amazon_pay",
|
||||||
expect(onClose).toHaveBeenCalledOnce();
|
"klarna",
|
||||||
|
],
|
||||||
|
paymentMethods: {
|
||||||
|
applePay: "always",
|
||||||
|
googlePay: "always",
|
||||||
|
link: "auto",
|
||||||
|
paypal: "auto",
|
||||||
|
amazonPay: "auto",
|
||||||
|
klarna: "auto",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(capturedPaymentProps?.options).toMatchObject({
|
||||||
|
layout: { type: "accordion", defaultCollapsed: true },
|
||||||
|
paymentMethodOrder: ["card"],
|
||||||
|
wallets: { applePay: "never", googlePay: "never", link: "never" },
|
||||||
|
});
|
||||||
|
const text = document.body.textContent ?? "";
|
||||||
|
expect(text).toContain("Pay by card");
|
||||||
|
const express = document.body.querySelector('[data-testid="express-checkout"]');
|
||||||
|
const card = document.body.querySelector('[data-testid="payment-element"]');
|
||||||
|
expect(express).not.toBeNull();
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
express!.compareDocumentPosition(card!) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("portals the lazy loading state", () => {
|
it("does not pass an invalid or disabled Customer Session secret", () => {
|
||||||
act(() => root.render(<StripePaymentDialogLoading />));
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret="pi_123_secret_payment"
|
||||||
|
customerSessionClientSecret="invalid"
|
||||||
|
savedPaymentMethodsEnabled={false}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const dialog = document.body.querySelector('[role="dialog"]');
|
expect(capturedElementsOptions).not.toHaveProperty(
|
||||||
expect(dialog).not.toBeNull();
|
"customerSessionClientSecret",
|
||||||
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();
|
it("uses the same confirmation path for an Express Checkout approval", async () => {
|
||||||
|
const onConfirmed = vi.fn();
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret="pi_123_secret_payment"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onConfirmed={onConfirmed}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onConfirm = capturedExpressProps?.onConfirm as
|
||||||
|
| ((event: { paymentFailed: ReturnType<typeof vi.fn> }) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
expect(onConfirm).toBeTypeOf("function");
|
||||||
|
await act(async () => {
|
||||||
|
await onConfirm?.({ paymentFailed: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stripeConfirmPayment).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stripeConfirmPayment).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
elements: expect.any(Object),
|
||||||
|
redirect: "if_required",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onConfirmed).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
import type { CheckoutIntent } from "@/data/schemas/auth";
|
||||||
|
import { createCheckoutHandoff } from "@/lib/auth/checkout_handoff";
|
||||||
|
import { BrowserDetector } from "@/utils/browser-detect";
|
||||||
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
|
import { Result } from "@/utils/result";
|
||||||
|
import { UrlLauncherUtil } from "@/utils/url-launcher-util";
|
||||||
|
|
||||||
|
export interface ExternalBrowserCheckoutButtonProps {
|
||||||
|
checkoutIntent: CheckoutIntent;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExternalBrowserCheckoutButton({
|
||||||
|
checkoutIntent,
|
||||||
|
disabled = false,
|
||||||
|
}: ExternalBrowserCheckoutButtonProps) {
|
||||||
|
const isFacebookBrowser = useSyncExternalStore(
|
||||||
|
() => () => undefined,
|
||||||
|
() => BrowserDetector.isFacebookInAppBrowser(),
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const [isOpening, setIsOpening] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!isFacebookBrowser) return null;
|
||||||
|
|
||||||
|
const handleOpen = async () => {
|
||||||
|
if (disabled || isOpening) return;
|
||||||
|
setIsOpening(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
const result = await createCheckoutHandoff(checkoutIntent);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
setErrorMessage(
|
||||||
|
ExceptionHandler.message(
|
||||||
|
result.error,
|
||||||
|
"Could not open this checkout in your browser. Please sign in and try again.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsOpening(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
UrlLauncherUtil.openUrlWithExternalBrowser(result.data.externalUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 w-full text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-h-11 w-full cursor-pointer rounded-full border border-black/15 bg-white px-4 py-2.5 text-sm font-semibold text-text-foreground disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
|
disabled={disabled || isOpening}
|
||||||
|
onClick={() => void handleOpen()}
|
||||||
|
>
|
||||||
|
{isOpening
|
||||||
|
? "Opening browser..."
|
||||||
|
: "Open in browser for more payment methods"}
|
||||||
|
</button>
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="mt-2 text-sm text-[#c0392b]" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ type PaymentLaunchDialogFlow = Pick<
|
|||||||
| "shouldShowQrisDialog"
|
| "shouldShowQrisDialog"
|
||||||
| "shouldShowStripeDialog"
|
| "shouldShowStripeDialog"
|
||||||
| "stripeClientSecret"
|
| "stripeClientSecret"
|
||||||
|
| "stripeCustomerSessionClientSecret"
|
||||||
|
| "savedPaymentMethodsEnabled"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface PaymentLaunchDialogsProps {
|
export interface PaymentLaunchDialogsProps {
|
||||||
@@ -64,6 +66,10 @@ export function PaymentLaunchDialogs({
|
|||||||
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
||||||
<LazyStripePaymentDialog
|
<LazyStripePaymentDialog
|
||||||
clientSecret={launch.stripeClientSecret}
|
clientSecret={launch.stripeClientSecret}
|
||||||
|
customerSessionClientSecret={
|
||||||
|
launch.stripeCustomerSessionClientSecret
|
||||||
|
}
|
||||||
|
savedPaymentMethodsEnabled={launch.savedPaymentMethodsEnabled}
|
||||||
returnPath={stripeReturnPath}
|
returnPath={stripeReturnPath}
|
||||||
onClose={launch.handleStripeClose}
|
onClose={launch.handleStripeClose}
|
||||||
onConfirmed={launch.handleStripeConfirmed}
|
onConfirmed={launch.handleStripeConfirmed}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export const stripePaymentDialogStyles = {
|
|||||||
content:
|
content:
|
||||||
"m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]",
|
"m-0 text-(length:--responsive-body,14px) leading-normal text-[#393939]",
|
||||||
form: "flex flex-col gap-(--page-section-gap,18px)",
|
form: "flex flex-col gap-(--page-section-gap,18px)",
|
||||||
|
expressSection: "w-full empty:hidden",
|
||||||
|
cardSection:
|
||||||
|
"flex w-full flex-col gap-(--spacing-sm,8px) border-t border-black/10 pt-(--page-section-gap,18px)",
|
||||||
|
cardTitle:
|
||||||
|
"m-0 text-(length:--responsive-body,16px) font-semibold text-text-foreground",
|
||||||
error:
|
error:
|
||||||
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
|
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
|
||||||
actions: "flex w-full gap-(--spacing-md,12px)",
|
actions: "flex w-full gap-(--spacing-md,12px)",
|
||||||
|
|||||||
@@ -4,19 +4,28 @@
|
|||||||
*
|
*
|
||||||
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
|
||||||
*/
|
*/
|
||||||
import { useId, useState, type FormEvent } from "react";
|
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
|
||||||
import {
|
import {
|
||||||
Elements,
|
Elements,
|
||||||
|
ExpressCheckoutElement,
|
||||||
PaymentElement,
|
PaymentElement,
|
||||||
useElements,
|
useElements,
|
||||||
useStripe,
|
useStripe,
|
||||||
} from "@stripe/react-stripe-js";
|
} from "@stripe/react-stripe-js";
|
||||||
import { loadStripe } from "@stripe/stripe-js";
|
import {
|
||||||
|
loadStripe,
|
||||||
|
type AvailablePaymentMethods,
|
||||||
|
type StripeExpressCheckoutElementConfirmEvent,
|
||||||
|
} from "@stripe/stripe-js";
|
||||||
|
|
||||||
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
import { ModalPortal } from "@/app/_components/core/modal-portal";
|
||||||
import { ExceptionHandler } from "@/core/errors";
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
|
import { BrowserDetector } from "@/utils/browser-detect";
|
||||||
|
import { PlatformDetector } from "@/utils/platform-detect";
|
||||||
|
import { getStripeCustomerSessionClientSecret } from "@/lib/payment/payment_launch";
|
||||||
|
import { behaviorAnalytics } from "@/lib/analytics";
|
||||||
|
|
||||||
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
||||||
|
|
||||||
@@ -26,8 +35,16 @@ const stripePromise = stripePublishableKey
|
|||||||
? loadStripe(stripePublishableKey)
|
? loadStripe(stripePublishableKey)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
type ExpressAvailabilityValue = boolean | { available: boolean } | undefined;
|
||||||
|
|
||||||
|
function isExpressMethodAvailable(value: ExpressAvailabilityValue): boolean {
|
||||||
|
return value === true || (typeof value === "object" && value.available);
|
||||||
|
}
|
||||||
|
|
||||||
export interface StripePaymentDialogProps {
|
export interface StripePaymentDialogProps {
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
|
customerSessionClientSecret?: string | null;
|
||||||
|
savedPaymentMethodsEnabled?: boolean;
|
||||||
returnPath?: string;
|
returnPath?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirmed?: () => void;
|
onConfirmed?: () => void;
|
||||||
@@ -35,12 +52,19 @@ export interface StripePaymentDialogProps {
|
|||||||
|
|
||||||
export function StripePaymentDialog({
|
export function StripePaymentDialog({
|
||||||
clientSecret,
|
clientSecret,
|
||||||
|
customerSessionClientSecret = null,
|
||||||
|
savedPaymentMethodsEnabled = false,
|
||||||
returnPath = ROUTES.subscription + "/success",
|
returnPath = ROUTES.subscription + "/success",
|
||||||
onClose,
|
onClose,
|
||||||
onConfirmed,
|
onConfirmed,
|
||||||
}: StripePaymentDialogProps) {
|
}: StripePaymentDialogProps) {
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const descriptionId = useId();
|
const descriptionId = useId();
|
||||||
|
const savedCardClientSecret = getStripeCustomerSessionClientSecret({
|
||||||
|
provider: "stripe",
|
||||||
|
customerSessionClientSecret,
|
||||||
|
savedPaymentMethodsEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
if (!stripePromise) {
|
if (!stripePromise) {
|
||||||
return (
|
return (
|
||||||
@@ -100,6 +124,9 @@ export function StripePaymentDialog({
|
|||||||
stripe={stripePromise}
|
stripe={stripePromise}
|
||||||
options={{
|
options={{
|
||||||
clientSecret,
|
clientSecret,
|
||||||
|
...(savedCardClientSecret
|
||||||
|
? { customerSessionClientSecret: savedCardClientSecret }
|
||||||
|
: {}),
|
||||||
appearance: {
|
appearance: {
|
||||||
theme: "stripe",
|
theme: "stripe",
|
||||||
variables: {
|
variables: {
|
||||||
@@ -132,6 +159,20 @@ function StripePaymentForm({
|
|||||||
const elements = useElements();
|
const elements = useElements();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
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<
|
const handleLoadError = (event: Parameters<
|
||||||
NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
|
NonNullable<React.ComponentProps<typeof PaymentElement>["onLoadError"]>
|
||||||
@@ -150,38 +191,118 @@ function StripePaymentForm({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const updateExpressAvailability = (
|
||||||
event.preventDefault();
|
availablePaymentMethods:
|
||||||
if (!stripe || !elements || isSubmitting) return;
|
| AvailablePaymentMethods
|
||||||
|
| {
|
||||||
|
applePay?: { available: boolean };
|
||||||
|
googlePay?: { available: boolean };
|
||||||
|
link?: { available: boolean };
|
||||||
|
paypal?: { available: boolean };
|
||||||
|
amazonPay?: { available: boolean };
|
||||||
|
klarna?: { available: boolean };
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
) => {
|
||||||
|
const availability = {
|
||||||
|
applePay: isExpressMethodAvailable(availablePaymentMethods?.applePay),
|
||||||
|
googlePay: isExpressMethodAvailable(availablePaymentMethods?.googlePay),
|
||||||
|
link: isExpressMethodAvailable(availablePaymentMethods?.link),
|
||||||
|
paypal: isExpressMethodAvailable(availablePaymentMethods?.paypal),
|
||||||
|
amazonPay: isExpressMethodAvailable(availablePaymentMethods?.amazonPay),
|
||||||
|
klarna: isExpressMethodAvailable(availablePaymentMethods?.klarna),
|
||||||
|
};
|
||||||
|
setHasExpressPaymentMethods(Object.values(availability).some(Boolean));
|
||||||
|
const browser = BrowserDetector.isFacebookInAppBrowser()
|
||||||
|
? "facebook"
|
||||||
|
: BrowserDetector.getBrowserName() || "unknown";
|
||||||
|
const platform = PlatformDetector.getPlatform();
|
||||||
|
const metadata = {
|
||||||
|
browser,
|
||||||
|
platform,
|
||||||
|
availablePaymentMethods: availability,
|
||||||
|
};
|
||||||
|
log.info(
|
||||||
|
"[stripe-payment-dialog] Express Checkout availability",
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
behaviorAnalytics.elementClick(
|
||||||
|
"stripe.express_checkout_availability",
|
||||||
|
"Stripe Express Checkout availability",
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExpressLoadError = (event: Parameters<
|
||||||
|
NonNullable<
|
||||||
|
React.ComponentProps<typeof ExpressCheckoutElement>["onLoadError"]
|
||||||
|
>
|
||||||
|
>[0]) => {
|
||||||
|
setHasExpressPaymentMethods(false);
|
||||||
|
log.warn("[stripe-payment-dialog] Express Checkout load failed", {
|
||||||
|
type: event.error.type,
|
||||||
|
code: event.error.code,
|
||||||
|
message: event.error.message,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPayment = async (
|
||||||
|
options: {
|
||||||
|
submitPaymentElement: boolean;
|
||||||
|
expressEvent?: StripeExpressCheckoutElementConfirmEvent;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (!stripe || !elements || submittingRef.current) return;
|
||||||
|
|
||||||
|
submittingRef.current = true;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
const { error: submitError } = await elements.submit();
|
if (options.submitPaymentElement) {
|
||||||
if (submitError) {
|
let submitError: unknown;
|
||||||
setErrorMessage(
|
try {
|
||||||
ExceptionHandler.message(submitError, "Please check your payment info."),
|
({ error: submitError } = await elements.submit());
|
||||||
);
|
} catch (error) {
|
||||||
setIsSubmitting(false);
|
submitError = error;
|
||||||
return;
|
}
|
||||||
|
if (submitError) {
|
||||||
|
setErrorMessage(
|
||||||
|
ExceptionHandler.message(
|
||||||
|
submitError,
|
||||||
|
"Please check your payment info.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
submittingRef.current = false;
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const returnUrl = new URL(
|
const returnUrl = new URL(
|
||||||
returnPath ?? ROUTES.subscription + "/success",
|
returnPath ?? ROUTES.subscription + "/success",
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
);
|
);
|
||||||
const { error } = await stripe.confirmPayment({
|
let confirmationError: unknown;
|
||||||
elements,
|
try {
|
||||||
confirmParams: {
|
({ error: confirmationError } = await stripe.confirmPayment({
|
||||||
return_url: returnUrl.toString(),
|
elements,
|
||||||
},
|
confirmParams: {
|
||||||
redirect: "if_required",
|
return_url: returnUrl.toString(),
|
||||||
});
|
},
|
||||||
|
redirect: "if_required",
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
confirmationError = error;
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (confirmationError) {
|
||||||
setErrorMessage(
|
const message = ExceptionHandler.message(
|
||||||
ExceptionHandler.message(error, "Payment confirmation failed."),
|
confirmationError,
|
||||||
|
"Payment confirmation failed.",
|
||||||
);
|
);
|
||||||
|
setErrorMessage(message);
|
||||||
|
options.expressEvent?.paymentFailed({ reason: "fail", message });
|
||||||
|
submittingRef.current = false;
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -189,9 +310,74 @@ function StripePaymentForm({
|
|||||||
onConfirmed?.();
|
onConfirmed?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await confirmPayment({ submitPaymentElement: true });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className={styles.form} onSubmit={handleSubmit}>
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
<PaymentElement onLoadError={handleLoadError} />
|
<section
|
||||||
|
className={styles.expressSection}
|
||||||
|
hidden={hasExpressPaymentMethods === false}
|
||||||
|
aria-label="Express payment methods"
|
||||||
|
>
|
||||||
|
<ExpressCheckoutElement
|
||||||
|
options={{
|
||||||
|
paymentMethodOrder: [
|
||||||
|
"apple_pay",
|
||||||
|
"google_pay",
|
||||||
|
"link",
|
||||||
|
"paypal",
|
||||||
|
"amazon_pay",
|
||||||
|
"klarna",
|
||||||
|
],
|
||||||
|
paymentMethods: {
|
||||||
|
applePay: "always",
|
||||||
|
googlePay: "always",
|
||||||
|
link: "auto",
|
||||||
|
paypal: "auto",
|
||||||
|
amazonPay: "auto",
|
||||||
|
klarna: "auto",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
maxColumns: expressMaxColumns,
|
||||||
|
maxRows: 6,
|
||||||
|
overflow: "never",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onReady={(event) =>
|
||||||
|
updateExpressAvailability(event.availablePaymentMethods)
|
||||||
|
}
|
||||||
|
onAvailablePaymentMethodsChange={(event) =>
|
||||||
|
updateExpressAvailability(event.paymentMethods)
|
||||||
|
}
|
||||||
|
onLoadError={handleExpressLoadError}
|
||||||
|
onConfirm={(event) =>
|
||||||
|
void confirmPayment({
|
||||||
|
submitPaymentElement: false,
|
||||||
|
expressEvent: event,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className={styles.cardSection} aria-labelledby="pay-by-card-title">
|
||||||
|
<h3 id="pay-by-card-title" className={styles.cardTitle}>
|
||||||
|
Pay by card
|
||||||
|
</h3>
|
||||||
|
<PaymentElement
|
||||||
|
options={{
|
||||||
|
layout: { type: "accordion", defaultCollapsed: true },
|
||||||
|
paymentMethodOrder: ["card"],
|
||||||
|
wallets: {
|
||||||
|
applePay: "never",
|
||||||
|
googlePay: "never",
|
||||||
|
link: "never",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onLoadError={handleLoadError}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getPaymentUrl,
|
getPaymentUrl,
|
||||||
|
getStripeCustomerSessionClientSecret,
|
||||||
getStripeClientSecret,
|
getStripeClientSecret,
|
||||||
isEzpayPayment,
|
isEzpayPayment,
|
||||||
launchEzpayRedirect,
|
launchEzpayRedirect,
|
||||||
@@ -59,6 +60,8 @@ export interface PaymentLaunchFlow {
|
|||||||
shouldShowQrisDialog: boolean;
|
shouldShowQrisDialog: boolean;
|
||||||
shouldShowStripeDialog: boolean;
|
shouldShowStripeDialog: boolean;
|
||||||
stripeClientSecret: string | null;
|
stripeClientSecret: string | null;
|
||||||
|
stripeCustomerSessionClientSecret: string | null;
|
||||||
|
savedPaymentMethodsEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QrisPaymentDetails {
|
export interface QrisPaymentDetails {
|
||||||
@@ -165,6 +168,9 @@ export function usePaymentLaunchFlow({
|
|||||||
const stripeClientSecret = payment.payParams
|
const stripeClientSecret = payment.payParams
|
||||||
? getStripeClientSecret(payment.payParams)
|
? getStripeClientSecret(payment.payParams)
|
||||||
: null;
|
: null;
|
||||||
|
const stripeCustomerSessionClientSecret = payment.payParams
|
||||||
|
? getStripeCustomerSessionClientSecret(payment.payParams)
|
||||||
|
: null;
|
||||||
const ezpayLaunchTarget =
|
const ezpayLaunchTarget =
|
||||||
payment.payParams && isEzpayPayment(payment.payParams)
|
payment.payParams && isEzpayPayment(payment.payParams)
|
||||||
? resolveEzpayLaunchTarget(payment.payParams)
|
? resolveEzpayLaunchTarget(payment.payParams)
|
||||||
@@ -385,5 +391,8 @@ export function usePaymentLaunchFlow({
|
|||||||
shouldShowQrisDialog,
|
shouldShowQrisDialog,
|
||||||
shouldShowStripeDialog,
|
shouldShowStripeDialog,
|
||||||
stripeClientSecret,
|
stripeClientSecret,
|
||||||
|
stripeCustomerSessionClientSecret,
|
||||||
|
savedPaymentMethodsEnabled:
|
||||||
|
stripeCustomerSessionClientSecret !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface UsePaymentRouteFlowInput {
|
|||||||
characterId?: string;
|
characterId?: string;
|
||||||
initialCategory?: string | null;
|
initialCategory?: string | null;
|
||||||
initialPlanId?: string | null;
|
initialPlanId?: string | null;
|
||||||
|
initialAutoRenew?: boolean | null;
|
||||||
commercialOfferId?: string | null;
|
commercialOfferId?: string | null;
|
||||||
resumeOrderId?: string | null;
|
resumeOrderId?: string | null;
|
||||||
chatActionId?: string | null;
|
chatActionId?: string | null;
|
||||||
@@ -44,6 +45,7 @@ export function usePaymentRouteFlow({
|
|||||||
characterId,
|
characterId,
|
||||||
initialCategory = null,
|
initialCategory = null,
|
||||||
initialPlanId = null,
|
initialPlanId = null,
|
||||||
|
initialAutoRenew = null,
|
||||||
commercialOfferId = null,
|
commercialOfferId = null,
|
||||||
resumeOrderId = null,
|
resumeOrderId = null,
|
||||||
chatActionId = null,
|
chatActionId = null,
|
||||||
@@ -57,6 +59,7 @@ export function usePaymentRouteFlow({
|
|||||||
characterId ?? "",
|
characterId ?? "",
|
||||||
initialCategory ?? "",
|
initialCategory ?? "",
|
||||||
initialPlanId ?? "",
|
initialPlanId ?? "",
|
||||||
|
initialAutoRenew === null ? "" : String(initialAutoRenew),
|
||||||
commercialOfferId ?? "",
|
commercialOfferId ?? "",
|
||||||
chatActionId ?? "",
|
chatActionId ?? "",
|
||||||
].join(":");
|
].join(":");
|
||||||
@@ -78,6 +81,9 @@ export function usePaymentRouteFlow({
|
|||||||
...(characterId ? { characterId } : {}),
|
...(characterId ? { characterId } : {}),
|
||||||
...(initialCategory ? { category: initialCategory } : {}),
|
...(initialCategory ? { category: initialCategory } : {}),
|
||||||
...(initialPlanId ? { planId: initialPlanId } : {}),
|
...(initialPlanId ? { planId: initialPlanId } : {}),
|
||||||
|
...(initialAutoRenew === null
|
||||||
|
? {}
|
||||||
|
: { autoRenew: initialAutoRenew }),
|
||||||
...(commercialOfferId ? { commercialOfferId } : {}),
|
...(commercialOfferId ? { commercialOfferId } : {}),
|
||||||
...(chatActionId ? { chatActionId } : {}),
|
...(chatActionId ? { chatActionId } : {}),
|
||||||
});
|
});
|
||||||
@@ -89,6 +95,7 @@ export function usePaymentRouteFlow({
|
|||||||
chatActionId,
|
chatActionId,
|
||||||
initialCategory,
|
initialCategory,
|
||||||
initialPlanId,
|
initialPlanId,
|
||||||
|
initialAutoRenew,
|
||||||
initialPayChannel,
|
initialPayChannel,
|
||||||
paymentDispatch,
|
paymentDispatch,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
persistExternalEntryPayload,
|
persistExternalEntryPayload,
|
||||||
|
resolveCheckoutIntentDestination,
|
||||||
resolveExternalEntryDestination,
|
resolveExternalEntryDestination,
|
||||||
resolveExternalEntryPromotionType,
|
resolveExternalEntryPromotionType,
|
||||||
resolveExternalEntryTarget,
|
resolveExternalEntryTarget,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff";
|
import { consumeTopUpHandoff } from "@/lib/auth/top_up_handoff";
|
||||||
|
import { consumeCheckoutHandoff } from "@/lib/auth/checkout_handoff";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
import {
|
import {
|
||||||
isFavoriteEntryRequest,
|
isFavoriteEntryRequest,
|
||||||
@@ -61,6 +63,9 @@ export default function ExternalEntryPersist({
|
|||||||
const [hasPersisted, setHasPersisted] = useState(false);
|
const [hasPersisted, setHasPersisted] = useState(false);
|
||||||
const [handoffError, setHandoffError] = useState<string | null>(null);
|
const [handoffError, setHandoffError] = useState<string | null>(null);
|
||||||
const [handoffCompleted, setHandoffCompleted] = useState(false);
|
const [handoffCompleted, setHandoffCompleted] = useState(false);
|
||||||
|
const [checkoutDestination, setCheckoutDestination] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const hasNavigatedRef = useRef(false);
|
const hasNavigatedRef = useRef(false);
|
||||||
const handoffStartedRef = useRef(false);
|
const handoffStartedRef = useRef(false);
|
||||||
const targetRoute = resolveExternalEntryTarget({ target });
|
const targetRoute = resolveExternalEntryTarget({ target });
|
||||||
@@ -71,11 +76,14 @@ export default function ExternalEntryPersist({
|
|||||||
mode,
|
mode,
|
||||||
promotionType,
|
promotionType,
|
||||||
});
|
});
|
||||||
const isTopUpHandoff = targetRoute === ROUTES.subscription;
|
const normalizedTarget = target?.trim().toLowerCase() ?? "";
|
||||||
|
const isTopUpHandoff = normalizedTarget === "topup";
|
||||||
|
const isCheckoutHandoff = normalizedTarget === "checkout";
|
||||||
|
const isLoginHandoff = isTopUpHandoff || isCheckoutHandoff;
|
||||||
const displayedHandoffError =
|
const displayedHandoffError =
|
||||||
handoffError ??
|
handoffError ??
|
||||||
(isTopUpHandoff && !hasValue(handoffToken)
|
(isLoginHandoff && !hasValue(handoffToken)
|
||||||
? "This top-up link is invalid. Please request a new link in Messenger."
|
? "This checkout link is invalid. Please return and request a new link."
|
||||||
: null);
|
: null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -128,7 +136,7 @@ export default function ExternalEntryPersist({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasNavigatedRef.current || !hasPersisted) return;
|
if (hasNavigatedRef.current || !hasPersisted) return;
|
||||||
|
|
||||||
if (isTopUpHandoff) {
|
if (isLoginHandoff) {
|
||||||
if (!authState.hasInitialized || authState.isLoading) return;
|
if (!authState.hasInitialized || authState.isLoading) return;
|
||||||
if (handoffCompleted) {
|
if (handoffCompleted) {
|
||||||
if (
|
if (
|
||||||
@@ -138,25 +146,60 @@ export default function ExternalEntryPersist({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasNavigatedRef.current = true;
|
hasNavigatedRef.current = true;
|
||||||
router.replace(destination);
|
router.replace(checkoutDestination ?? destination);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (handoffStartedRef.current) return;
|
if (handoffStartedRef.current) return;
|
||||||
if (!hasValue(handoffToken)) return;
|
if (!hasValue(handoffToken)) return;
|
||||||
handoffStartedRef.current = true;
|
handoffStartedRef.current = true;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const result = await consumeTopUpHandoff(handoffToken);
|
if (isCheckoutHandoff) {
|
||||||
if (Result.isErr(result)) {
|
const result = await consumeCheckoutHandoff(handoffToken);
|
||||||
log.warn("[ExternalEntryPersist] top-up handoff failed", result.error);
|
if (Result.isErr(result)) {
|
||||||
|
log.warn(
|
||||||
|
"[ExternalEntryPersist] checkout handoff failed",
|
||||||
|
result.error,
|
||||||
|
);
|
||||||
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
"",
|
||||||
|
"/external-entry?target=checkout",
|
||||||
|
);
|
||||||
|
setHandoffError(
|
||||||
|
"This checkout link is invalid, expired, or already used. Please return and request a new link.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
"",
|
||||||
|
"/external-entry?target=checkout",
|
||||||
|
);
|
||||||
|
setCheckoutDestination(
|
||||||
|
resolveCheckoutIntentDestination(result.data),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const result = await consumeTopUpHandoff(handoffToken);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
log.warn(
|
||||||
|
"[ExternalEntryPersist] top-up handoff failed",
|
||||||
|
result.error,
|
||||||
|
);
|
||||||
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
"",
|
||||||
|
"/external-entry?target=topup",
|
||||||
|
);
|
||||||
|
setHandoffError(
|
||||||
|
"This top-up link is invalid or has expired. Please request a new link in Messenger.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
window.history.state,
|
window.history.state,
|
||||||
"",
|
"",
|
||||||
"/external-entry?target=topup",
|
"/external-entry?target=topup",
|
||||||
);
|
);
|
||||||
setHandoffError(
|
|
||||||
"This top-up link is invalid or has expired. Please request a new link in Messenger.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setHandoffCompleted(true);
|
setHandoffCompleted(true);
|
||||||
authDispatch({ type: "AuthInit" });
|
authDispatch({ type: "AuthInit" });
|
||||||
@@ -190,9 +233,12 @@ export default function ExternalEntryPersist({
|
|||||||
authState.isLoading,
|
authState.isLoading,
|
||||||
authState.loginStatus,
|
authState.loginStatus,
|
||||||
destination,
|
destination,
|
||||||
|
checkoutDestination,
|
||||||
hasPersisted,
|
hasPersisted,
|
||||||
handoffCompleted,
|
handoffCompleted,
|
||||||
handoffToken,
|
handoffToken,
|
||||||
|
isCheckoutHandoff,
|
||||||
|
isLoginHandoff,
|
||||||
isTopUpHandoff,
|
isTopUpHandoff,
|
||||||
psid,
|
psid,
|
||||||
router,
|
router,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* `/external-entry?target=tip`
|
* `/external-entry?target=tip`
|
||||||
* `/external-entry?target=private-zone&character=nayeli`
|
* `/external-entry?target=private-zone&character=nayeli`
|
||||||
* `/external-entry?target=topup&handoffToken=<opaque-token>`
|
* `/external-entry?target=topup&handoffToken=<opaque-token>`
|
||||||
|
* `/external-entry?target=checkout&handoffToken=<opaque-token>`
|
||||||
*
|
*
|
||||||
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
||||||
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
|
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
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 { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||||
import {
|
import {
|
||||||
@@ -70,6 +71,21 @@ export function SubscriptionCheckoutButton({
|
|||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</SubscriptionCtaButton>
|
</SubscriptionCtaButton>
|
||||||
|
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
|
||||||
|
<ExternalBrowserCheckoutButton
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
checkoutIntent={{
|
||||||
|
planId: payment.selectedPlanId,
|
||||||
|
autoRenew: payment.autoRenew,
|
||||||
|
...(payment.commercialOfferId
|
||||||
|
? { commercialOfferId: payment.commercialOfferId }
|
||||||
|
: {}),
|
||||||
|
...(payment.chatActionId
|
||||||
|
? { chatActionId: payment.chatActionId }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{payment.errorMessage ? (
|
{payment.errorMessage ? (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "@/lib/analytics/payment_analytics_context";
|
} from "@/lib/analytics/payment_analytics_context";
|
||||||
import {
|
import {
|
||||||
getFirstPaymentSearchParam,
|
getFirstPaymentSearchParam,
|
||||||
|
parsePaymentAutoRenew,
|
||||||
parsePaymentReturnSearchParams,
|
parsePaymentReturnSearchParams,
|
||||||
parseSubscriptionReturnTo,
|
parseSubscriptionReturnTo,
|
||||||
type PaymentSearchParams,
|
type PaymentSearchParams,
|
||||||
@@ -57,6 +58,7 @@ export default async function SubscriptionPage({
|
|||||||
analyticsContext={analyticsContext}
|
analyticsContext={analyticsContext}
|
||||||
sourceCharacterSlug={sourceCharacterSlug}
|
sourceCharacterSlug={sourceCharacterSlug}
|
||||||
initialPlanId={initialPlanId}
|
initialPlanId={initialPlanId}
|
||||||
|
initialAutoRenew={parsePaymentAutoRenew(query.autoRenew)}
|
||||||
commercialOfferId={commercialOfferId}
|
commercialOfferId={commercialOfferId}
|
||||||
resumeOrderId={resumeOrderId}
|
resumeOrderId={resumeOrderId}
|
||||||
chatActionId={chatActionId}
|
chatActionId={chatActionId}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export interface SubscriptionScreenProps {
|
|||||||
analyticsContext?: PaymentAnalyticsContext;
|
analyticsContext?: PaymentAnalyticsContext;
|
||||||
sourceCharacterSlug?: string;
|
sourceCharacterSlug?: string;
|
||||||
initialPlanId?: string | null;
|
initialPlanId?: string | null;
|
||||||
|
initialAutoRenew?: boolean | null;
|
||||||
commercialOfferId?: string | null;
|
commercialOfferId?: string | null;
|
||||||
resumeOrderId?: string | null;
|
resumeOrderId?: string | null;
|
||||||
chatActionId?: string | null;
|
chatActionId?: string | null;
|
||||||
@@ -66,6 +67,7 @@ export function SubscriptionScreen({
|
|||||||
analyticsContext: providedAnalyticsContext,
|
analyticsContext: providedAnalyticsContext,
|
||||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||||
initialPlanId = null,
|
initialPlanId = null,
|
||||||
|
initialAutoRenew = null,
|
||||||
commercialOfferId = null,
|
commercialOfferId = null,
|
||||||
resumeOrderId = null,
|
resumeOrderId = null,
|
||||||
chatActionId = null,
|
chatActionId = null,
|
||||||
@@ -103,6 +105,7 @@ export function SubscriptionScreen({
|
|||||||
sourceCharacterSlug,
|
sourceCharacterSlug,
|
||||||
initialPayChannel: paymentMethodConfig.initialPayChannel,
|
initialPayChannel: paymentMethodConfig.initialPayChannel,
|
||||||
initialPlanId,
|
initialPlanId,
|
||||||
|
initialAutoRenew,
|
||||||
commercialOfferId,
|
commercialOfferId,
|
||||||
resumeOrderId,
|
resumeOrderId,
|
||||||
chatActionId,
|
chatActionId,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface UseSubscriptionPaymentFlowInput {
|
|||||||
initialPayChannel: PayChannel;
|
initialPayChannel: PayChannel;
|
||||||
sourceCharacterSlug?: string;
|
sourceCharacterSlug?: string;
|
||||||
initialPlanId?: string | null;
|
initialPlanId?: string | null;
|
||||||
|
initialAutoRenew?: boolean | null;
|
||||||
commercialOfferId?: string | null;
|
commercialOfferId?: string | null;
|
||||||
resumeOrderId?: string | null;
|
resumeOrderId?: string | null;
|
||||||
chatActionId?: string | null;
|
chatActionId?: string | null;
|
||||||
@@ -33,6 +34,7 @@ export function useSubscriptionPaymentFlow({
|
|||||||
initialPayChannel,
|
initialPayChannel,
|
||||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||||
initialPlanId = null,
|
initialPlanId = null,
|
||||||
|
initialAutoRenew = null,
|
||||||
commercialOfferId = null,
|
commercialOfferId = null,
|
||||||
resumeOrderId = null,
|
resumeOrderId = null,
|
||||||
chatActionId = null,
|
chatActionId = null,
|
||||||
@@ -43,6 +45,7 @@ export function useSubscriptionPaymentFlow({
|
|||||||
paymentType: subscriptionType,
|
paymentType: subscriptionType,
|
||||||
shouldResumePendingOrder,
|
shouldResumePendingOrder,
|
||||||
initialPlanId,
|
initialPlanId,
|
||||||
|
initialAutoRenew,
|
||||||
commercialOfferId,
|
commercialOfferId,
|
||||||
resumeOrderId,
|
resumeOrderId,
|
||||||
chatActionId,
|
chatActionId,
|
||||||
|
|||||||
@@ -359,6 +359,8 @@ function makePaymentState(
|
|||||||
selectedGiftCategory: giftCategory.category,
|
selectedGiftCategory: giftCategory.category,
|
||||||
isFirstRecharge: false,
|
isFirstRecharge: false,
|
||||||
commercialOffer: null,
|
commercialOffer: null,
|
||||||
|
commercialOfferId: null,
|
||||||
|
chatActionId: null,
|
||||||
selectedPlanId: giftPlan.planId,
|
selectedPlanId: giftPlan.planId,
|
||||||
payChannel: "stripe",
|
payChannel: "stripe",
|
||||||
autoRenew: false,
|
autoRenew: false,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||||
|
import { ExternalBrowserCheckoutButton } from "@/app/_components/payment/external-browser-checkout-button";
|
||||||
import { useActiveCharacter } from "@/providers/character-provider";
|
import { useActiveCharacter } from "@/providers/character-provider";
|
||||||
import {
|
import {
|
||||||
usePaymentDispatch,
|
usePaymentDispatch,
|
||||||
@@ -63,6 +64,19 @@ export function TipCheckoutButton({
|
|||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
|
{payment.payChannel === "stripe" && payment.selectedPlanId ? (
|
||||||
|
<ExternalBrowserCheckoutButton
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
checkoutIntent={{
|
||||||
|
planId: payment.selectedPlanId,
|
||||||
|
autoRenew: false,
|
||||||
|
recipientCharacterId: character.id,
|
||||||
|
...(payment.chatActionId
|
||||||
|
? { chatActionId: payment.chatActionId }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{payment.errorMessage ? (
|
{payment.errorMessage ? (
|
||||||
<p className={styles.checkoutError} role="alert">
|
<p className={styles.checkoutError} role="alert">
|
||||||
{payment.errorMessage}
|
{payment.errorMessage}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { ExceptionHandler } from "@/core/errors";
|
import { ExceptionHandler } from "@/core/errors";
|
||||||
import type { IAuthRepository } from "@/data/repositories/interfaces";
|
import type { IAuthRepository } from "@/data/repositories/interfaces";
|
||||||
import {
|
import {
|
||||||
|
CheckoutHandoffConsumeRequestSchema,
|
||||||
|
CheckoutHandoffCreateRequestSchema,
|
||||||
|
type CheckoutHandoffCreateResponse,
|
||||||
|
type CheckoutIntent,
|
||||||
FacebookIdentityRequestSchema,
|
FacebookIdentityRequestSchema,
|
||||||
FacebookLoginRequestSchema,
|
FacebookLoginRequestSchema,
|
||||||
FbIdLoginRequestSchema,
|
FbIdLoginRequestSchema,
|
||||||
@@ -276,6 +280,30 @@ export class AuthRepository implements IAuthRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 创建订单前生成一次性外部浏览器支付交接链接。 */
|
||||||
|
async createCheckoutHandoff(
|
||||||
|
checkoutIntent: CheckoutIntent,
|
||||||
|
): Promise<Result<CheckoutHandoffCreateResponse>> {
|
||||||
|
return Result.wrap(() =>
|
||||||
|
this.api.createCheckoutHandoff(
|
||||||
|
CheckoutHandoffCreateRequestSchema.parse(checkoutIntent),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消费支付交接凭证,保存后端签发的正式登录态并返回购买意图。 */
|
||||||
|
async consumeCheckoutHandoff(
|
||||||
|
handoffToken: string,
|
||||||
|
): Promise<Result<CheckoutIntent>> {
|
||||||
|
return Result.wrap(async () => {
|
||||||
|
const response = await this.api.consumeCheckoutHandoff(
|
||||||
|
CheckoutHandoffConsumeRequestSchema.parse({ handoffToken }),
|
||||||
|
);
|
||||||
|
await this._saveLoginData(response, response.loginStatus);
|
||||||
|
return response.checkoutIntent;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新 token:先读本地的 refresh token,空则直接返回错误;
|
* 刷新 token:先读本地的 refresh token,空则直接返回错误;
|
||||||
* 调用 API 成功后写回新的 login token + refresh token。
|
* 调用 API 成功后写回新的 login token + refresh token。
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
import type { Result } from "@/utils/result";
|
import type { Result } from "@/utils/result";
|
||||||
import type {
|
import type {
|
||||||
|
CheckoutHandoffCreateResponse,
|
||||||
|
CheckoutIntent,
|
||||||
GuestLoginResponse,
|
GuestLoginResponse,
|
||||||
LoginStatus,
|
LoginStatus,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@@ -74,6 +76,16 @@ export interface IAuthRepository {
|
|||||||
/** 消费一次性充值登录凭证并建立正式会话。 */
|
/** 消费一次性充值登录凭证并建立正式会话。 */
|
||||||
consumeTopUpHandoff(handoffToken: string): Promise<Result<LoginStatus>>;
|
consumeTopUpHandoff(handoffToken: string): Promise<Result<LoginStatus>>;
|
||||||
|
|
||||||
|
/** 在创建订单前生成10分钟有效的外部浏览器支付链接。 */
|
||||||
|
createCheckoutHandoff(
|
||||||
|
checkoutIntent: CheckoutIntent,
|
||||||
|
): Promise<Result<CheckoutHandoffCreateResponse>>;
|
||||||
|
|
||||||
|
/** 消费单次支付交接凭证并保存正式登录态。 */
|
||||||
|
consumeCheckoutHandoff(
|
||||||
|
handoffToken: string,
|
||||||
|
): Promise<Result<CheckoutIntent>>;
|
||||||
|
|
||||||
/** 刷新 token:先读本地的 refresh token,空则返回错误。 */
|
/** 刷新 token:先读本地的 refresh token,空则返回错误。 */
|
||||||
refreshToken(): Promise<Result<RefreshTokenResponse>>;
|
refreshToken(): Promise<Result<RefreshTokenResponse>>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CheckoutHandoffConsumeResponseSchema,
|
||||||
|
CheckoutHandoffCreateRequestSchema,
|
||||||
|
} from "../checkout_handoff";
|
||||||
|
|
||||||
|
describe("checkout handoff schemas", () => {
|
||||||
|
it("keeps only the minimal order-free purchase intent", () => {
|
||||||
|
expect(
|
||||||
|
CheckoutHandoffCreateRequestSchema.parse({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
recipientCharacterId: "elio",
|
||||||
|
price: 9.99,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
recipientCharacterId: "elio",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses the formal session and checkout intent returned after consumption", () => {
|
||||||
|
const parsed = CheckoutHandoffConsumeResponseSchema.parse({
|
||||||
|
user: {
|
||||||
|
id: "00000000-0000-0000-0000-000000000123",
|
||||||
|
username: "checkout-user",
|
||||||
|
email: "checkout@example.com",
|
||||||
|
platform: "web",
|
||||||
|
},
|
||||||
|
token: "access-token",
|
||||||
|
refreshToken: "refresh-token",
|
||||||
|
loginStatus: "email",
|
||||||
|
checkoutIntent: {
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.checkoutIntent.planId).toBe("vip_monthly");
|
||||||
|
expect(parsed.loginStatus).toBe("email");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { stringOrEmpty } from "../nullable-defaults";
|
||||||
|
import { UserSchema } from "../user/user";
|
||||||
|
|
||||||
|
export const CheckoutIntentSchema = z
|
||||||
|
.object({
|
||||||
|
planId: z.string().min(1).max(120),
|
||||||
|
autoRenew: z.boolean(),
|
||||||
|
recipientCharacterId: z.string().min(1).max(80).optional(),
|
||||||
|
commercialOfferId: z.string().min(1).max(80).optional(),
|
||||||
|
chatActionId: z.uuid().optional(),
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export const CheckoutHandoffCreateRequestSchema = CheckoutIntentSchema;
|
||||||
|
|
||||||
|
export const CheckoutHandoffCreateResponseSchema = z
|
||||||
|
.object({
|
||||||
|
externalUrl: z.url(),
|
||||||
|
expiresAt: z.string().min(1),
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export const CheckoutHandoffConsumeRequestSchema = z
|
||||||
|
.object({
|
||||||
|
handoffToken: z.string().min(32).max(512),
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export const CheckoutHandoffConsumeResponseSchema = z
|
||||||
|
.object({
|
||||||
|
user: UserSchema,
|
||||||
|
token: z.string().min(1),
|
||||||
|
refreshToken: stringOrEmpty,
|
||||||
|
loginStatus: z.enum([
|
||||||
|
"email",
|
||||||
|
"google",
|
||||||
|
"facebook",
|
||||||
|
"facebookMessenger",
|
||||||
|
]),
|
||||||
|
checkoutIntent: CheckoutIntentSchema,
|
||||||
|
})
|
||||||
|
.readonly();
|
||||||
|
|
||||||
|
export type CheckoutIntent = z.output<typeof CheckoutIntentSchema>;
|
||||||
|
export type CheckoutHandoffCreateRequest = z.output<
|
||||||
|
typeof CheckoutHandoffCreateRequestSchema
|
||||||
|
>;
|
||||||
|
export type CheckoutHandoffCreateResponse = z.output<
|
||||||
|
typeof CheckoutHandoffCreateResponseSchema
|
||||||
|
>;
|
||||||
|
export type CheckoutHandoffConsumeRequest = z.output<
|
||||||
|
typeof CheckoutHandoffConsumeRequestSchema
|
||||||
|
>;
|
||||||
|
export type CheckoutHandoffConsumeResponse = z.output<
|
||||||
|
typeof CheckoutHandoffConsumeResponseSchema
|
||||||
|
>;
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./facebook_user_data";
|
export * from "./facebook_user_data";
|
||||||
|
export * from "./checkout_handoff";
|
||||||
export * from "./login_status";
|
export * from "./login_status";
|
||||||
export * from "./request/facebook_identity_request";
|
export * from "./request/facebook_identity_request";
|
||||||
export * from "./request/facebook_login_request";
|
export * from "./request/facebook_login_request";
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" },
|
"facebookLogin": { "method": "post", "path": "/api/auth/login/facebook" },
|
||||||
"facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" },
|
"facebookIdLogin": { "method": "post", "path": "/api/auth/login/facebook/by-id" },
|
||||||
"topUpHandoffConsume": { "method": "post", "path": "/api/auth/handoff/topup/consume" },
|
"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" },
|
"refresh": { "method": "post", "path": "/api/auth/refresh" },
|
||||||
"logout": { "method": "post", "path": "/api/auth/logout" },
|
"logout": { "method": "post", "path": "/api/auth/logout" },
|
||||||
"getCurrentUser": { "method": "get", "path": "/api/auth/me" },
|
"getCurrentUser": { "method": "get", "path": "/api/auth/me" },
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export class ApiPath {
|
|||||||
/** 消费一次性充值登录凭证 */
|
/** 消费一次性充值登录凭证 */
|
||||||
static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path;
|
static readonly topUpHandoffConsume = apiContract.topUpHandoffConsume.path;
|
||||||
|
|
||||||
|
/** 创建一次性外部浏览器支付交接链接(创建订单前)。 */
|
||||||
|
static readonly checkoutHandoffCreate = apiContract.checkoutHandoffCreate.path;
|
||||||
|
|
||||||
|
/** 消费一次性外部浏览器支付交接凭证。 */
|
||||||
|
static readonly checkoutHandoffConsume = apiContract.checkoutHandoffConsume.path;
|
||||||
|
|
||||||
/** 刷新 Token */
|
/** 刷新 Token */
|
||||||
static readonly refresh = apiContract.refresh.path;
|
static readonly refresh = apiContract.refresh.path;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CheckoutHandoffConsumeRequest,
|
||||||
|
CheckoutHandoffConsumeResponse,
|
||||||
|
CheckoutHandoffConsumeResponseSchema,
|
||||||
|
CheckoutHandoffCreateRequest,
|
||||||
|
CheckoutHandoffCreateResponse,
|
||||||
|
CheckoutHandoffCreateResponseSchema,
|
||||||
FacebookIdentityRequest,
|
FacebookIdentityRequest,
|
||||||
FacebookIdentityResponse,
|
FacebookIdentityResponse,
|
||||||
FacebookIdentityResponseSchema,
|
FacebookIdentityResponseSchema,
|
||||||
@@ -104,6 +110,32 @@ export class AuthApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在创建支付订单前生成一次性外部浏览器交接链接。 */
|
||||||
|
async createCheckoutHandoff(
|
||||||
|
body: CheckoutHandoffCreateRequest,
|
||||||
|
): Promise<CheckoutHandoffCreateResponse> {
|
||||||
|
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||||
|
ApiPath.checkoutHandoffCreate,
|
||||||
|
{ method: "POST", body },
|
||||||
|
);
|
||||||
|
return CheckoutHandoffCreateResponseSchema.parse(
|
||||||
|
unwrap(env) as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消费一次性支付交接凭证并恢复正式登录态与购买意图。 */
|
||||||
|
async consumeCheckoutHandoff(
|
||||||
|
body: CheckoutHandoffConsumeRequest,
|
||||||
|
): Promise<CheckoutHandoffConsumeResponse> {
|
||||||
|
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||||
|
ApiPath.checkoutHandoffConsume,
|
||||||
|
{ method: "POST", body },
|
||||||
|
);
|
||||||
|
return CheckoutHandoffConsumeResponseSchema.parse(
|
||||||
|
unwrap(env) as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 绑定 Facebook ASID / PSID 到当前用户
|
* 绑定 Facebook ASID / PSID 到当前用户
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type {
|
||||||
|
CheckoutHandoffCreateResponse,
|
||||||
|
CheckoutIntent,
|
||||||
|
} from "@/data/schemas/auth";
|
||||||
|
import { getAuthRepository } from "@/data/repositories/auth_repository";
|
||||||
|
import type { Result } from "@/utils/result";
|
||||||
|
|
||||||
|
export function createCheckoutHandoff(
|
||||||
|
checkoutIntent: CheckoutIntent,
|
||||||
|
): Promise<Result<CheckoutHandoffCreateResponse>> {
|
||||||
|
return getAuthRepository().createCheckoutHandoff(checkoutIntent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeCheckoutHandoff(
|
||||||
|
handoffToken: string,
|
||||||
|
): Promise<Result<CheckoutIntent>> {
|
||||||
|
return getAuthRepository().consumeCheckoutHandoff(handoffToken);
|
||||||
|
}
|
||||||
@@ -3,12 +3,38 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
resolveCheckoutIntentDestination,
|
||||||
resolveExternalEntryDestination,
|
resolveExternalEntryDestination,
|
||||||
resolveExternalEntryPromotionType,
|
resolveExternalEntryPromotionType,
|
||||||
resolveExternalEntryTarget,
|
resolveExternalEntryTarget,
|
||||||
shouldBindExternalFacebookIdentity,
|
shouldBindExternalFacebookIdentity,
|
||||||
} from "../external_entry";
|
} from "../external_entry";
|
||||||
|
|
||||||
|
describe("checkout external browser destination", () => {
|
||||||
|
it("restores subscription intent without carrying the handoff token", () => {
|
||||||
|
expect(
|
||||||
|
resolveCheckoutIntentDestination({
|
||||||
|
planId: "vip_monthly",
|
||||||
|
autoRenew: true,
|
||||||
|
commercialOfferId: "offer-1",
|
||||||
|
chatActionId: "00000000-0000-0000-0000-000000000123",
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
"/subscription?planId=vip_monthly&autoRenew=1&commercialOfferId=offer-1&chatActionId=00000000-0000-0000-0000-000000000123&payChannel=stripe",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores a character tip intent by canonical character id", () => {
|
||||||
|
expect(
|
||||||
|
resolveCheckoutIntentDestination({
|
||||||
|
planId: "maya_coffee",
|
||||||
|
autoRenew: false,
|
||||||
|
recipientCharacterId: "maya-tan",
|
||||||
|
}),
|
||||||
|
).toBe("/characters/maya/tip?planId=maya_coffee&payChannel=stripe");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("external entry navigation", () => {
|
describe("external entry navigation", () => {
|
||||||
it("defaults to chat", () => {
|
it("defaults to chat", () => {
|
||||||
expect(resolveExternalEntryTarget({})).toBe(ROUTES.chat);
|
expect(resolveExternalEntryTarget({})).toBe(ROUTES.chat);
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
|||||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||||
import type { PendingChatPromotionType } from "@/data/storage/navigation";
|
import type { PendingChatPromotionType } from "@/data/storage/navigation";
|
||||||
import type { LoginStatus } from "@/data/schemas/auth";
|
import type { LoginStatus } from "@/data/schemas/auth";
|
||||||
|
import type { CheckoutIntent } from "@/data/schemas/auth";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CHARACTER_SLUG,
|
DEFAULT_CHARACTER_SLUG,
|
||||||
|
getCharacterById,
|
||||||
getCharacterBySlug,
|
getCharacterBySlug,
|
||||||
} from "@/data/constants/character";
|
} from "@/data/constants/character";
|
||||||
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
import { getCharacterRoutes, ROUTES } from "@/router/routes";
|
||||||
@@ -100,6 +102,30 @@ export function resolveExternalEntryDestination({
|
|||||||
return routes.chat;
|
return routes.chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveCheckoutIntentDestination(
|
||||||
|
intent: CheckoutIntent,
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams({ planId: intent.planId });
|
||||||
|
|
||||||
|
if (intent.recipientCharacterId) {
|
||||||
|
const character = getCharacterById(intent.recipientCharacterId);
|
||||||
|
const routes = getCharacterRoutes(
|
||||||
|
character?.slug ?? DEFAULT_CHARACTER_SLUG,
|
||||||
|
);
|
||||||
|
if (intent.chatActionId) params.set("chatActionId", intent.chatActionId);
|
||||||
|
params.set("payChannel", "stripe");
|
||||||
|
return `${routes.tip}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.set("autoRenew", intent.autoRenew ? "1" : "0");
|
||||||
|
if (intent.commercialOfferId) {
|
||||||
|
params.set("commercialOfferId", intent.commercialOfferId);
|
||||||
|
}
|
||||||
|
if (intent.chatActionId) params.set("chatActionId", intent.chatActionId);
|
||||||
|
params.set("payChannel", "stripe");
|
||||||
|
return `${ROUTES.subscription}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function persistExternalEntryPayload({
|
export async function persistExternalEntryPayload({
|
||||||
deviceId,
|
deviceId,
|
||||||
asid,
|
asid,
|
||||||
@@ -138,6 +164,7 @@ function resolveTarget(
|
|||||||
return ROUTES.privateZone;
|
return ROUTES.privateZone;
|
||||||
}
|
}
|
||||||
if (target === "topup") return ROUTES.subscription;
|
if (target === "topup") return ROUTES.subscription;
|
||||||
|
if (target === "checkout") return ROUTES.subscription;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getPaymentUrl,
|
getPaymentUrl,
|
||||||
|
getStripeCustomerSessionClientSecret,
|
||||||
getStripeClientSecret,
|
getStripeClientSecret,
|
||||||
isEzpayPayment,
|
isEzpayPayment,
|
||||||
launchEzpayRedirect,
|
launchEzpayRedirect,
|
||||||
@@ -43,6 +44,28 @@ describe("payment launch helpers", () => {
|
|||||||
).toBeNull();
|
).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", () => {
|
it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveEzpayLaunchTarget({
|
resolveEzpayLaunchTarget({
|
||||||
|
|||||||
@@ -143,6 +143,27 @@ export function getStripeClientSecret(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStripeCustomerSessionClientSecret(
|
||||||
|
payParams: Record<string, unknown>,
|
||||||
|
): string | null {
|
||||||
|
const provider = payParams.provider;
|
||||||
|
const isStripeProvider =
|
||||||
|
typeof provider !== "string" || provider.toLowerCase() === "stripe";
|
||||||
|
const enabled = payParams.savedPaymentMethodsEnabled === true;
|
||||||
|
const clientSecret = payParams.customerSessionClientSecret;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isStripeProvider &&
|
||||||
|
enabled &&
|
||||||
|
typeof clientSecret === "string" &&
|
||||||
|
/^cuss_[^\s]+_secret_[^\s]+$/i.test(clientSecret)
|
||||||
|
) {
|
||||||
|
return clientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LaunchEzpayRedirectInput {
|
export interface LaunchEzpayRedirectInput {
|
||||||
orderId: string | null;
|
orderId: string | null;
|
||||||
paymentUrl: string;
|
paymentUrl: string;
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export function parsePaymentPayChannel(
|
|||||||
return channel === "ezpay" || channel === "stripe" ? channel : null;
|
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(
|
export function parseSubscriptionReturnTo(
|
||||||
value: PaymentSearchParamValue,
|
value: PaymentSearchParamValue,
|
||||||
): AppSubscriptionReturnTo {
|
): AppSubscriptionReturnTo {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createTestPaymentMachine,
|
createTestPaymentMachine,
|
||||||
giftCatalog,
|
|
||||||
lifetimePlan,
|
lifetimePlan,
|
||||||
monthlyPlan,
|
monthlyPlan,
|
||||||
quarterlyPlan,
|
quarterlyPlan,
|
||||||
@@ -51,7 +50,7 @@ describe("payment catalog flow", () => {
|
|||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores a valid first-category product and rejects another category", async () => {
|
it("restores a valid tip product across categories from checkout handoff", async () => {
|
||||||
const actor = createActor(createTestPaymentMachine()).start();
|
const actor = createActor(createTestPaymentMachine()).start();
|
||||||
actor.send({
|
actor.send({
|
||||||
type: "PaymentInit",
|
type: "PaymentInit",
|
||||||
@@ -79,8 +78,9 @@ describe("payment catalog flow", () => {
|
|||||||
snapshot.context.requestedGiftPlanId === null,
|
snapshot.context.requestedGiftPlanId === null,
|
||||||
);
|
);
|
||||||
expect(actor.getSnapshot().context.selectedPlanId).toBe(
|
expect(actor.getSnapshot().context.selectedPlanId).toBe(
|
||||||
giftCatalog.plans[0]?.planId,
|
"tip_flowers_usd_12_99",
|
||||||
);
|
);
|
||||||
|
expect(actor.getSnapshot().context.selectedGiftCategory).toBe("flowers");
|
||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -50,15 +50,23 @@ export function hydrateGiftProductsState(
|
|||||||
const giftProducts = response.plans.filter(
|
const giftProducts = response.plans.filter(
|
||||||
(product) => product.characterId === characterId,
|
(product) => product.characterId === characterId,
|
||||||
);
|
);
|
||||||
const selectedGiftCategory = giftCategories[0]?.category ?? null;
|
const restoredProduct = restoredPlanId
|
||||||
|
? giftProducts.find((product) => product.planId === restoredPlanId)
|
||||||
|
: null;
|
||||||
|
const selectedGiftCategory =
|
||||||
|
restoredProduct?.category ??
|
||||||
|
(restoredCategory &&
|
||||||
|
giftCategories.some((category) => category.category === restoredCategory)
|
||||||
|
? restoredCategory
|
||||||
|
: (giftCategories[0]?.category ?? null));
|
||||||
const visibleProducts = selectedGiftCategory
|
const visibleProducts = selectedGiftCategory
|
||||||
? giftProducts.filter(
|
? giftProducts.filter(
|
||||||
(product) => product.category === selectedGiftCategory,
|
(product) => product.category === selectedGiftCategory,
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
const canRestoreSelection =
|
const canRestoreSelection = visibleProducts.some(
|
||||||
restoredCategory === selectedGiftCategory &&
|
(product) => product.planId === restoredPlanId,
|
||||||
visibleProducts.some((product) => product.planId === restoredPlanId);
|
);
|
||||||
const selectedPlanId = canRestoreSelection
|
const selectedPlanId = canRestoreSelection
|
||||||
? (restoredPlanId ?? "")
|
? (restoredPlanId ?? "")
|
||||||
: (visibleProducts[0]?.planId ?? "");
|
: (visibleProducts[0]?.planId ?? "");
|
||||||
|
|||||||
@@ -32,14 +32,18 @@ function initializeCatalogState(
|
|||||||
const catalogChanged = planCatalog !== context.planCatalog;
|
const catalogChanged = planCatalog !== context.planCatalog;
|
||||||
const characterChanged = giftCharacterId !== context.giftCharacterId;
|
const characterChanged = giftCharacterId !== context.giftCharacterId;
|
||||||
const selectionChanged =
|
const selectionChanged =
|
||||||
planCatalog === "tip" &&
|
planCatalog === "tip"
|
||||||
((event.category ?? null) !== context.selectedGiftCategory ||
|
? (event.category ?? null) !== context.selectedGiftCategory ||
|
||||||
(event.planId ?? null) !== (context.selectedPlanId || null));
|
(event.planId ?? null) !== (context.selectedPlanId || null)
|
||||||
|
: (event.planId ?? null) !== (context.selectedPlanId || null);
|
||||||
const commercialOfferId =
|
const commercialOfferId =
|
||||||
planCatalog === "default" ? (event.commercialOfferId ?? null) : null;
|
planCatalog === "default" ? (event.commercialOfferId ?? null) : null;
|
||||||
const offerChanged = commercialOfferId !== context.commercialOfferId;
|
const offerChanged = commercialOfferId !== context.commercialOfferId;
|
||||||
const chatActionId = event.chatActionId ?? null;
|
const chatActionId = event.chatActionId ?? null;
|
||||||
const chatActionChanged = chatActionId !== context.chatActionId;
|
const chatActionChanged = chatActionId !== context.chatActionId;
|
||||||
|
const initialAutoRenew =
|
||||||
|
planCatalog === "tip" ? false : (event.autoRenew ?? true);
|
||||||
|
const autoRenewChanged = initialAutoRenew !== context.autoRenew;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
planCatalog,
|
planCatalog,
|
||||||
@@ -51,7 +55,12 @@ function initializeCatalogState(
|
|||||||
planCatalog === "tip" ? (event.category ?? null) : null,
|
planCatalog === "tip" ? (event.category ?? null) : null,
|
||||||
requestedGiftPlanId:
|
requestedGiftPlanId:
|
||||||
planCatalog === "tip" ? (event.planId ?? null) : null,
|
planCatalog === "tip" ? (event.planId ?? null) : null,
|
||||||
...(catalogChanged || characterChanged || selectionChanged || offerChanged || chatActionChanged
|
...(catalogChanged ||
|
||||||
|
characterChanged ||
|
||||||
|
selectionChanged ||
|
||||||
|
offerChanged ||
|
||||||
|
chatActionChanged ||
|
||||||
|
autoRenewChanged
|
||||||
? {
|
? {
|
||||||
...resetOrderState(),
|
...resetOrderState(),
|
||||||
plans: [],
|
plans: [],
|
||||||
@@ -62,7 +71,7 @@ function initializeCatalogState(
|
|||||||
planCatalog === "default" ? (event.planId ?? "") : "",
|
planCatalog === "default" ? (event.planId ?? "") : "",
|
||||||
isFirstRecharge: false,
|
isFirstRecharge: false,
|
||||||
commercialOffer: null,
|
commercialOffer: null,
|
||||||
autoRenew: planCatalog !== "tip",
|
autoRenew: initialAutoRenew,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface PaymentContextState {
|
|||||||
selectedGiftCategory: string | null;
|
selectedGiftCategory: string | null;
|
||||||
isFirstRecharge: MachineContext["isFirstRecharge"];
|
isFirstRecharge: MachineContext["isFirstRecharge"];
|
||||||
commercialOffer: MachineContext["commercialOffer"];
|
commercialOffer: MachineContext["commercialOffer"];
|
||||||
|
commercialOfferId: string | null;
|
||||||
|
chatActionId: string | null;
|
||||||
selectedPlanId: string;
|
selectedPlanId: string;
|
||||||
payChannel: MachineContext["payChannel"];
|
payChannel: MachineContext["payChannel"];
|
||||||
autoRenew: boolean;
|
autoRenew: boolean;
|
||||||
@@ -76,6 +78,8 @@ function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
|
|||||||
selectedGiftCategory: state.context.selectedGiftCategory,
|
selectedGiftCategory: state.context.selectedGiftCategory,
|
||||||
isFirstRecharge: state.context.isFirstRecharge,
|
isFirstRecharge: state.context.isFirstRecharge,
|
||||||
commercialOffer: state.context.commercialOffer,
|
commercialOffer: state.context.commercialOffer,
|
||||||
|
commercialOfferId: state.context.commercialOfferId,
|
||||||
|
chatActionId: state.context.chatActionId,
|
||||||
selectedPlanId: state.context.selectedPlanId,
|
selectedPlanId: state.context.selectedPlanId,
|
||||||
payChannel: state.context.payChannel,
|
payChannel: state.context.payChannel,
|
||||||
autoRenew: state.context.autoRenew,
|
autoRenew: state.context.autoRenew,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type PaymentEvent =
|
|||||||
characterId?: string;
|
characterId?: string;
|
||||||
category?: string | null;
|
category?: string | null;
|
||||||
planId?: string | null;
|
planId?: string | null;
|
||||||
|
autoRenew?: boolean;
|
||||||
commercialOfferId?: string | null;
|
commercialOfferId?: string | null;
|
||||||
chatActionId?: string | null;
|
chatActionId?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Logger } from "../logger";
|
||||||
|
|
||||||
|
describe("Logger Stripe secret redaction", () => {
|
||||||
|
it("redacts nested Stripe secret fields and values before formatting", () => {
|
||||||
|
const output = Logger.formatValue({
|
||||||
|
payParams: {
|
||||||
|
clientSecret: "pi_123_secret_card",
|
||||||
|
nested: {
|
||||||
|
customerSessionClientSecret: "cuss_123_secret_saved",
|
||||||
|
handoffToken: "one-time-checkout-token",
|
||||||
|
safe: "order-123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output).toContain("order-123");
|
||||||
|
expect(output).not.toContain("secret_card");
|
||||||
|
expect(output).not.toContain("secret_saved");
|
||||||
|
expect(output).not.toContain("one-time-checkout-token");
|
||||||
|
expect(output.match(/\[REDACTED\]/g)?.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts a secret embedded in a serialized response string", () => {
|
||||||
|
const output = Logger.formatValue(
|
||||||
|
JSON.stringify({ customerSessionClientSecret: "cuss_1_secret_value" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(output).not.toContain("secret_value");
|
||||||
|
expect(output).toContain("[REDACTED]");
|
||||||
|
});
|
||||||
|
});
|
||||||
+49
-16
@@ -107,9 +107,13 @@ export class Logger {
|
|||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (trimmed.length === 0) return "";
|
if (trimmed.length === 0) return "";
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
return JSON.stringify(
|
||||||
|
Logger.toSerializableLogValue(JSON.parse(trimmed)),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return value;
|
return Logger.redactSensitiveString(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,28 +153,31 @@ export class Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private write(level: LogLevel, args: LogArgs): void {
|
private write(level: LogLevel, args: LogArgs): void {
|
||||||
Logger.reportImportantProductionLog(level, this.component, args);
|
const safeArgs = args.map((value) =>
|
||||||
|
Logger.toSerializableLogValue(value),
|
||||||
|
);
|
||||||
|
Logger.reportImportantProductionLog(level, this.component, safeArgs);
|
||||||
|
|
||||||
if (Logger.shouldUseBrowserConsole()) {
|
if (Logger.shouldUseBrowserConsole()) {
|
||||||
Logger.writeBrowserConsole(level, this.component, args);
|
Logger.writeBrowserConsole(level, this.component, safeArgs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case "debug":
|
case "debug":
|
||||||
this.logger.debug(...Logger.normalizeLogArgs(args));
|
this.logger.debug(...Logger.normalizeLogArgs(safeArgs));
|
||||||
return;
|
return;
|
||||||
case "info":
|
case "info":
|
||||||
this.logger.info(...Logger.normalizeLogArgs(args));
|
this.logger.info(...Logger.normalizeLogArgs(safeArgs));
|
||||||
return;
|
return;
|
||||||
case "warn":
|
case "warn":
|
||||||
this.logger.warn(...Logger.normalizeLogArgs(args));
|
this.logger.warn(...Logger.normalizeLogArgs(safeArgs));
|
||||||
return;
|
return;
|
||||||
case "error":
|
case "error":
|
||||||
this.logger.error(...Logger.normalizeLogArgs(args));
|
this.logger.error(...Logger.normalizeLogArgs(safeArgs));
|
||||||
return;
|
return;
|
||||||
case "fatal":
|
case "fatal":
|
||||||
this.logger.fatal(...Logger.normalizeLogArgs(args));
|
this.logger.fatal(...Logger.normalizeLogArgs(safeArgs));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,6 +273,10 @@ export class Logger {
|
|||||||
return value.map((item) => Logger.toSerializableLogValue(item, seen));
|
return value.map((item) => Logger.toSerializableLogValue(item, seen));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return Logger.redactSensitiveString(value);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof value !== "object" || value === null) {
|
if (typeof value !== "object" || value === null) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -281,19 +292,39 @@ export class Logger {
|
|||||||
|
|
||||||
const output: Record<string, unknown> = {};
|
const output: Record<string, unknown> = {};
|
||||||
for (const [key, item] of Object.entries(value)) {
|
for (const [key, item] of Object.entries(value)) {
|
||||||
output[key] = Logger.toSerializableLogValue(item, seen);
|
output[key] = Logger.isSensitiveLogKey(key)
|
||||||
|
? "[REDACTED]"
|
||||||
|
: Logger.toSerializableLogValue(item, seen);
|
||||||
}
|
}
|
||||||
return output;
|
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(
|
private static serializeError(
|
||||||
error: Error,
|
error: Error,
|
||||||
seen: WeakSet<object>,
|
seen: WeakSet<object>,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const output: Record<string, unknown> = {
|
const output: Record<string, unknown> = {
|
||||||
name: error.name,
|
name: error.name,
|
||||||
message: error.message,
|
message: Logger.redactSensitiveString(error.message),
|
||||||
stack: error.stack,
|
stack: error.stack
|
||||||
|
? Logger.redactSensitiveString(error.stack)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (seen.has(error)) {
|
if (seen.has(error)) {
|
||||||
@@ -303,10 +334,12 @@ export class Logger {
|
|||||||
|
|
||||||
for (const key of Object.getOwnPropertyNames(error)) {
|
for (const key of Object.getOwnPropertyNames(error)) {
|
||||||
if (key in output) continue;
|
if (key in output) continue;
|
||||||
output[key] = Logger.toSerializableLogValue(
|
output[key] = Logger.isSensitiveLogKey(key)
|
||||||
(error as unknown as Record<string, unknown>)[key],
|
? "[REDACTED]"
|
||||||
seen,
|
: Logger.toSerializableLogValue(
|
||||||
);
|
(error as unknown as Record<string, unknown>)[key],
|
||||||
|
seen,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user