Order No. {payment.orderId}
- {formatQrisAmount(payment.amountCents, payment.currency)}
+ {formatRegionalQrAmount(payment.amountCents, payment.currency)}
["experience"],
+) {
+ if (experience === "gcashQrPh") {
+ return {
+ paymentName: "GCash / QR Ph",
+ title: "Pay with GCash / QR Ph",
+ description:
+ "Open GCash or another QR Ph-compatible app and scan this code.",
+ qrTitle: "GCash / QR Ph payment QR code",
+ unavailableMessage:
+ "GCash / QR Ph data is unavailable. Please close this dialog and try again.",
+ };
+ }
+ if (experience === "qris") {
+ return {
+ paymentName: "QRIS",
+ title: "Scan to pay with QRIS",
+ description:
+ "Open a QRIS-compatible banking or wallet app and scan this code.",
+ qrTitle: "QRIS payment QR code",
+ unavailableMessage:
+ "QRIS data is unavailable. Please close this dialog and try again.",
+ };
+ }
+ return {
+ paymentName: "payment QR",
+ title: "Scan to pay",
+ description: "Open a compatible banking or wallet app and scan this code.",
+ qrTitle: "Payment QR code",
+ unavailableMessage:
+ "Payment QR data is unavailable. Please close this dialog and try again.",
+ };
+}
+
interface EzpayRedirectConfirmDialogProps {
description: ReactNode;
externalCheckoutAnalyticsKey: string;
diff --git a/src/app/_hooks/use-payment-launch-flow.ts b/src/app/_hooks/use-payment-launch-flow.ts
index b0cb9667..2db626a9 100644
--- a/src/app/_hooks/use-payment-launch-flow.ts
+++ b/src/app/_hooks/use-payment-launch-flow.ts
@@ -4,6 +4,7 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
import {
getPaymentUrl,
+ getPaymentUrlHostname,
getStripeClientSecret,
isEzpayPayment,
launchEzpayRedirect,
@@ -41,31 +42,31 @@ export interface UsePaymentLaunchFlowInput {
subscriptionType: PendingPaymentSubscriptionType;
giftCategory?: string | null;
giftPlanId?: string | null;
+ countryCode?: string | null;
}
export interface PaymentLaunchFlow {
ezpayPaymentUrl: string | null;
handleEzpayCancel: () => void;
handleEzpayConfirm: () => void;
- handleQrisClose: () => void;
- handleQrisResume: () => void;
+ handleRegionalQrClose: () => void;
handleStripeClose: () => void;
handleStripeConfirmed: () => void;
- hasHiddenQrisPayment: boolean;
isConfirmingEzpay: boolean;
- qrisErrorMessage: string | null;
- qrisPayment: QrisPaymentDetails | null;
- qrisStatus: PaymentContextState["orderStatus"];
+ regionalQrErrorMessage: string | null;
+ regionalQrPayment: RegionalQrPaymentDetails | null;
+ regionalQrStatus: PaymentContextState["orderStatus"];
resetLaunchState: () => void;
shouldShowEzpayConfirmDialog: boolean;
- shouldShowQrisDialog: boolean;
+ shouldShowRegionalQrDialog: boolean;
shouldShowStripeDialog: boolean;
stripeClientSecret: string | null;
}
-export interface QrisPaymentDetails {
+export interface RegionalQrPaymentDetails {
amountCents: number;
currency: string;
+ experience: "gcashQrPh" | "qris" | "paymentQr";
orderId: string;
qrData: string;
}
@@ -155,30 +156,36 @@ export function usePaymentLaunchFlow({
subscriptionType,
giftCategory,
giftPlanId,
+ countryCode,
}: 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 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)
+ ? resolveEzpayLaunchTarget(payment.payParams, {
+ countryCode,
+ currency: paymentCurrency,
+ })
: null;
const ezpayPaymentUrl =
ezpayLaunchTarget?.kind === "url"
? ezpayLaunchTarget.paymentUrl
: null;
- const selectedPlan = payment.plans.find(
- (item) => item.planId === payment.selectedPlanId,
- );
- const qrisPayment: QrisPaymentDetails | null =
+ const regionalQrPayment: RegionalQrPaymentDetails | null =
payment.payParams &&
payment.currentOrderId &&
ezpayLaunchTarget?.kind === "qr"
@@ -188,9 +195,9 @@ export function usePaymentLaunchFlow({
selectedPlan?.amountCents ??
0,
currency:
- paymentParamString(payment.payParams, "currency") ??
- selectedPlan?.currency ??
- "IDR",
+ paymentCurrency ??
+ (ezpayLaunchTarget.experience === "gcashQrPh" ? "PHP" : "IDR"),
+ experience: ezpayLaunchTarget.experience,
orderId: payment.currentOrderId,
qrData: ezpayLaunchTarget.qrData,
}
@@ -209,7 +216,33 @@ export function usePaymentLaunchFlow({
const isEzpay = isEzpayPayment(payment.payParams);
if (isEzpay) {
- const target = resolveEzpayLaunchTarget(payment.payParams);
+ 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({
@@ -224,11 +257,18 @@ export function usePaymentLaunchFlow({
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({
type: "PaymentLaunchFailed",
- errorMessage: "Missing order id before showing QRIS.",
+ errorMessage: "Missing order id before showing payment QR code.",
});
return;
}
- trackPaymentCheckoutOpened(payment, "qris_embedded");
+ trackPaymentCheckoutOpened(
+ payment,
+ target.experience === "qris"
+ ? "qris_embedded"
+ : target.experience === "gcashQrPh"
+ ? "gcash_qrph_embedded"
+ : "payment_qr_embedded",
+ );
return;
}
@@ -236,7 +276,7 @@ export function usePaymentLaunchFlow({
if (!AppEnvUtil.isProduction()) {
log.debug(`[${logScope}] ezpay confirmation required`, {
orderId: payment.currentOrderId,
- paymentUrl,
+ paymentUrlHost: getPaymentUrlHostname(paymentUrl),
subscriptionType,
});
return;
@@ -262,7 +302,7 @@ export function usePaymentLaunchFlow({
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
try {
- window.location.href = paymentUrl;
+ window.location.assign(paymentUrl);
trackPaymentCheckoutOpened(payment, paymentUrl);
} catch {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
@@ -283,6 +323,7 @@ export function usePaymentLaunchFlow({
log,
logScope,
characterSlug,
+ countryCode,
payment.currentOrderId,
payment,
payment.launchNonce,
@@ -291,6 +332,7 @@ export function usePaymentLaunchFlow({
payment.plans,
payment.selectedPlanId,
paymentDispatch,
+ paymentCurrency,
returnTo,
subscriptionType,
giftCategory,
@@ -307,22 +349,13 @@ export function usePaymentLaunchFlow({
payParams: payment.payParams,
paymentUrl: ezpayPaymentUrl,
});
- const shouldShowQrisDialog = Boolean(
- qrisPayment &&
- qrisPayment.orderId !== hiddenQrisOrderId &&
- !payment.isPaid,
- );
- const hasHiddenQrisPayment = Boolean(
- qrisPayment &&
- qrisPayment.orderId === hiddenQrisOrderId &&
- payment.isPollingOrder &&
- !payment.isPaid,
+ const shouldShowRegionalQrDialog = Boolean(
+ regionalQrPayment && !payment.isPaid,
);
function resetLaunchState(): void {
setIsConfirmingEzpay(false);
setHiddenStripeClientSecret(null);
- setHiddenQrisOrderId(null);
}
function handleStripeClose(): void {
@@ -369,39 +402,29 @@ export function usePaymentLaunchFlow({
paymentDispatch({ type: "PaymentReset" });
}
- function handleQrisClose(): void {
- log.debug(`[${logScope}] qris dialog closed`, {
- orderId: qrisPayment?.orderId ?? payment.currentOrderId,
+ function handleRegionalQrClose(): void {
+ log.debug(`[${logScope}] regional payment QR dialog closed`, {
+ orderId: regionalQrPayment?.orderId ?? payment.currentOrderId,
+ experience: regionalQrPayment?.experience ?? null,
subscriptionType,
});
- setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
- }
-
- function handleQrisResume(): void {
- if (!hasHiddenQrisPayment) return;
- log.debug(`[${logScope}] qris dialog resumed`, {
- orderId: qrisPayment?.orderId ?? payment.currentOrderId,
- subscriptionType,
- });
- setHiddenQrisOrderId(null);
+ paymentDispatch({ type: "PaymentReset" });
}
return {
ezpayPaymentUrl,
handleEzpayCancel,
handleEzpayConfirm,
- handleQrisClose,
- handleQrisResume,
+ handleRegionalQrClose,
handleStripeClose,
handleStripeConfirmed,
- hasHiddenQrisPayment,
isConfirmingEzpay,
- qrisErrorMessage: payment.errorMessage,
- qrisPayment,
- qrisStatus: payment.orderStatus,
+ regionalQrErrorMessage: payment.errorMessage,
+ regionalQrPayment,
+ regionalQrStatus: payment.orderStatus,
resetLaunchState,
shouldShowEzpayConfirmDialog,
- shouldShowQrisDialog,
+ shouldShowRegionalQrDialog,
shouldShowStripeDialog,
stripeClientSecret,
};
diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx
index af39cf12..6a62c991 100644
--- a/src/app/subscription/components/subscription-checkout-button.tsx
+++ b/src/app/subscription/components/subscription-checkout-button.tsx
@@ -24,6 +24,7 @@ export interface SubscriptionCheckoutButtonProps {
subscriptionType: "vip" | "topup";
returnTo?: SubscriptionReturnTo;
sourceCharacterSlug?: string;
+ countryCode?: string | null;
}
export function SubscriptionCheckoutButton({
@@ -31,6 +32,7 @@ export function SubscriptionCheckoutButton({
subscriptionType,
returnTo = null,
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
+ countryCode = null,
}: SubscriptionCheckoutButtonProps) {
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
@@ -42,25 +44,18 @@ export function SubscriptionCheckoutButton({
returnTo: returnTo ?? undefined,
characterSlug: sourceCharacterSlug,
subscriptionType,
+ countryCode,
});
- const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
- const isLoading =
- payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
+ const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
const readyLabel = "Pay and Top Up";
- const label = isResumingQris
- ? "Resume QRIS payment"
- : payment.isPollingOrder
- ? "Processing payment..."
- : payment.isCreatingOrder
- ? "Creating order..."
- : readyLabel;
+ const label = payment.isPollingOrder
+ ? "Processing payment..."
+ : payment.isCreatingOrder
+ ? "Creating order..."
+ : readyLabel;
const handleClick = () => {
- if (isResumingQris) {
- paymentLaunch.handleQrisResume();
- return;
- }
if (disabled || isLoading) return;
paymentLaunch.resetLaunchState();
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
@@ -71,7 +66,7 @@ export function SubscriptionCheckoutButton({
type="button"
data-analytics-key="subscription.checkout"
data-analytics-label="Start subscription checkout"
- disabled={disabled && !isResumingQris}
+ disabled={disabled}
isLoading={isLoading}
onClick={handleClick}
>
diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx
index 14effc46..bd682263 100644
--- a/src/app/subscription/subscription-screen.tsx
+++ b/src/app/subscription/subscription-screen.tsx
@@ -337,6 +337,7 @@ export function SubscriptionScreen({
subscriptionType={subscriptionType}
returnTo={returnTo}
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
+ countryCode={countryCode}
/>
diff --git a/src/app/tip/tip-checkout-button.tsx b/src/app/tip/tip-checkout-button.tsx
index 19ace199..07db8339 100644
--- a/src/app/tip/tip-checkout-button.tsx
+++ b/src/app/tip/tip-checkout-button.tsx
@@ -19,6 +19,7 @@ export interface TipCheckoutButtonProps {
disabled?: boolean;
onOrder: () => void;
returnPath: string;
+ countryCode?: string | null;
}
export function TipCheckoutButton({
@@ -27,6 +28,7 @@ export function TipCheckoutButton({
disabled = false,
onOrder,
returnPath,
+ countryCode = null,
}: TipCheckoutButtonProps) {
const character = useActiveCharacter();
const payment = usePaymentState();
@@ -40,14 +42,11 @@ export function TipCheckoutButton({
giftCategory,
giftPlanId,
characterSlug: character.slug,
+ countryCode,
});
- const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
- const isLoading =
- payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
- const label = isResumingQris
- ? "Resume QRIS payment"
- : payment.isPollingOrder
+ const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
+ const label = payment.isPollingOrder
? "Processing payment..."
: payment.isCreatingOrder
? "Creating order..."
@@ -62,8 +61,8 @@ export function TipCheckoutButton({
data-analytics-key="tip.checkout"
data-analytics-label="Buy coffee tip"
className={styles.checkoutButton}
- disabled={(disabled && !isResumingQris) || isLoading}
- onClick={isResumingQris ? paymentLaunch.handleQrisResume : onOrder}
+ disabled={disabled || isLoading}
+ onClick={onOrder}
>
{label}
diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx
index 67cc808a..c8f8c9e0 100644
--- a/src/app/tip/tip-screen.tsx
+++ b/src/app/tip/tip-screen.tsx
@@ -305,6 +305,7 @@ export function TipScreen({
disabled={!canCreateOrder}
onOrder={handleOrder}
returnPath={returnPath}
+ countryCode={userState.currentUser?.countryCode}
/>
>
diff --git a/src/lib/payment/__tests__/payment_launch.test.ts b/src/lib/payment/__tests__/payment_launch.test.ts
index d931b6c6..f9f3face 100644
--- a/src/lib/payment/__tests__/payment_launch.test.ts
+++ b/src/lib/payment/__tests__/payment_launch.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import {
getPaymentUrl,
+ getPaymentUrlHostname,
getStripeClientSecret,
isEzpayPayment,
launchEzpayRedirect,
@@ -43,36 +44,103 @@ describe("payment launch helpers", () => {
).toBeNull();
});
- it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => {
+ it("prioritizes a hosted GCash URL for Philippine checkout", () => {
expect(
resolveEzpayLaunchTarget({
provider: "ezpay",
channelType: "URL",
- payData: "https://pay.example/qris",
- }),
- ).toEqual({ kind: "url", paymentUrl: "https://pay.example/qris" });
+ payData: "https://pay.example/gcash",
+ }, { countryCode: "PH", currency: "PHP" }),
+ ).toEqual({ kind: "url", paymentUrl: "https://pay.example/gcash" });
+
expect(
resolveEzpayLaunchTarget({
provider: "ezpay",
channelType: "QR",
- payData: "00020101021226670016COM.NOBUBANK.WWW",
- }),
+ payData: "000201010212ph-qr-payload",
+ cashierUrl: "https://pay.example/gcash-hosted",
+ }, { countryCode: "PH" }),
+ ).toEqual({
+ kind: "url",
+ paymentUrl: "https://pay.example/gcash-hosted",
+ });
+ });
+
+ it("keeps a usable QR Ph fallback without calling it QRIS", () => {
+ expect(
+ resolveEzpayLaunchTarget(
+ {
+ provider: "ezpay",
+ channelType: "QR",
+ payData: "000201010212ph-qr-payload",
+ },
+ { currency: "PHP" },
+ ),
).toEqual({
kind: "qr",
+ experience: "gcashQrPh",
+ qrData: "000201010212ph-qr-payload",
+ });
+
+ expect(
+ resolveEzpayLaunchTarget(
+ {
+ provider: "ezpay",
+ payData: "000201010212ph-qr-payload",
+ },
+ { countryCode: "PH" },
+ ),
+ ).toEqual({
+ kind: "qr",
+ experience: "gcashQrPh",
+ qrData: "000201010212ph-qr-payload",
+ });
+ });
+
+ it("keeps Indonesian QRIS ahead of an optional hosted URL", () => {
+ expect(
+ resolveEzpayLaunchTarget(
+ {
+ provider: "ezpay",
+ channelType: "QR",
+ payData: "00020101021226670016COM.NOBUBANK.WWW",
+ cashierUrl: "https://pay.example/indonesia",
+ },
+ { countryCode: "ID", currency: "IDR" },
+ ),
+ ).toEqual({
+ kind: "qr",
+ experience: "qris",
qrData: "00020101021226670016COM.NOBUBANK.WWW",
});
+ });
+
+ it("reports a regional error only when both URL and QR data are absent", () => {
expect(
- resolveEzpayLaunchTarget({
- provider: "ezpay",
- channelType: "QR",
- payData: "",
- }),
+ resolveEzpayLaunchTarget(
+ {
+ provider: "ezpay",
+ channelType: "QR",
+ payData: "",
+ },
+ { countryCode: "PH" },
+ ),
).toEqual({
kind: "error",
- errorMessage: "QRIS payment data is missing. Please try again.",
+ errorMessage:
+ "GCash / QR Ph payment data is missing. Please try again.",
});
});
+ it("keeps diagnostics to the payment URL hostname", () => {
+ expect(
+ getPaymentUrlHostname(
+ "https://pay.example/gcash?token=must-not-appear-in-logs",
+ ),
+ ).toBe("pay.example");
+ expect(getPaymentUrlHostname("not-a-url")).toBeNull();
+ });
+
it("rejects VA and unknown Ezpay channel types with explicit errors", () => {
expect(
resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }),
diff --git a/src/lib/payment/payment_launch.ts b/src/lib/payment/payment_launch.ts
index 71b93286..21b6b275 100644
--- a/src/lib/payment/payment_launch.ts
+++ b/src/lib/payment/payment_launch.ts
@@ -13,9 +13,20 @@ const log = new Logger("LibPaymentPaymentLaunch");
export type EzpayLaunchTarget =
| { kind: "url"; paymentUrl: string }
- | { kind: "qr"; qrData: 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