Files
cozsweet-frontend-nextjs/src/lib/payment/payment_launch.ts
T
Codex 599f3e3d26
Docker Image / Build and Push Docker Image (push) Successful in 2m12s
fix(payment): route Philippine GCash separately from QRIS
2026-07-29 19:21:07 +08:00

313 lines
7.5 KiB
TypeScript

"use client";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
import {
savePendingEzpayOrder,
type PendingPaymentReturnTo,
type PendingPaymentSubscriptionType,
} from "./pending_payment_order";
const log = new Logger("LibPaymentPaymentLaunch");
export type EzpayLaunchTarget =
| { kind: "url"; paymentUrl: string }
| {
kind: "qr";
experience: EzpayQrExperience;
qrData: string;
}
| { kind: "error"; errorMessage: string };
export type EzpayQrExperience = "gcashQrPh" | "qris" | "paymentQr";
export interface ResolveEzpayLaunchContext {
countryCode?: string | null;
currency?: string | null;
}
function getNonEmptyString(
payParams: Record<string, unknown>,
...keys: string[]
): string | null {
for (const key of keys) {
const value = payParams[key];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return null;
}
function getHttpPaymentUrl(value: string | null): string | null {
if (!value) return null;
try {
const parsed = new URL(value);
return parsed.protocol === "https:" || parsed.protocol === "http:"
? value
: null;
} catch {
return null;
}
}
export function getPaymentUrlHostname(value: string | null): string | null {
const paymentUrl = getHttpPaymentUrl(value);
if (!paymentUrl) return null;
return new URL(paymentUrl).hostname;
}
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 resolveEzpayLaunchTarget(
payParams: Record<string, unknown>,
context: ResolveEzpayLaunchContext = {},
): EzpayLaunchTarget {
const rawChannelType = getNonEmptyString(
payParams,
"channelType",
"channel_type",
);
const channelType = rawChannelType?.toUpperCase() ?? null;
const payData = getNonEmptyString(payParams, "payData", "pay_data");
const payDataUrl = getHttpPaymentUrl(payData);
const namedPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
const paymentUrl = payDataUrl ?? namedPaymentUrl;
const qrData = payData && !payDataUrl ? payData : null;
const region = resolveEzpayRegion(payParams, context);
if (
region === "ph" &&
paymentUrl &&
(channelType === "QR" || channelType === "URL" || channelType === null)
) {
return { kind: "url", paymentUrl };
}
if (channelType === "QR") {
if (qrData) {
return {
kind: "qr",
experience: resolveEzpayQrExperience(region),
qrData,
};
}
return paymentUrl
? { kind: "url", paymentUrl }
: {
kind: "error",
errorMessage: regionalQrMissingMessage(region),
};
}
if (channelType === "VA") {
return {
kind: "error",
errorMessage: "Virtual account payments are not supported in this app.",
};
}
if (channelType === "URL") {
return paymentUrl
? { kind: "url", paymentUrl }
: {
kind: "error",
errorMessage: "Ezpay payment URL is missing or invalid. Please try again.",
};
}
if (channelType) {
return {
kind: "error",
errorMessage: `Unsupported Ezpay payment channel: ${channelType}.`,
};
}
if (paymentUrl) return { kind: "url", paymentUrl };
if (qrData && region !== null) {
return {
kind: "qr",
experience: resolveEzpayQrExperience(region),
qrData,
};
}
return {
kind: "error",
errorMessage:
"Ezpay payment parameters did not include a supported URL or payment QR code.",
};
}
function resolveEzpayRegion(
payParams: Record<string, unknown>,
context: ResolveEzpayLaunchContext,
): "ph" | "id" | null {
const currency = (
context.currency ?? getNonEmptyString(payParams, "currency")
)
?.trim()
.toUpperCase();
if (currency === "PHP") return "ph";
if (currency === "IDR") return "id";
const countryCode = (
context.countryCode ??
getNonEmptyString(
payParams,
"purchaseCountryCode",
"purchase_country_code",
"countryCode",
"country_code",
)
)
?.trim()
.toUpperCase();
if (countryCode === "PH") return "ph";
if (countryCode === "ID") return "id";
return null;
}
function resolveEzpayQrExperience(
region: "ph" | "id" | null,
): EzpayQrExperience {
if (region === "ph") return "gcashQrPh";
if (region === "id") return "qris";
return "paymentQr";
}
function regionalQrMissingMessage(region: "ph" | "id" | null): string {
if (region === "ph") {
return "GCash / QR Ph payment data is missing. Please try again.";
}
if (region === "id") {
return "QRIS payment data is missing. Please try again.";
}
return "Payment QR data is missing. Please try again.";
}
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;
giftCategory?: string | null;
giftPlanId?: string | null;
returnTo?: PendingPaymentReturnTo;
characterSlug?: string;
onOpened?: () => void;
onFailed: (errorMessage: string) => void;
}
export async function launchEzpayRedirect({
orderId,
paymentUrl,
subscriptionType,
giftCategory,
giftPlanId,
returnTo,
characterSlug,
onOpened,
onFailed,
}: LaunchEzpayRedirectInput): Promise<void> {
const paymentUrlHost = getPaymentUrlHostname(paymentUrl);
log.debug("[payment-launch] launchEzpayRedirect START", {
hasOrderId: Boolean(orderId),
orderId,
subscriptionType,
paymentUrlHost,
});
if (!orderId) {
const errorMessage = "Missing order id before opening Ezpay.";
log.error("[payment-launch] pending ezpay order save skipped", {
subscriptionType,
paymentUrlHost,
errorMessage,
});
onFailed(errorMessage);
return;
}
const saveResult = await savePendingEzpayOrder({
orderId,
subscriptionType,
giftCategory,
giftPlanId,
...(returnTo ? { returnTo } : {}),
...(characterSlug ? { characterSlug } : {}),
});
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,
});
try {
window.location.href = paymentUrl;
onOpened?.();
} catch {
onFailed("Could not open Ezpay. Please try again.");
}
}