313 lines
7.5 KiB
TypeScript
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.");
|
|
}
|
|
}
|