feat(payment): support Indonesia QRIS checkout
This commit is contained in:
@@ -6,10 +6,15 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
|
||||
const hiddenLaunch = {
|
||||
handleEzpayCancel: vi.fn(),
|
||||
handleEzpayConfirm: vi.fn(),
|
||||
handleQrisClose: vi.fn(),
|
||||
handleStripeClose: vi.fn(),
|
||||
handleStripeConfirmed: vi.fn(),
|
||||
isConfirmingEzpay: false,
|
||||
qrisErrorMessage: null,
|
||||
qrisPayment: null,
|
||||
qrisStatus: null,
|
||||
shouldShowEzpayConfirmDialog: false,
|
||||
shouldShowQrisDialog: false,
|
||||
shouldShowStripeDialog: false,
|
||||
stripeClientSecret: null,
|
||||
};
|
||||
@@ -43,10 +48,65 @@ describe("PaymentLaunchDialogs", () => {
|
||||
);
|
||||
|
||||
expect(html).toContain('role="alertdialog"');
|
||||
expect(html).toContain("Continue to GCash?");
|
||||
expect(html).toContain("Continue to payment?");
|
||||
expect(html).toContain("Your coffee order is ready.");
|
||||
expect(html).toContain("Order No. order-123");
|
||||
expect(html).toContain('data-analytics-key="tip.external_checkout"');
|
||||
expect(html).toContain("Opening...");
|
||||
});
|
||||
|
||||
it("renders an accessible QRIS dialog with display cents and order status", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PaymentLaunchDialogs
|
||||
currentOrderId="order-id-qris"
|
||||
externalCheckoutAnalyticsKey="tip.external_checkout"
|
||||
ezpayDescription="Scan QRIS to finish the payment."
|
||||
launch={{
|
||||
...hiddenLaunch,
|
||||
qrisPayment: {
|
||||
qrData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
orderId: "order-id-qris",
|
||||
amountCents: 5_000_000,
|
||||
currency: "IDR",
|
||||
},
|
||||
qrisStatus: "pending",
|
||||
shouldShowQrisDialog: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('role="dialog"');
|
||||
expect(html).toContain("Scan to pay with QRIS");
|
||||
expect(html).toContain("QRIS payment QR code");
|
||||
expect(html).toContain("Order No. order-id-qris");
|
||||
expect(html).toContain("Rp");
|
||||
expect(html).toContain("50.000");
|
||||
expect(html).toContain("Waiting for payment");
|
||||
});
|
||||
|
||||
it("shows QRIS failure state and handles empty QR data safely", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PaymentLaunchDialogs
|
||||
currentOrderId="order-id-qris"
|
||||
externalCheckoutAnalyticsKey="payment.external_checkout"
|
||||
ezpayDescription="Scan QRIS to finish the payment."
|
||||
launch={{
|
||||
...hiddenLaunch,
|
||||
qrisErrorMessage: "Payment failed or was cancelled.",
|
||||
qrisPayment: {
|
||||
qrData: "",
|
||||
orderId: "order-id-qris",
|
||||
amountCents: 5_000_000,
|
||||
currency: "IDR",
|
||||
},
|
||||
qrisStatus: "failed",
|
||||
shouldShowQrisDialog: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("QRIS data is unavailable");
|
||||
expect(html).toContain("Payment failed or was cancelled.");
|
||||
expect(html).toContain("Close");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,27 @@ describe("PaymentMethodSelector", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("labels Indonesia Ezpay as QRIS without the GCash logo", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PaymentMethodSelector
|
||||
config={{
|
||||
canChoosePaymentMethod: true,
|
||||
initialPayChannel: "ezpay",
|
||||
showPaymentMethodSelector: true,
|
||||
ezpayDisplayName: "QRIS",
|
||||
}}
|
||||
value="ezpay"
|
||||
caption="QRIS by default in Indonesia"
|
||||
analyticsKey="subscription.payment_method"
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('aria-label="QRIS"');
|
||||
expect(html).toContain("QRIS by default in Indonesia");
|
||||
expect(html).not.toContain("gcash-logo.svg");
|
||||
});
|
||||
|
||||
it("renders compact controls without the caption", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PaymentMethodSelector
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
|
||||
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||
|
||||
@@ -11,10 +12,15 @@ type PaymentLaunchDialogFlow = Pick<
|
||||
PaymentLaunchFlow,
|
||||
| "handleEzpayCancel"
|
||||
| "handleEzpayConfirm"
|
||||
| "handleQrisClose"
|
||||
| "handleStripeClose"
|
||||
| "handleStripeConfirmed"
|
||||
| "isConfirmingEzpay"
|
||||
| "qrisErrorMessage"
|
||||
| "qrisPayment"
|
||||
| "qrisStatus"
|
||||
| "shouldShowEzpayConfirmDialog"
|
||||
| "shouldShowQrisDialog"
|
||||
| "shouldShowStripeDialog"
|
||||
| "stripeClientSecret"
|
||||
>;
|
||||
@@ -46,6 +52,14 @@ export function PaymentLaunchDialogs({
|
||||
onConfirm={launch.handleEzpayConfirm}
|
||||
/>
|
||||
) : null}
|
||||
{launch.shouldShowQrisDialog && launch.qrisPayment ? (
|
||||
<QrisPaymentDialog
|
||||
payment={launch.qrisPayment}
|
||||
status={launch.qrisStatus}
|
||||
errorMessage={launch.qrisErrorMessage}
|
||||
onClose={launch.handleQrisClose}
|
||||
/>
|
||||
) : null}
|
||||
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
||||
<LazyStripePaymentDialog
|
||||
clientSecret={launch.stripeClientSecret}
|
||||
@@ -58,6 +72,102 @@ export function PaymentLaunchDialogs({
|
||||
);
|
||||
}
|
||||
|
||||
interface QrisPaymentDialogProps {
|
||||
errorMessage: string | null;
|
||||
onClose: () => void;
|
||||
payment: NonNullable<PaymentLaunchFlow["qrisPayment"]>;
|
||||
status: PaymentLaunchFlow["qrisStatus"];
|
||||
}
|
||||
|
||||
function formatQrisAmount(amountCents: number, currency: string): string {
|
||||
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
|
||||
try {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: normalizedCurrency,
|
||||
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
|
||||
}).format(amountCents / 100);
|
||||
} catch {
|
||||
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function qrisStatusMessage(
|
||||
status: PaymentLaunchFlow["qrisStatus"],
|
||||
errorMessage: string | null,
|
||||
): string {
|
||||
if (status === "failed") return errorMessage || "Payment failed. Please try again.";
|
||||
if (status === "expired") return errorMessage || "This QRIS order has expired.";
|
||||
return "Waiting for payment";
|
||||
}
|
||||
|
||||
function QrisPaymentDialog({
|
||||
errorMessage,
|
||||
onClose,
|
||||
payment,
|
||||
status,
|
||||
}: QrisPaymentDialogProps) {
|
||||
const titleId = useId();
|
||||
const statusMessage = qrisStatusMessage(status, errorMessage);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className={styles.dialog}>
|
||||
<div className={`${styles.header} text-center`}>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
Scan to pay with QRIS
|
||||
</h2>
|
||||
<p className={styles.content}>
|
||||
Open a QRIS-compatible banking or wallet app and scan this code.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4">
|
||||
{payment.qrData ? (
|
||||
<QRCodeSVG
|
||||
value={payment.qrData}
|
||||
title="QRIS payment QR code"
|
||||
size={256}
|
||||
level="M"
|
||||
marginSize={2}
|
||||
className="h-auto w-full max-w-64"
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.error} role="alert">
|
||||
QRIS data is unavailable. Please close this dialog and try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-4 flex flex-col gap-2 text-center">
|
||||
<p className={styles.content}>Order No. {payment.orderId}</p>
|
||||
<p className="m-0 text-xl font-bold text-text-foreground">
|
||||
{formatQrisAmount(payment.amountCents, payment.currency)}
|
||||
</p>
|
||||
<p
|
||||
className={status === "failed" || status === "expired" ? styles.error : styles.content}
|
||||
role="status"
|
||||
>
|
||||
{statusMessage}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.button} ${styles.secondary}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EzpayRedirectConfirmDialogProps {
|
||||
description: ReactNode;
|
||||
externalCheckoutAnalyticsKey: string;
|
||||
@@ -87,7 +197,7 @@ function EzpayRedirectConfirmDialog({
|
||||
<div className={styles.dialog}>
|
||||
<div className={styles.header}>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
Continue to GCash?
|
||||
Continue to payment?
|
||||
</h2>
|
||||
<p className={styles.content}>{description}</p>
|
||||
<p className={styles.content}>Order No. {orderId}</p>
|
||||
|
||||
@@ -44,13 +44,24 @@ export function PaymentMethodSelector({
|
||||
}: PaymentMethodSelectorProps) {
|
||||
if (!config.showPaymentMethodSelector) return null;
|
||||
const isCompact = density === "compact";
|
||||
const ezpayDisplayName = config.ezpayDisplayName ?? "GCash";
|
||||
const configuredPaymentMethods = PAYMENT_METHODS.map((method) =>
|
||||
method.channel === "ezpay"
|
||||
? {
|
||||
...method,
|
||||
title: ezpayDisplayName,
|
||||
logoSrc:
|
||||
ezpayDisplayName === "GCash" ? method.logoSrc : undefined,
|
||||
}
|
||||
: method,
|
||||
);
|
||||
|
||||
const paymentMethods =
|
||||
config.initialPayChannel === "ezpay"
|
||||
? [...PAYMENT_METHODS].sort((a, b) =>
|
||||
? [...configuredPaymentMethods].sort((a, b) =>
|
||||
a.channel === "ezpay" ? -1 : b.channel === "ezpay" ? 1 : 0,
|
||||
)
|
||||
: PAYMENT_METHODS;
|
||||
: configuredPaymentMethods;
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getStripeClientSecret,
|
||||
isEzpayPayment,
|
||||
launchEzpayRedirect,
|
||||
resolveEzpayLaunchTarget,
|
||||
} from "@/lib/payment/payment_launch";
|
||||
import { behaviorAnalytics } from "@/lib/analytics";
|
||||
import type {
|
||||
@@ -46,15 +47,55 @@ 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;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -118,12 +159,40 @@ export function usePaymentLaunchFlow({
|
||||
string | null
|
||||
>(null);
|
||||
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
|
||||
const [hiddenQrisOrderId, setHiddenQrisOrderId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const stripeClientSecret = payment.payParams
|
||||
? getStripeClientSecret(payment.payParams)
|
||||
: null;
|
||||
const ezpayPaymentUrl = payment.payParams
|
||||
? getPaymentUrl(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;
|
||||
@@ -136,11 +205,33 @@ export function usePaymentLaunchFlow({
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentUrl = getPaymentUrl(payment.payParams);
|
||||
if (paymentUrl) {
|
||||
const isEzpay = isEzpayPayment(payment.payParams);
|
||||
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 (!AppEnvUtil.isProduction() && isEzpay) {
|
||||
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,
|
||||
@@ -149,24 +240,25 @@ export function usePaymentLaunchFlow({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEzpay) {
|
||||
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;
|
||||
}
|
||||
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);
|
||||
@@ -213,10 +305,16 @@ export function usePaymentLaunchFlow({
|
||||
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 {
|
||||
@@ -263,15 +361,28 @@ export function usePaymentLaunchFlow({
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -95,7 +95,7 @@ export function SubscriptionCheckoutButton({
|
||||
<PaymentLaunchDialogs
|
||||
currentOrderId={payment.currentOrderId}
|
||||
externalCheckoutAnalyticsKey="subscription.external_checkout"
|
||||
ezpayDescription="Your order has been created. Continue to GCash to finish the payment."
|
||||
ezpayDescription="Your order has been created. Continue to the secure payment page to finish."
|
||||
launch={paymentLaunch}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type PaymentAnalyticsContext,
|
||||
} from "@/lib/analytics";
|
||||
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
|
||||
import { useHasHydrated } from "@/hooks/use-has-hydrated";
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
|
||||
import {
|
||||
@@ -55,11 +56,15 @@ export function SubscriptionScreen({
|
||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
}: SubscriptionScreenProps) {
|
||||
const userState = useUserState();
|
||||
const hasHydrated = useHasHydrated();
|
||||
const countryCode = userState.currentUser?.countryCode;
|
||||
const paymentMethodConfig = getPaymentMethodConfig({
|
||||
countryCode,
|
||||
requestedPayChannel: initialPayChannel,
|
||||
});
|
||||
const renderedPaymentMethodConfig = hasHydrated
|
||||
? paymentMethodConfig
|
||||
: { ...paymentMethodConfig, showPaymentMethodSelector: false };
|
||||
const {
|
||||
payment,
|
||||
paymentDispatch,
|
||||
@@ -225,10 +230,14 @@ export function SubscriptionScreen({
|
||||
</div>
|
||||
|
||||
<PaymentMethodSelector
|
||||
config={paymentMethodConfig}
|
||||
config={renderedPaymentMethodConfig}
|
||||
value={payment.payChannel}
|
||||
disabled={isPaymentBusy}
|
||||
caption="GCash by default in the Philippines"
|
||||
caption={
|
||||
paymentMethodConfig.ezpayDisplayName === "QRIS"
|
||||
? "QRIS by default in Indonesia"
|
||||
: "GCash by default in the Philippines"
|
||||
}
|
||||
className={styles.paymentMethodSlot}
|
||||
analyticsKey="subscription.payment_method"
|
||||
onChange={handlePaymentMethodChange}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function TipCheckoutButton({
|
||||
<PaymentLaunchDialogs
|
||||
currentOrderId={payment.currentOrderId}
|
||||
externalCheckoutAnalyticsKey="tip.external_checkout"
|
||||
ezpayDescription="Your coffee order is ready. Continue to GCash to finish the payment."
|
||||
ezpayDescription="Your gift order is ready. Continue to the secure payment page to finish."
|
||||
launch={paymentLaunch}
|
||||
stripeReturnPath={returnPath}
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type PaymentAnalyticsContext,
|
||||
} from "@/lib/analytics";
|
||||
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
|
||||
import { useHasHydrated } from "@/hooks/use-has-hydrated";
|
||||
import { buildTipGiftPath } from "@/lib/tip/tip_gift";
|
||||
import {
|
||||
useActiveCharacter,
|
||||
@@ -50,11 +51,15 @@ export function TipScreen({
|
||||
const character = useActiveCharacter();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const userState = useUserState();
|
||||
const hasHydrated = useHasHydrated();
|
||||
const supportPrompt = useTipSupportPrompt();
|
||||
const paymentMethodConfig = getPaymentMethodConfig({
|
||||
countryCode: userState.currentUser?.countryCode,
|
||||
requestedPayChannel: initialPayChannel,
|
||||
});
|
||||
const renderedPaymentMethodConfig = hasHydrated
|
||||
? paymentMethodConfig
|
||||
: { ...paymentMethodConfig, showPaymentMethodSelector: false };
|
||||
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
||||
catalog: "tip",
|
||||
characterId: character.id,
|
||||
@@ -281,7 +286,7 @@ export function TipScreen({
|
||||
</section>
|
||||
|
||||
<PaymentMethodSelector
|
||||
config={paymentMethodConfig}
|
||||
config={renderedPaymentMethodConfig}
|
||||
value={payment.payChannel}
|
||||
density="compact"
|
||||
disabled={isPaymentBusy}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getStripeClientSecret,
|
||||
isEzpayPayment,
|
||||
launchEzpayRedirect,
|
||||
resolveEzpayLaunchTarget,
|
||||
} from "../payment_launch";
|
||||
|
||||
describe("payment launch helpers", () => {
|
||||
@@ -42,6 +43,51 @@ describe("payment launch helpers", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => {
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({
|
||||
provider: "ezpay",
|
||||
channelType: "URL",
|
||||
payData: "https://pay.example/qris",
|
||||
}),
|
||||
).toEqual({ kind: "url", paymentUrl: "https://pay.example/qris" });
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({
|
||||
provider: "ezpay",
|
||||
channelType: "QR",
|
||||
payData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "qr",
|
||||
qrData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
});
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({
|
||||
provider: "ezpay",
|
||||
channelType: "QR",
|
||||
payData: "",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "error",
|
||||
errorMessage: "QRIS payment data is missing. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects VA and unknown Ezpay channel types with explicit errors", () => {
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }),
|
||||
).toEqual({
|
||||
kind: "error",
|
||||
errorMessage: "Virtual account payments are not supported in this app.",
|
||||
});
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "OTHER" }),
|
||||
).toEqual({
|
||||
kind: "error",
|
||||
errorMessage: "Unsupported Ezpay payment channel: OTHER.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a launch failure before redirecting without an order id", async () => {
|
||||
const onOpened = vi.fn();
|
||||
const onFailed = vi.fn();
|
||||
|
||||
@@ -12,18 +12,21 @@ describe("payment method rules", () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("defaults Philippines users to GCash and other countries to Stripe", () => {
|
||||
it("defaults Philippines and Indonesia users to their regional Ezpay method", () => {
|
||||
expect(getDefaultPayChannelForCountryCode("PH")).toBe("ezpay");
|
||||
expect(getDefaultPayChannelForCountryCode("ph")).toBe("ezpay");
|
||||
expect(getDefaultPayChannelForCountryCode("ID")).toBe("ezpay");
|
||||
expect(getDefaultPayChannelForCountryCode(" id ")).toBe("ezpay");
|
||||
expect(getDefaultPayChannelForCountryCode("HK")).toBe("stripe");
|
||||
expect(getDefaultPayChannelForCountryCode(null)).toBe("stripe");
|
||||
});
|
||||
|
||||
it("only allows Philippines users to choose in production", () => {
|
||||
it("allows Philippines and Indonesia users to choose in production", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
|
||||
|
||||
expect(canChoosePayChannel("PH")).toBe(true);
|
||||
expect(canChoosePayChannel("ph")).toBe(true);
|
||||
expect(canChoosePayChannel("ID")).toBe(true);
|
||||
expect(canChoosePayChannel("HK")).toBe(false);
|
||||
expect(canChoosePayChannel(null)).toBe(false);
|
||||
expect(
|
||||
@@ -35,6 +38,7 @@ describe("payment method rules", () => {
|
||||
canChoosePaymentMethod: false,
|
||||
initialPayChannel: "stripe",
|
||||
showPaymentMethodSelector: false,
|
||||
ezpayDisplayName: "GCash",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +56,25 @@ describe("payment method rules", () => {
|
||||
).toBe("stripe");
|
||||
});
|
||||
|
||||
it("defaults Indonesia to QRIS but permits an explicit Stripe choice", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
|
||||
|
||||
expect(
|
||||
getPaymentMethodConfig({
|
||||
countryCode: "ID",
|
||||
requestedPayChannel: null,
|
||||
}),
|
||||
).toEqual({
|
||||
canChoosePaymentMethod: true,
|
||||
initialPayChannel: "ezpay",
|
||||
showPaymentMethodSelector: true,
|
||||
ezpayDisplayName: "QRIS",
|
||||
});
|
||||
expect(
|
||||
resolvePayChannel({ countryCode: "ID", requestedPayChannel: "stripe" }),
|
||||
).toBe("stripe");
|
||||
});
|
||||
|
||||
it("keeps test channels but shows the selector only for Philippines", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
|
||||
|
||||
@@ -71,6 +94,7 @@ describe("payment method rules", () => {
|
||||
canChoosePaymentMethod: true,
|
||||
initialPayChannel: "ezpay",
|
||||
showPaymentMethodSelector: false,
|
||||
ezpayDisplayName: "GCash",
|
||||
});
|
||||
expect(
|
||||
getPaymentMethodConfig({
|
||||
|
||||
@@ -3,5 +3,8 @@ import type { PayChannel } from "@/data/schemas/payment";
|
||||
export function getDefaultPayChannelForCountryCode(
|
||||
countryCode: string | null | undefined,
|
||||
): PayChannel {
|
||||
return countryCode?.trim().toUpperCase() === "PH" ? "ezpay" : "stripe";
|
||||
const normalizedCountryCode = countryCode?.trim().toUpperCase();
|
||||
return normalizedCountryCode === "PH" || normalizedCountryCode === "ID"
|
||||
? "ezpay"
|
||||
: "stripe";
|
||||
}
|
||||
|
||||
@@ -11,6 +11,36 @@ import {
|
||||
|
||||
const log = new Logger("LibPaymentPaymentLaunch");
|
||||
|
||||
export type EzpayLaunchTarget =
|
||||
| { kind: "url"; paymentUrl: string }
|
||||
| { kind: "qr"; qrData: string }
|
||||
| { kind: "error"; errorMessage: string };
|
||||
|
||||
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 getPaymentUrl(payParams: Record<string, unknown>): string | null {
|
||||
const keys = [
|
||||
"cashierUrl",
|
||||
@@ -39,6 +69,61 @@ export function isEzpayPayment(payParams: Record<string, unknown>): boolean {
|
||||
return typeof provider === "string" && provider.toLowerCase() === "ezpay";
|
||||
}
|
||||
|
||||
export function resolveEzpayLaunchTarget(
|
||||
payParams: Record<string, unknown>,
|
||||
): EzpayLaunchTarget {
|
||||
const rawChannelType = getNonEmptyString(
|
||||
payParams,
|
||||
"channelType",
|
||||
"channel_type",
|
||||
);
|
||||
const channelType = rawChannelType?.toUpperCase() ?? null;
|
||||
const payData = getNonEmptyString(payParams, "payData", "pay_data");
|
||||
|
||||
if (channelType === "QR") {
|
||||
return payData
|
||||
? { kind: "qr", qrData: payData }
|
||||
: {
|
||||
kind: "error",
|
||||
errorMessage: "QRIS payment data is missing. Please try again.",
|
||||
};
|
||||
}
|
||||
|
||||
if (channelType === "VA") {
|
||||
return {
|
||||
kind: "error",
|
||||
errorMessage: "Virtual account payments are not supported in this app.",
|
||||
};
|
||||
}
|
||||
|
||||
if (channelType === "URL") {
|
||||
const paymentUrl =
|
||||
getHttpPaymentUrl(payData) ?? getHttpPaymentUrl(getPaymentUrl(payParams));
|
||||
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}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
|
||||
return legacyPaymentUrl
|
||||
? { kind: "url", paymentUrl: legacyPaymentUrl }
|
||||
: {
|
||||
kind: "error",
|
||||
errorMessage:
|
||||
"Ezpay payment parameters did not include a supported URL or QRIS code.",
|
||||
};
|
||||
}
|
||||
|
||||
export function getStripeClientSecret(
|
||||
payParams: Record<string, unknown>,
|
||||
): string | null {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getDefaultPayChannelForCountryCode } from "./default_pay_channel";
|
||||
|
||||
export interface PaymentMethodConfig {
|
||||
canChoosePaymentMethod: boolean;
|
||||
ezpayDisplayName?: "GCash" | "QRIS";
|
||||
initialPayChannel: PayChannel;
|
||||
showPaymentMethodSelector: boolean;
|
||||
}
|
||||
@@ -13,7 +14,7 @@ export function canChoosePayChannel(
|
||||
countryCode: string | null | undefined,
|
||||
): boolean {
|
||||
if (!AppEnvUtil.isProduction()) return true;
|
||||
return isPhilippinesCountryCode(countryCode);
|
||||
return isRegionalEzpayCountryCode(countryCode);
|
||||
}
|
||||
|
||||
export function resolvePayChannel(input: {
|
||||
@@ -33,13 +34,23 @@ export function getPaymentMethodConfig(input: {
|
||||
}): PaymentMethodConfig {
|
||||
return {
|
||||
canChoosePaymentMethod: canChoosePayChannel(input.countryCode),
|
||||
ezpayDisplayName: isIndonesiaCountryCode(input.countryCode)
|
||||
? "QRIS"
|
||||
: "GCash",
|
||||
initialPayChannel: resolvePayChannel(input),
|
||||
showPaymentMethodSelector: isPhilippinesCountryCode(input.countryCode),
|
||||
showPaymentMethodSelector: isRegionalEzpayCountryCode(input.countryCode),
|
||||
};
|
||||
}
|
||||
|
||||
function isPhilippinesCountryCode(
|
||||
function isRegionalEzpayCountryCode(
|
||||
countryCode: string | null | undefined,
|
||||
): boolean {
|
||||
return countryCode?.trim().toUpperCase() === "PH";
|
||||
const normalizedCountryCode = countryCode?.trim().toUpperCase();
|
||||
return normalizedCountryCode === "PH" || normalizedCountryCode === "ID";
|
||||
}
|
||||
|
||||
function isIndonesiaCountryCode(
|
||||
countryCode: string | null | undefined,
|
||||
): boolean {
|
||||
return countryCode?.trim().toUpperCase() === "ID";
|
||||
}
|
||||
|
||||
@@ -256,7 +256,10 @@ describe("payment order flow", () => {
|
||||
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
orderStatus: "expired",
|
||||
payParams: null,
|
||||
payParams: {
|
||||
provider: "stripe",
|
||||
clientSecret: "pi_test_secret_test",
|
||||
},
|
||||
errorMessage:
|
||||
"This payment order has expired. Please create a new order.",
|
||||
});
|
||||
|
||||
@@ -156,7 +156,6 @@ const applyFailedOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
|
||||
|
||||
const applyExpiredOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
|
||||
orderStatus: event.output.status,
|
||||
payParams: null,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: "This payment order has expired. Please create a new order.",
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user