"use client"; import { type Dispatch, useEffect, useRef, useState } from "react"; import { getPaymentUrl, getStripeCustomerSessionClientSecret, 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; } export interface PaymentLaunchFlow { ezpayPaymentUrl: string | null; handleEzpayCancel: () => void; handleEzpayConfirm: () => void; handleQrisClose: () => void; handleStripeClose: () => void; handleStripeConfirmed: () => void; isConfirmingEzpay: boolean; qrisErrorMessage: string | null; qrisPayment: QrisPaymentDetails | null; qrisStatus: PaymentContextState["orderStatus"]; resetLaunchState: () => void; shouldShowEzpayConfirmDialog: boolean; shouldShowQrisDialog: boolean; shouldShowStripeDialog: boolean; stripeClientSecret: string | null; stripeCustomerSessionClientSecret: string | null; savedPaymentMethodsEnabled: boolean; } export interface QrisPaymentDetails { amountCents: number; currency: string; 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, }: UsePaymentLaunchFlowInput): PaymentLaunchFlow { const launchedNonceRef = useRef(0); const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< string | null >(null); const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false); const [hiddenQrisOrderId, setHiddenQrisOrderId] = useState( null, ); const stripeClientSecret = payment.payParams ? getStripeClientSecret(payment.payParams) : null; const stripeCustomerSessionClientSecret = payment.payParams ? getStripeCustomerSessionClientSecret(payment.payParams) : null; const ezpayLaunchTarget = payment.payParams && isEzpayPayment(payment.payParams) ? resolveEzpayLaunchTarget(payment.payParams) : null; const ezpayPaymentUrl = ezpayLaunchTarget?.kind === "url" ? ezpayLaunchTarget.paymentUrl : null; const selectedPlan = payment.plans.find( (item) => item.planId === payment.selectedPlanId, ); const qrisPayment: QrisPaymentDetails | null = payment.payParams && payment.currentOrderId && ezpayLaunchTarget?.kind === "qr" ? { amountCents: paymentParamAmountCents(payment.payParams) ?? selectedPlan?.amountCents ?? 0, currency: paymentParamString(payment.payParams, "currency") ?? selectedPlan?.currency ?? "IDR", 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); 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 QRIS.", }); return; } trackPaymentCheckoutOpened(payment, "qris_embedded"); return; } const paymentUrl = target.paymentUrl; if (!AppEnvUtil.isProduction()) { log.debug(`[${logScope}] ezpay confirmation required`, { orderId: payment.currentOrderId, 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.href = 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, payment.currentOrderId, payment, payment.launchNonce, payment.payChannel, payment.payParams, payment.plans, payment.selectedPlanId, paymentDispatch, 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 shouldShowQrisDialog = Boolean( qrisPayment && qrisPayment.orderId !== hiddenQrisOrderId && !payment.isPaid, ); function resetLaunchState(): void { setIsConfirmingEzpay(false); setHiddenStripeClientSecret(null); setHiddenQrisOrderId(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 handleQrisClose(): void { log.debug(`[${logScope}] qris dialog closed`, { orderId: qrisPayment?.orderId ?? payment.currentOrderId, subscriptionType, }); setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId); } return { ezpayPaymentUrl, handleEzpayCancel, handleEzpayConfirm, handleQrisClose, handleStripeClose, handleStripeConfirmed, isConfirmingEzpay, qrisErrorMessage: payment.errorMessage, qrisPayment, qrisStatus: payment.orderStatus, resetLaunchState, shouldShowEzpayConfirmDialog, shouldShowQrisDialog, shouldShowStripeDialog, stripeClientSecret, stripeCustomerSessionClientSecret, savedPaymentMethodsEnabled: stripeCustomerSessionClientSecret !== null, }; }