399 lines
11 KiB
TypeScript
399 lines
11 KiB
TypeScript
"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<string, unknown> | null;
|
|
paymentUrl: string | null;
|
|
}
|
|
|
|
export interface UsePaymentLaunchFlowInput {
|
|
log: Logger;
|
|
logScope: string;
|
|
payment: PaymentContextState;
|
|
paymentDispatch: Dispatch<PaymentEvent>;
|
|
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<string, unknown>,
|
|
...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<string, unknown>,
|
|
): 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<string | null>(
|
|
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,
|
|
};
|
|
}
|