diff --git a/e2e/specs/mock/payment/indonesia-qris.spec.ts b/e2e/specs/mock/payment/indonesia-qris.spec.ts index f2571bbc..3c68d215 100644 --- a/e2e/specs/mock/payment/indonesia-qris.spec.ts +++ b/e2e/specs/mock/payment/indonesia-qris.spec.ts @@ -266,14 +266,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", @@ -283,20 +283,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("payment issue submits Other to the feedback API without creating an order", async ({ @@ -350,7 +344,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", @@ -360,19 +354,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(); }); diff --git a/e2e/specs/mock/payment/philippines-gcash.spec.ts b/e2e/specs/mock/payment/philippines-gcash.spec.ts new file mode 100644 index 00000000..db85e5ff --- /dev/null +++ b/e2e/specs/mock/payment/philippines-gcash.spec.ts @@ -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); +}); diff --git a/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx b/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx index db6404be..05dd5549 100644 --- a/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx +++ b/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx @@ -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, stripeCustomerSessionClientSecret: null, @@ -98,14 +98,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, }} />, ), @@ -124,6 +125,44 @@ describe("PaymentLaunchDialogs", () => { expect(dialog?.textContent).toContain("Waiting for payment"); }); + it("labels a Philippine QR fallback as GCash / QR Ph", () => { + act(() => + root.render( + , + ), + ); + + 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( @@ -133,15 +172,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, }} />, ), diff --git a/src/app/_components/payment/payment-launch-dialogs.tsx b/src/app/_components/payment/payment-launch-dialogs.tsx index dbbade8b..beba861f 100644 --- a/src/app/_components/payment/payment-launch-dialogs.tsx +++ b/src/app/_components/payment/payment-launch-dialogs.tsx @@ -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" | "stripeCustomerSessionClientSecret" @@ -55,12 +55,12 @@ export function PaymentLaunchDialogs({ onConfirm={launch.handleEzpayConfirm} /> ) : null} - {launch.shouldShowQrisDialog && launch.qrisPayment ? ( - ) : null} {launch.shouldShowStripeDialog && launch.stripeClientSecret ? ( @@ -79,43 +79,56 @@ export function PaymentLaunchDialogs({ ); } -interface QrisPaymentDialogProps { +interface RegionalQrPaymentDialogProps { errorMessage: string | null; onClose: () => void; - payment: NonNullable; - status: PaymentLaunchFlow["qrisStatus"]; + payment: NonNullable; + 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 (

- Scan to pay with QRIS + {copy.title}

- Open a QRIS-compatible banking or wallet app and scan this code. + {copy.description}

{payment.qrData ? ( ) : (

- QRIS data is unavailable. Please close this dialog and try again. + {copy.unavailableMessage}

)}

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 1d645763..53e3b117 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, getStripeCustomerSessionClientSecret, getStripeClientSecret, isEzpayPayment, @@ -42,33 +43,33 @@ 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; stripeCustomerSessionClientSecret: string | null; savedPaymentMethodsEnabled: boolean; } -export interface QrisPaymentDetails { +export interface RegionalQrPaymentDetails { amountCents: number; currency: string; + experience: "gcashQrPh" | "qris" | "paymentQr"; orderId: string; qrData: string; } @@ -158,33 +159,39 @@ 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 stripeCustomerSessionClientSecret = payment.payParams ? getStripeCustomerSessionClientSecret(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" @@ -194,9 +201,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, } @@ -215,7 +222,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({ @@ -230,11 +263,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; } @@ -242,7 +282,7 @@ export function usePaymentLaunchFlow({ if (!AppEnvUtil.isProduction()) { log.debug(`[${logScope}] ezpay confirmation required`, { orderId: payment.currentOrderId, - paymentUrl, + paymentUrlHost: getPaymentUrlHostname(paymentUrl), subscriptionType, }); return; @@ -268,7 +308,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"); @@ -289,6 +329,7 @@ export function usePaymentLaunchFlow({ log, logScope, characterSlug, + countryCode, payment.currentOrderId, payment, payment.launchNonce, @@ -297,6 +338,7 @@ export function usePaymentLaunchFlow({ payment.plans, payment.selectedPlanId, paymentDispatch, + paymentCurrency, returnTo, subscriptionType, giftCategory, @@ -313,22 +355,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 { @@ -375,39 +408,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, stripeCustomerSessionClientSecret, diff --git a/src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx b/src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx index 1b6229ba..861ba482 100644 --- a/src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx +++ b/src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx @@ -4,8 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ dispatch: vi.fn(), - handleQrisResume: vi.fn(), - hasHiddenQrisPayment: false, resetLaunchState: vi.fn(), payment: { selectedPlanId: "vip_monthly", @@ -27,8 +25,6 @@ vi.mock("@/stores/payment/payment-context", () => ({ vi.mock("@/app/_hooks/use-payment-launch-flow", () => ({ usePaymentLaunchFlow: () => ({ - handleQrisResume: mocks.handleQrisResume, - hasHiddenQrisPayment: mocks.hasHiddenQrisPayment, resetLaunchState: mocks.resetLaunchState, launch: {}, }), @@ -95,8 +91,6 @@ describe("SubscriptionCheckoutButton renewal confirmation", () => { .IS_REACT_ACT_ENVIRONMENT = true; localStorage.clear(); mocks.dispatch.mockClear(); - mocks.handleQrisResume.mockClear(); - mocks.hasHiddenQrisPayment = false; mocks.resetLaunchState.mockClear(); mocks.payment.selectedPlanId = "vip_monthly"; mocks.payment.autoRenew = true; @@ -189,10 +183,9 @@ describe("SubscriptionCheckoutButton renewal confirmation", () => { }); }); - it("reopens a hidden QRIS order without creating another order", () => { + it("keeps checkout disabled while a payment order is still being polled", () => { mocks.payment.payChannel = "ezpay"; mocks.payment.isPollingOrder = true; - mocks.hasHiddenQrisPayment = true; act(() => root.render( @@ -204,11 +197,8 @@ describe("SubscriptionCheckoutButton renewal confirmation", () => { ), ); - const resumeButton = getButton(container, "Resume QRIS payment"); - expect(resumeButton.disabled).toBe(false); - act(() => resumeButton.click()); - - expect(mocks.handleQrisResume).toHaveBeenCalledOnce(); + const checkoutButton = getButton(container, "Processing payment..."); + expect(checkoutButton.disabled).toBe(true); expect(mocks.resetLaunchState).not.toHaveBeenCalled(); expect(mocks.dispatch).not.toHaveBeenCalled(); }); diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index 8b98d794..920614aa 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -35,6 +35,7 @@ export interface SubscriptionCheckoutButtonProps { sourceCharacterSlug?: string; renewalPlan?: VipOfferPlanView | null; renewalConsentSubjectId?: string | null; + countryCode?: string | null; } export function SubscriptionCheckoutButton({ @@ -44,6 +45,7 @@ export function SubscriptionCheckoutButton({ sourceCharacterSlug = DEFAULT_CHARACTER_SLUG, renewalPlan = null, renewalConsentSubjectId = null, + countryCode = null, }: SubscriptionCheckoutButtonProps) { const [showRenewalConfirmation, setShowRenewalConfirmation] = useState(false); @@ -57,15 +59,12 @@ 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 + const label = payment.isPollingOrder ? "Processing payment..." : payment.isCreatingOrder ? "Creating order..." @@ -78,10 +77,6 @@ export function SubscriptionCheckoutButton({ }; const handleClick = () => { - if (isResumingQris) { - paymentLaunch.handleQrisResume(); - return; - } if (disabled || isLoading) return; if ( payment.payChannel === "stripe" && @@ -123,7 +118,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 06723252..32941ffa 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -306,6 +306,7 @@ export function SubscriptionScreen({ sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG} renewalPlan={selectedVipPlan} renewalConsentSubjectId={userState.currentUser?.id || null} + countryCode={countryCode} />

diff --git a/src/app/tip/tip-checkout-button.tsx b/src/app/tip/tip-checkout-button.tsx index 40dfdf82..910b84a4 100644 --- a/src/app/tip/tip-checkout-button.tsx +++ b/src/app/tip/tip-checkout-button.tsx @@ -20,6 +20,7 @@ export interface TipCheckoutButtonProps { disabled?: boolean; onOrder: () => void; returnPath: string; + countryCode?: string | null; } export function TipCheckoutButton({ @@ -28,6 +29,7 @@ export function TipCheckoutButton({ disabled = false, onOrder, returnPath, + countryCode = null, }: TipCheckoutButtonProps) { const character = useActiveCharacter(); const payment = usePaymentState(); @@ -41,14 +43,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..." @@ -63,8 +62,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 77f28b98..bd27f2af 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 e7f9b148..e7e6984f 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, getStripeCustomerSessionClientSecret, getStripeClientSecret, isEzpayPayment, @@ -66,36 +67,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 1563a5a1..554cc787 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, ...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 | null { const keys = [ "cashierUrl", @@ -71,6 +88,7 @@ export function isEzpayPayment(payParams: Record): boolean { export function resolveEzpayLaunchTarget( payParams: Record, + 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, + 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( @@ -187,18 +275,19 @@ export async function launchEzpayRedirect({ onOpened, onFailed, }: LaunchEzpayRedirectInput): Promise { + 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);