"use client"; import { type Dispatch, useEffect, useRef, useState } from "react"; import { getPaymentUrl, getPaymentUrlHostname, getStripeClientSecret, isEzpayPayment, launchEzpayRedirect, resolveEzpayLaunchTarget, } from "@/lib/payment/payment_launch"; import { behaviorAnalytics } from "@/lib/analytics"; import type { PendingPaymentReturnTo, PendingPaymentSubscriptionType, } from "@/lib/payment/pending_payment_order"; import type { PaymentContextState, } from "@/stores/payment/payment-context"; import type { PaymentEvent } from "@/stores/payment/payment-events"; import { AppEnvUtil } from "@/utils/app-env"; import { Logger } from "@/utils/logger"; const UNSUPPORTED_PAYMENT_PARAMS_MESSAGE = "Payment parameters did not include a supported URL or Stripe client secret."; export interface ShouldShowEzpayConfirmationInput { currentOrderId: string | null; isProduction: boolean; payParams: Record | null; paymentUrl: string | null; } export interface UsePaymentLaunchFlowInput { log: Logger; logScope: string; payment: PaymentContextState; paymentDispatch: Dispatch; returnTo?: PendingPaymentReturnTo; characterSlug?: string; subscriptionType: PendingPaymentSubscriptionType; giftCategory?: string | null; giftPlanId?: string | null; countryCode?: string | null; } export interface PaymentLaunchFlow { ezpayPaymentUrl: string | null; handleEzpayCancel: () => void; handleEzpayConfirm: () => void; handleRegionalQrClose: () => void; handleStripeClose: () => void; handleStripeConfirmed: () => void; isConfirmingEzpay: boolean; regionalQrErrorMessage: string | null; regionalQrPayment: RegionalQrPaymentDetails | null; regionalQrStatus: PaymentContextState["orderStatus"]; resetLaunchState: () => void; shouldShowEzpayConfirmDialog: boolean; shouldShowRegionalQrDialog: boolean; shouldShowStripeDialog: boolean; stripeClientSecret: string | null; } export interface RegionalQrPaymentDetails { amountCents: number; currency: string; experience: "gcashQrPh" | "qris" | "paymentQr"; orderId: string; qrData: string; } function paymentParamString( payParams: Record, ...keys: string[] ): string | null { for (const key of keys) { const value = payParams[key]; if (typeof value === "string" && value.trim()) return value.trim(); } return null; } function paymentParamAmountCents( payParams: Record, ): number | null { for (const key of [ "firstChargeAmountCents", "first_charge_amount_cents", "amountCents", "amount_cents", ]) { const value = payParams[key]; if (typeof value === "number" && Number.isFinite(value) && value > 0) { return value; } } return null; } function trackPaymentCheckoutOpened( payment: PaymentContextState, checkoutUrl: string, ): void { const plan = payment.plans.find( (item) => item.planId === payment.selectedPlanId, ); if (!payment.currentOrderId || !plan) return; behaviorAnalytics.checkoutOpened({ orderId: payment.currentOrderId, plan, payChannel: payment.payChannel, checkoutUrl, }); } function trackPaymentCheckoutFailed( payment: PaymentContextState, reason: "missing_checkout_url" | "payment_redirect_failed", ): void { const plan = payment.plans.find( (item) => item.planId === payment.selectedPlanId, ); if (!payment.currentOrderId || !plan) return; behaviorAnalytics.checkoutFailed({ orderId: payment.currentOrderId, plan, payChannel: payment.payChannel, reason, }); } export function shouldShowEzpayConfirmation({ currentOrderId, isProduction, payParams, paymentUrl, }: ShouldShowEzpayConfirmationInput): boolean { return Boolean( !isProduction && payParams && isEzpayPayment(payParams) && paymentUrl && currentOrderId, ); } export function usePaymentLaunchFlow({ log, logScope, payment, paymentDispatch, returnTo, characterSlug, subscriptionType, giftCategory, giftPlanId, countryCode, }: UsePaymentLaunchFlowInput): PaymentLaunchFlow { const launchedNonceRef = useRef(0); const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< string | null >(null); const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false); const stripeClientSecret = payment.payParams ? getStripeClientSecret(payment.payParams) : null; const selectedPlan = payment.plans.find( (item) => item.planId === payment.selectedPlanId, ); const paymentCurrency = payment.payParams ? paymentParamString(payment.payParams, "currency") ?? selectedPlan?.currency ?? null : selectedPlan?.currency ?? null; const ezpayLaunchTarget = payment.payParams && isEzpayPayment(payment.payParams) ? resolveEzpayLaunchTarget(payment.payParams, { countryCode, currency: paymentCurrency, }) : null; const ezpayPaymentUrl = ezpayLaunchTarget?.kind === "url" ? ezpayLaunchTarget.paymentUrl : null; const regionalQrPayment: RegionalQrPaymentDetails | null = payment.payParams && payment.currentOrderId && ezpayLaunchTarget?.kind === "qr" ? { amountCents: paymentParamAmountCents(payment.payParams) ?? selectedPlan?.amountCents ?? 0, currency: paymentCurrency ?? (ezpayLaunchTarget.experience === "gcashQrPh" ? "PHP" : "IDR"), experience: ezpayLaunchTarget.experience, orderId: payment.currentOrderId, qrData: ezpayLaunchTarget.qrData, } : null; useEffect(() => { if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) { return; } launchedNonceRef.current = payment.launchNonce; const clientSecret = getStripeClientSecret(payment.payParams); if (clientSecret) { trackPaymentCheckoutOpened(payment, "stripe_embedded"); return; } const isEzpay = isEzpayPayment(payment.payParams); if (isEzpay) { const target = resolveEzpayLaunchTarget(payment.payParams, { countryCode, currency: paymentCurrency, }); const channelType = paymentParamString( payment.payParams, "channelType", "channel_type", ); const channelCode = paymentParamString( payment.payParams, "channelCode", "channel_code", ); const namedPaymentUrl = getPaymentUrl(payment.payParams); log.debug(`[${logScope}] ezpay launch target resolved`, { countryCode: countryCode?.trim().toUpperCase() ?? null, currency: paymentCurrency?.trim().toUpperCase() ?? null, channelType: channelType?.toUpperCase() ?? null, channelCode, hasCashierUrl: getPaymentUrlHostname(namedPaymentUrl) !== null, hasQrData: target.kind === "qr", paymentUrlHost: target.kind === "url" ? getPaymentUrlHostname(target.paymentUrl) : null, }); if (target.kind === "error") { trackPaymentCheckoutFailed(payment, "missing_checkout_url"); paymentDispatch({ type: "PaymentLaunchFailed", errorMessage: target.errorMessage, }); return; } if (target.kind === "qr") { if (!payment.currentOrderId) { trackPaymentCheckoutFailed(payment, "missing_checkout_url"); paymentDispatch({ type: "PaymentLaunchFailed", errorMessage: "Missing order id before showing payment QR code.", }); return; } trackPaymentCheckoutOpened( payment, target.experience === "qris" ? "qris_embedded" : target.experience === "gcashQrPh" ? "gcash_qrph_embedded" : "payment_qr_embedded", ); return; } const paymentUrl = target.paymentUrl; if (!AppEnvUtil.isProduction()) { log.debug(`[${logScope}] ezpay confirmation required`, { orderId: payment.currentOrderId, paymentUrlHost: getPaymentUrlHostname(paymentUrl), subscriptionType, }); return; } void launchEzpayRedirect({ orderId: payment.currentOrderId, paymentUrl, subscriptionType, giftCategory, giftPlanId, ...(returnTo ? { returnTo } : {}), ...(characterSlug ? { characterSlug } : {}), onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl), onFailed: (errorMessage) => { trackPaymentCheckoutFailed(payment, "payment_redirect_failed"); paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }); }, }); return; } const paymentUrl = getPaymentUrl(payment.payParams); if (paymentUrl) { try { window.location.assign(paymentUrl); trackPaymentCheckoutOpened(payment, paymentUrl); } catch { trackPaymentCheckoutFailed(payment, "payment_redirect_failed"); paymentDispatch({ type: "PaymentLaunchFailed", errorMessage: "Could not open the payment page. Please try again.", }); } return; } trackPaymentCheckoutFailed(payment, "missing_checkout_url"); paymentDispatch({ type: "PaymentLaunchFailed", errorMessage: UNSUPPORTED_PAYMENT_PARAMS_MESSAGE, }); }, [ log, logScope, characterSlug, countryCode, payment.currentOrderId, payment, payment.launchNonce, payment.payChannel, payment.payParams, payment.plans, payment.selectedPlanId, paymentDispatch, paymentCurrency, returnTo, subscriptionType, giftCategory, giftPlanId, ]); const shouldShowStripeDialog = stripeClientSecret !== null && stripeClientSecret !== hiddenStripeClientSecret && !payment.isPaid; const shouldShowEzpayConfirmDialog = shouldShowEzpayConfirmation({ currentOrderId: payment.currentOrderId, isProduction: AppEnvUtil.isProduction(), payParams: payment.payParams, paymentUrl: ezpayPaymentUrl, }); const shouldShowRegionalQrDialog = Boolean( regionalQrPayment && !payment.isPaid, ); function resetLaunchState(): void { setIsConfirmingEzpay(false); setHiddenStripeClientSecret(null); } function handleStripeClose(): void { if (stripeClientSecret) { setHiddenStripeClientSecret(stripeClientSecret); } if (payment.orderStatus !== "paid") { paymentDispatch({ type: "PaymentReset" }); } } function handleStripeConfirmed(): void { if (stripeClientSecret) { setHiddenStripeClientSecret(stripeClientSecret); } } function handleEzpayConfirm(): void { if (!payment.currentOrderId || !ezpayPaymentUrl) return; setIsConfirmingEzpay(true); void launchEzpayRedirect({ orderId: payment.currentOrderId, paymentUrl: ezpayPaymentUrl, subscriptionType, giftCategory, giftPlanId, ...(returnTo ? { returnTo } : {}), ...(characterSlug ? { characterSlug } : {}), onOpened: () => trackPaymentCheckoutOpened(payment, ezpayPaymentUrl), onFailed: (errorMessage) => { trackPaymentCheckoutFailed(payment, "payment_redirect_failed"); setIsConfirmingEzpay(false); paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }); }, }); } function handleEzpayCancel(): void { log.debug(`[${logScope}] ezpay confirmation cancelled`, { orderId: payment.currentOrderId, subscriptionType, }); setIsConfirmingEzpay(false); paymentDispatch({ type: "PaymentReset" }); } function handleRegionalQrClose(): void { log.debug(`[${logScope}] regional payment QR dialog closed`, { orderId: regionalQrPayment?.orderId ?? payment.currentOrderId, experience: regionalQrPayment?.experience ?? null, subscriptionType, }); paymentDispatch({ type: "PaymentReset" }); } return { ezpayPaymentUrl, handleEzpayCancel, handleEzpayConfirm, handleRegionalQrClose, handleStripeClose, handleStripeConfirmed, isConfirmingEzpay, regionalQrErrorMessage: payment.errorMessage, regionalQrPayment, regionalQrStatus: payment.orderStatus, resetLaunchState, shouldShowEzpayConfirmDialog, shouldShowRegionalQrDialog, shouldShowStripeDialog, stripeClientSecret, }; }