1 Commits

Author SHA1 Message Date
Codex 599f3e3d26 fix(payment): route Philippine GCash separately from QRIS
Docker Image / Build and Push Docker Image (push) Successful in 2m12s
2026-07-29 19:21:07 +08:00
11 changed files with 583 additions and 180 deletions
+16 -30
View File
@@ -283,14 +283,14 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
).toBeVisible({ timeout: 10_000 });
});
test("closing QRIS restores a resumable checkout without creating a duplicate order", async ({
test("closing QRIS restores payment selection and allows switching to Stripe", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup&character=elio");
const orderId = await expectQrisOrder(
await expectQrisOrder(
page,
/Pay and Top Up/i,
"dol_1000",
@@ -300,20 +300,14 @@ test("closing QRIS restores a resumable checkout without creating a duplicate or
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
const resumeButton = page.getByRole("button", {
name: "Resume QRIS payment",
});
await expect(resumeButton).toBeEnabled();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.getByRole("button", { name: "Pay and Top Up" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
await resumeButton.click();
await expect(dialog).toBeVisible();
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
await expect(
page.getByRole("alertdialog", { name: "Payment successful" }),
).toBeVisible({ timeout: 10_000 });
});
test("closing QRIS allows switching to Stripe and creating a Stripe order", async ({
@@ -408,7 +402,7 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
await page.goto("/characters/elio/tip");
await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible();
const orderId = await expectQrisOrder(
await expectQrisOrder(
page,
/Order and Buy/i,
"tip_coffee_usd_4_99",
@@ -418,19 +412,11 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
const resumeButton = page.getByRole("button", {
name: "Resume QRIS payment",
});
await expect(resumeButton).toBeEnabled();
await resumeButton.click();
await expect(dialog).toBeVisible();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Send another gift" })).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toBeHidden();
});
@@ -0,0 +1,153 @@
import { expect, test, type Page } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { apiEnvelope } from "@e2e/fixtures/data/common";
import { e2eEmailUser } from "@e2e/fixtures/data/user";
import {
clearBrowserState,
seedEmailSession,
} from "@e2e/fixtures/test-helpers";
const phpPlans = {
plans: [
{
planId: "dol_1000",
planName: "1,000 Credits",
orderType: "dol",
amountCents: 49_990,
originalAmountCents: 74_990,
dailyPriceCents: null,
currency: "PHP",
vipDays: null,
dolAmount: 1_000,
creditBalance: 1_000,
},
],
};
async function registerPhilippinesPaymentMocks(
page: Page,
response: "hosted" | "qr",
) {
let createOrderCount = 0;
await page.route("**/api/user/profile", async (route) => {
await route.fulfill({
json: apiEnvelope({ ...e2eEmailUser, countryCode: "PH" }),
});
});
await page.route("**/api/payment/plans**", async (route) => {
await route.fulfill({ json: apiEnvelope(phpPlans) });
});
await page.route("**/api/payment/create-order", async (route) => {
createOrderCount += 1;
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
payParams: {
provider: "ezpay",
countryCode: "PH",
channelCode: "PH_QRPH_DYNAMIC",
channelType: "QR",
payData: "000201010212ph-qr-payload",
...(response === "hosted"
? { cashierUrl: "https://pay.example/gcash-hosted" }
: {}),
firstChargeAmountCents: 49_990,
currency: "PHP",
},
}),
});
});
await page.route("**/api/payment/order-status**", async (route) => {
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
status: "pending",
orderType: "dol",
planId: "dol_1000",
creditsAdded: 0,
}),
});
});
return {
getCreateOrderCount: () => createOrderCount,
};
}
async function preparePhilippinesUser(page: Page) {
await seedEmailSession(page);
await page.evaluate(() => {
const rawUser = localStorage.getItem("cozsweet:user");
const user = rawUser ? JSON.parse(rawUser) : {};
localStorage.setItem(
"cozsweet:user",
JSON.stringify({ ...user, countryCode: "PH" }),
);
});
}
test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page);
});
test("Philippines checkout prefers the hosted GCash URL even for a QR response", async ({
page,
}) => {
const payment = await registerPhilippinesPaymentMocks(page, "hosted");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await expect(page.getByRole("button", { name: "GCash" })).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByRole("button", { name: "Pay and Top Up" }).click();
await expect(
page.getByRole("alertdialog", { name: "Continue to payment?" }),
).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Pay with GCash / QR Ph" }),
).toHaveCount(0);
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toHaveCount(0);
expect(payment.getCreateOrderCount()).toBe(1);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
});
test("Philippines QR-only fallback is labeled GCash / QR Ph and can switch to Stripe", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const payment = await registerPhilippinesPaymentMocks(page, "qr");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await page.getByRole("button", { name: "Pay and Top Up" }).click();
const dialog = page.getByRole("dialog", {
name: "Pay with GCash / QR Ph",
});
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(/₱\s*499\.90/);
await expect(dialog).not.toContainText("QRIS");
await expect(
dialog.getByRole("img", { name: "GCash / QR Ph payment QR code" }),
).toBeVisible();
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(payment.getCreateOrderCount()).toBe(1);
});
@@ -7,15 +7,15 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
const hiddenLaunch = {
handleEzpayCancel: vi.fn(),
handleEzpayConfirm: vi.fn(),
handleQrisClose: vi.fn(),
handleRegionalQrClose: vi.fn(),
handleStripeClose: vi.fn(),
handleStripeConfirmed: vi.fn(),
isConfirmingEzpay: false,
qrisErrorMessage: null,
qrisPayment: null,
qrisStatus: null,
regionalQrErrorMessage: null,
regionalQrPayment: null,
regionalQrStatus: null,
shouldShowEzpayConfirmDialog: false,
shouldShowQrisDialog: false,
shouldShowRegionalQrDialog: false,
shouldShowStripeDialog: false,
stripeClientSecret: null,
};
@@ -96,14 +96,15 @@ describe("PaymentLaunchDialogs", () => {
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
qrisPayment: {
regionalQrPayment: {
qrData: "00020101021226670016COM.NOBUBANK.WWW",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
experience: "qris",
},
qrisStatus: "pending",
shouldShowQrisDialog: true,
regionalQrStatus: "pending",
shouldShowRegionalQrDialog: true,
}}
/>,
),
@@ -122,6 +123,44 @@ describe("PaymentLaunchDialogs", () => {
expect(dialog?.textContent).toContain("Waiting for payment");
});
it("labels a Philippine QR fallback as GCash / QR Ph", () => {
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-ph-qr"
externalCheckoutAnalyticsKey="payment.external_checkout"
ezpayDescription="Pay with GCash."
launch={{
...hiddenLaunch,
regionalQrPayment: {
qrData: "000201010212ph-qr-payload",
orderId: "order-ph-qr",
amountCents: 49_990,
currency: "PHP",
experience: "gcashQrPh",
},
regionalQrStatus: "pending",
shouldShowRegionalQrDialog: true,
}}
/>,
),
);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Pay with GCash / QR Ph");
expect(dialog?.textContent).not.toContain("QRIS");
expect(dialog?.querySelector("svg title")?.textContent).toBe(
"GCash / QR Ph payment QR code",
);
expect(dialog?.textContent).toContain("₱");
const closeButton = Array.from(
dialog?.querySelectorAll("button") ?? [],
).find((button) => button.textContent?.trim() === "Close");
act(() => closeButton?.click());
expect(hiddenLaunch.handleRegionalQrClose).toHaveBeenCalledOnce();
});
it("shows QRIS failure state and handles empty QR data safely", () => {
act(() =>
root.render(
@@ -131,15 +170,16 @@ describe("PaymentLaunchDialogs", () => {
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
qrisErrorMessage: "Payment failed or was cancelled.",
qrisPayment: {
regionalQrErrorMessage: "Payment failed or was cancelled.",
regionalQrPayment: {
qrData: "",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
experience: "qris",
},
qrisStatus: "failed",
shouldShowQrisDialog: true,
regionalQrStatus: "failed",
shouldShowRegionalQrDialog: true,
}}
/>,
),
@@ -13,15 +13,15 @@ type PaymentLaunchDialogFlow = Pick<
PaymentLaunchFlow,
| "handleEzpayCancel"
| "handleEzpayConfirm"
| "handleQrisClose"
| "handleRegionalQrClose"
| "handleStripeClose"
| "handleStripeConfirmed"
| "isConfirmingEzpay"
| "qrisErrorMessage"
| "qrisPayment"
| "qrisStatus"
| "regionalQrErrorMessage"
| "regionalQrPayment"
| "regionalQrStatus"
| "shouldShowEzpayConfirmDialog"
| "shouldShowQrisDialog"
| "shouldShowRegionalQrDialog"
| "shouldShowStripeDialog"
| "stripeClientSecret"
>;
@@ -53,12 +53,12 @@ export function PaymentLaunchDialogs({
onConfirm={launch.handleEzpayConfirm}
/>
) : null}
{launch.shouldShowQrisDialog && launch.qrisPayment ? (
<QrisPaymentDialog
payment={launch.qrisPayment}
status={launch.qrisStatus}
errorMessage={launch.qrisErrorMessage}
onClose={launch.handleQrisClose}
{launch.shouldShowRegionalQrDialog && launch.regionalQrPayment ? (
<RegionalQrPaymentDialog
payment={launch.regionalQrPayment}
status={launch.regionalQrStatus}
errorMessage={launch.regionalQrErrorMessage}
onClose={launch.handleRegionalQrClose}
/>
) : null}
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
@@ -73,43 +73,56 @@ export function PaymentLaunchDialogs({
);
}
interface QrisPaymentDialogProps {
interface RegionalQrPaymentDialogProps {
errorMessage: string | null;
onClose: () => void;
payment: NonNullable<PaymentLaunchFlow["qrisPayment"]>;
status: PaymentLaunchFlow["qrisStatus"];
payment: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>;
status: PaymentLaunchFlow["regionalQrStatus"];
}
function formatQrisAmount(amountCents: number, currency: string): string {
function formatRegionalQrAmount(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);
return new Intl.NumberFormat(
normalizedCurrency === "PHP" ? "en-PH" : "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"],
function regionalQrStatusMessage(
status: PaymentLaunchFlow["regionalQrStatus"],
errorMessage: string | null,
paymentName: string,
): string {
if (status === "failed") return errorMessage || "Payment failed. Please try again.";
if (status === "expired") return errorMessage || "This QRIS order has expired.";
if (status === "failed") {
return errorMessage || "Payment failed. Please try again.";
}
if (status === "expired") {
return errorMessage || `This ${paymentName} order has expired.`;
}
return "Waiting for payment";
}
function QrisPaymentDialog({
function RegionalQrPaymentDialog({
errorMessage,
onClose,
payment,
status,
}: QrisPaymentDialogProps) {
}: RegionalQrPaymentDialogProps) {
const titleId = useId();
const statusMessage = qrisStatusMessage(status, errorMessage);
const copy = regionalQrCopy(payment.experience);
const statusMessage = regionalQrStatusMessage(
status,
errorMessage,
copy.paymentName,
);
return (
<ModalPortal
@@ -123,17 +136,17 @@ function QrisPaymentDialog({
>
<div className={`${styles.header} text-center`}>
<h2 id={titleId} className={styles.title}>
Scan to pay with QRIS
{copy.title}
</h2>
<p className={styles.content}>
Open a QRIS-compatible banking or wallet app and scan this code.
{copy.description}
</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"
title={copy.qrTitle}
size={256}
level="M"
marginSize={2}
@@ -141,14 +154,14 @@ function QrisPaymentDialog({
/>
) : (
<p className={styles.error} role="alert">
QRIS data is unavailable. Please close this dialog and try again.
{copy.unavailableMessage}
</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)}
{formatRegionalQrAmount(payment.amountCents, payment.currency)}
</p>
<p
className={status === "failed" || status === "expired" ? styles.error : styles.content}
@@ -170,6 +183,41 @@ function QrisPaymentDialog({
);
}
function regionalQrCopy(
experience: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>["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;
+78 -55
View File
@@ -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<string | null>(
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,
};
@@ -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}
>
@@ -337,6 +337,7 @@ export function SubscriptionScreen({
subscriptionType={subscriptionType}
returnTo={returnTo}
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
countryCode={countryCode}
/>
</div>
+7 -8
View File
@@ -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}
</button>
+1
View File
@@ -305,6 +305,7 @@ export function TipScreen({
disabled={!canCreateOrder}
onOrder={handleOrder}
returnPath={returnPath}
countryCode={userState.currentUser?.countryCode}
/>
</div>
</>
@@ -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" }),
+105 -16
View File
@@ -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<string, unknown>,
...keys: string[]
@@ -41,6 +52,12 @@ function getHttpPaymentUrl(value: string | null): string | null {
}
}
export function getPaymentUrlHostname(value: string | null): string | null {
const paymentUrl = getHttpPaymentUrl(value);
if (!paymentUrl) return null;
return new URL(paymentUrl).hostname;
}
export function getPaymentUrl(payParams: Record<string, unknown>): string | null {
const keys = [
"cashierUrl",
@@ -71,6 +88,7 @@ export function isEzpayPayment(payParams: Record<string, unknown>): boolean {
export function resolveEzpayLaunchTarget(
payParams: Record<string, unknown>,
context: ResolveEzpayLaunchContext = {},
): EzpayLaunchTarget {
const rawChannelType = getNonEmptyString(
payParams,
@@ -79,13 +97,33 @@ export function resolveEzpayLaunchTarget(
);
const channelType = rawChannelType?.toUpperCase() ?? null;
const payData = getNonEmptyString(payParams, "payData", "pay_data");
const payDataUrl = getHttpPaymentUrl(payData);
const namedPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
const paymentUrl = payDataUrl ?? namedPaymentUrl;
const qrData = payData && !payDataUrl ? payData : null;
const region = resolveEzpayRegion(payParams, context);
if (
region === "ph" &&
paymentUrl &&
(channelType === "QR" || channelType === "URL" || channelType === null)
) {
return { kind: "url", paymentUrl };
}
if (channelType === "QR") {
return payData
? { kind: "qr", qrData: payData }
if (qrData) {
return {
kind: "qr",
experience: resolveEzpayQrExperience(region),
qrData,
};
}
return paymentUrl
? { kind: "url", paymentUrl }
: {
kind: "error",
errorMessage: "QRIS payment data is missing. Please try again.",
errorMessage: regionalQrMissingMessage(region),
};
}
@@ -97,8 +135,6 @@ export function resolveEzpayLaunchTarget(
}
if (channelType === "URL") {
const paymentUrl =
getHttpPaymentUrl(payData) ?? getHttpPaymentUrl(getPaymentUrl(payParams));
return paymentUrl
? { kind: "url", paymentUrl }
: {
@@ -114,14 +150,66 @@ export function resolveEzpayLaunchTarget(
};
}
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.",
};
if (paymentUrl) return { kind: "url", paymentUrl };
if (qrData && region !== null) {
return {
kind: "qr",
experience: resolveEzpayQrExperience(region),
qrData,
};
}
return {
kind: "error",
errorMessage:
"Ezpay payment parameters did not include a supported URL or payment QR code.",
};
}
function resolveEzpayRegion(
payParams: Record<string, unknown>,
context: ResolveEzpayLaunchContext,
): "ph" | "id" | null {
const currency = (
context.currency ?? getNonEmptyString(payParams, "currency")
)
?.trim()
.toUpperCase();
if (currency === "PHP") return "ph";
if (currency === "IDR") return "id";
const countryCode = (
context.countryCode ??
getNonEmptyString(
payParams,
"purchaseCountryCode",
"purchase_country_code",
"countryCode",
"country_code",
)
)
?.trim()
.toUpperCase();
if (countryCode === "PH") return "ph";
if (countryCode === "ID") return "id";
return null;
}
function resolveEzpayQrExperience(
region: "ph" | "id" | null,
): EzpayQrExperience {
if (region === "ph") return "gcashQrPh";
if (region === "id") return "qris";
return "paymentQr";
}
function regionalQrMissingMessage(region: "ph" | "id" | null): string {
if (region === "ph") {
return "GCash / QR Ph payment data is missing. Please try again.";
}
if (region === "id") {
return "QRIS payment data is missing. Please try again.";
}
return "Payment QR data is missing. Please try again.";
}
export function getStripeClientSecret(
@@ -166,18 +254,19 @@ export async function launchEzpayRedirect({
onOpened,
onFailed,
}: LaunchEzpayRedirectInput): Promise<void> {
const paymentUrlHost = getPaymentUrlHostname(paymentUrl);
log.debug("[payment-launch] launchEzpayRedirect START", {
hasOrderId: Boolean(orderId),
orderId,
subscriptionType,
paymentUrl,
paymentUrlHost,
});
if (!orderId) {
const errorMessage = "Missing order id before opening Ezpay.";
log.error("[payment-launch] pending ezpay order save skipped", {
subscriptionType,
paymentUrl,
paymentUrlHost,
errorMessage,
});
onFailed(errorMessage);