refactor(subscription): tighten payment flow boundaries
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user