refactor(subscription): tighten payment flow boundaries

This commit is contained in:
2026-06-30 15:36:49 +08:00
parent 61504050e5
commit 218df59345
9 changed files with 640 additions and 286 deletions
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ROUTES } from "@/router/routes";
import { consumePendingChatImageReturn } from "../chat_image_return_session";
import {
consumeSubscriptionExitUrl,
consumeSubscriptionExplicitExitUrl,
getSubscriptionFallbackExitUrl,
} from "../subscription_exit";
vi.mock("../chat_image_return_session", () => ({
consumePendingChatImageReturn: vi.fn(),
}));
const consumePendingChatImageReturnMock = vi.mocked(
consumePendingChatImageReturn,
);
describe("subscription exit helpers", () => {
beforeEach(() => {
consumePendingChatImageReturnMock.mockReset();
});
it("uses explicit chat image return urls first", () => {
consumePendingChatImageReturnMock.mockReturnValue({
reason: "image_paywall",
messageId: "msg_1",
returnUrl: "/chat/image/msg_1",
createdAt: 1,
});
expect(consumeSubscriptionExplicitExitUrl()).toBe("/chat/image/msg_1");
});
it("falls back to chat when requested", () => {
consumePendingChatImageReturnMock.mockReturnValue(null);
expect(consumeSubscriptionExitUrl("chat")).toBe(ROUTES.chat);
});
it("falls back to sidebar by default", () => {
consumePendingChatImageReturnMock.mockReturnValue(null);
expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.sidebar);
expect(consumeSubscriptionExitUrl(null)).toBe(ROUTES.sidebar);
});
});
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { ROUTES } from "@/router/routes";
import { consumePendingChatImageReturn } from "./chat_image_return_session";
export type SubscriptionReturnTo = "chat" | null;
export function consumeSubscriptionExplicitExitUrl(): string | null {
const pendingImageReturn = consumePendingChatImageReturn();
if (pendingImageReturn) return pendingImageReturn.returnUrl;
return null;
}
export function getSubscriptionFallbackExitUrl(
returnTo: SubscriptionReturnTo,
): string {
return returnTo === "chat" ? ROUTES.chat : ROUTES.sidebar;
}
export function consumeSubscriptionExitUrl(
returnTo: SubscriptionReturnTo,
): string {
return (
consumeSubscriptionExplicitExitUrl() ??
getSubscriptionFallbackExitUrl(returnTo)
);
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
getPaymentUrl,
getStripeClientSecret,
isEzpayPayment,
} from "../payment_launch";
describe("payment launch helpers", () => {
it("extracts supported redirect urls from payment params", () => {
expect(getPaymentUrl({ cashier_url: "https://pay.example/cashier" })).toBe(
"https://pay.example/cashier",
);
expect(getPaymentUrl({ checkoutUrl: "https://pay.example/checkout" })).toBe(
"https://pay.example/checkout",
);
expect(getPaymentUrl({ url: "" })).toBeNull();
});
it("detects ezpay provider case-insensitively", () => {
expect(isEzpayPayment({ provider: "ezpay" })).toBe(true);
expect(isEzpayPayment({ provider: "EZPAY" })).toBe(true);
expect(isEzpayPayment({ provider: "stripe" })).toBe(false);
});
it("extracts stripe client secrets only for stripe-like providers", () => {
expect(
getStripeClientSecret({
provider: "stripe",
clientSecret: "pi_secret",
}),
).toBe("pi_secret");
expect(getStripeClientSecret({ client_secret: "pi_secret_snake" })).toBe(
"pi_secret_snake",
);
expect(
getStripeClientSecret({
provider: "ezpay",
clientSecret: "pi_secret",
}),
).toBeNull();
});
});
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { Logger, Result } from "@/utils";
import {
savePendingEzpayOrder,
type PendingPaymentReturnTo,
type PendingPaymentSubscriptionType,
} from "./pending_payment_order";
const log = new Logger("LibPaymentPaymentLaunch");
export function getPaymentUrl(payParams: Record<string, unknown>): string | null {
const keys = [
"cashierUrl",
"cashier_url",
"checkoutUrl",
"checkout_url",
"paymentUrl",
"payment_url",
"approvalUrl",
"approval_url",
"redirectUrl",
"redirect_url",
"url",
];
for (const key of keys) {
const value = payParams[key];
if (typeof value === "string" && value.length > 0) return value;
}
return null;
}
export function isEzpayPayment(payParams: Record<string, unknown>): boolean {
const provider = payParams.provider;
return typeof provider === "string" && provider.toLowerCase() === "ezpay";
}
export function getStripeClientSecret(
payParams: Record<string, unknown>,
): string | null {
const provider = payParams.provider;
const clientSecret = payParams.clientSecret ?? payParams.client_secret;
const isStripeProvider =
typeof provider !== "string" || provider.toLowerCase() === "stripe";
if (
isStripeProvider &&
typeof clientSecret === "string" &&
clientSecret.length > 0
) {
return clientSecret;
}
return null;
}
export interface LaunchEzpayRedirectInput {
orderId: string | null;
paymentUrl: string;
subscriptionType: PendingPaymentSubscriptionType;
returnTo?: PendingPaymentReturnTo;
onFailed: (errorMessage: string) => void;
}
export async function launchEzpayRedirect({
orderId,
paymentUrl,
subscriptionType,
returnTo,
onFailed,
}: LaunchEzpayRedirectInput): Promise<void> {
log.debug("[payment-launch] launchEzpayRedirect START", {
hasOrderId: Boolean(orderId),
orderId,
subscriptionType,
paymentUrl,
});
if (!orderId) {
const errorMessage = "Missing order id before opening Ezpay.";
log.error("[payment-launch] pending ezpay order save skipped", {
subscriptionType,
paymentUrl,
errorMessage,
});
onFailed(errorMessage);
return;
}
const saveResult = await savePendingEzpayOrder({
orderId,
subscriptionType,
...(returnTo ? { returnTo } : {}),
});
if (Result.isErr(saveResult)) {
const errorMessage =
"Could not save payment order before opening Ezpay. Please try again.";
log.error("[payment-launch] pending ezpay order save failed", {
orderId,
subscriptionType,
error: saveResult.error,
});
onFailed(errorMessage);
return;
}
log.debug("[payment-launch] pending ezpay order saved", {
orderId,
subscriptionType,
});
log.debug("[payment-launch] launchEzpayRedirect NOW", {
orderId,
subscriptionType,
paymentUrl,
});
window.location.href = paymentUrl;
}