diff --git a/e2e/specs/mock/payment/indonesia-qris.spec.ts b/e2e/specs/mock/payment/indonesia-qris.spec.ts new file mode 100644 index 00000000..2b03a8f4 --- /dev/null +++ b/e2e/specs/mock/payment/indonesia-qris.spec.ts @@ -0,0 +1,253 @@ +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 idrPlans = { + plans: [ + { + planId: "vip_monthly", + planName: "Monthly", + orderType: "vip_monthly", + amountCents: 19_590_000, + originalAmountCents: 19_590_000, + dailyPriceCents: 653_000, + currency: "IDR", + vipDays: 30, + dolAmount: null, + creditBalance: 3_000, + }, + { + planId: "dol_1000", + planName: "1,000 Credits", + orderType: "dol", + amountCents: 8_890_000, + originalAmountCents: null, + dailyPriceCents: null, + currency: "IDR", + vipDays: null, + dolAmount: 1_000, + creditBalance: 1_000, + }, + ], +}; + +const idrGiftCatalog = { + characterId: "elio", + categories: [ + { + category: "coffee", + name: "Coffee", + productCount: 1, + imageUrl: null, + }, + ], + plans: [ + { + planId: "tip_coffee_usd_4_99", + planName: "Velvet Espresso", + orderType: "tip", + tipType: "coffee_small", + category: "coffee", + characterId: "elio", + description: "Buy Elio a Velvet Espresso", + imageUrl: null, + amountCents: 8_929_900, + currency: "IDR", + autoRenew: false, + isFirstRechargeOffer: false, + firstRechargeDiscountPercent: 0, + promotionType: null, + }, + ], +}; + +async function registerIndonesiaPaymentMocks(page: Page) { + const orderStatuses = new Map(); + const orderPlans = new Map(); + + await page.route("**/api/user/profile", async (route) => { + await route.fulfill({ + json: apiEnvelope({ ...e2eEmailUser, countryCode: "ID" }), + }); + }); + await page.route("**/api/payment/plans**", async (route) => { + await route.fulfill({ json: apiEnvelope(idrPlans) }); + }); + await page.route("**/api/payment/gift-products**", async (route) => { + await route.fulfill({ json: apiEnvelope(idrGiftCatalog) }); + }); + await page.route("**/api/payment/create-order", async (route) => { + const body = route.request().postDataJSON() as { planId: string }; + const plan = + idrPlans.plans.find((item) => item.planId === body.planId) ?? + idrGiftCatalog.plans.find((item) => item.planId === body.planId); + const orderId = `order_qris_${body.planId}`; + orderStatuses.set(orderId, "pending"); + orderPlans.set(orderId, { + planId: body.planId, + orderType: plan?.orderType ?? "dol", + }); + await route.fulfill({ + json: apiEnvelope({ + orderId, + payParams: { + provider: "ezpay", + countryCode: "ID", + channelCode: "ID_QRIS_DYNAMIC_QR", + channelType: "QR", + payData: "00020101021226670016COM.NOBUBANK.WWW", + firstChargeAmountCents: plan?.amountCents ?? 0, + currency: "IDR", + }, + }), + }); + }); + await page.route("**/api/payment/order-status**", async (route) => { + const url = new URL(route.request().url()); + const orderId = url.searchParams.get("order_id") ?? ""; + const plan = orderPlans.get(orderId); + await route.fulfill({ + json: apiEnvelope({ + orderId, + status: orderStatuses.get(orderId) ?? "pending", + orderType: plan?.orderType ?? "dol", + planId: plan?.planId ?? null, + creditsAdded: 0, + }), + }); + }); + await page.route("**/api/payment/tip-message", async (route) => { + const body = route.request().postDataJSON() as { orderId: string }; + await route.fulfill({ + json: apiEnvelope({ + orderId: body.orderId, + characterId: "elio", + planId: "tip_coffee_usd_4_99", + productName: "Velvet Espresso", + tipCount: 1, + poolIndex: 0, + message: "Thank you. Your gift made me smile.", + }), + }); + }); + + return { + markPaid(orderId: string) { + orderStatuses.set(orderId, "paid"); + }, + }; +} + +async function prepareIndonesiaUser(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: "ID" }), + ); + }); +} + +async function expectQrisOrder( + page: Page, + buttonName: RegExp, + planId: string, + formattedAmount: RegExp, +) { + const requestPromise = page.waitForRequest("**/api/payment/create-order"); + await page.getByRole("button", { name: buttonName }).click(); + const request = await requestPromise; + expect(request.postDataJSON()).toMatchObject({ + planId, + payChannel: "ezpay", + }); + + const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" }); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText(formattedAmount); + await expect(dialog).toContainText("Waiting for payment"); + await expect( + dialog.getByRole("img", { name: "QRIS payment QR code" }), + ).toBeVisible(); + return `order_qris_${planId}`; +} + +test.beforeEach(async ({ baseURL, context, page }) => { + await clearBrowserState(context, page, baseURL); + await mockCoreApis(page); +}); + +test("Indonesia VIP defaults to QRIS and completes after status polling", async ({ + page, +}) => { + const payment = await registerIndonesiaPaymentMocks(page); + await prepareIndonesiaUser(page); + await page.goto("/subscription?type=vip"); + + await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute( + "aria-pressed", + "true", + ); + const orderId = await expectQrisOrder( + page, + /Pay and Activ/i, + "vip_monthly", + /Rp\s*195[.,]900/, + ); + payment.markPaid(orderId); + + const success = page.getByRole("alertdialog", { name: "Payment successful" }); + await expect(success).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByRole("dialog", { name: "Scan to pay with QRIS" }), + ).toBeHidden(); +}); + +test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => { + const payment = await registerIndonesiaPaymentMocks(page); + await prepareIndonesiaUser(page); + await page.goto("/subscription?type=topup"); + + const orderId = await expectQrisOrder( + page, + /Pay and Top Up/i, + "dol_1000", + /Rp\s*88[.,]900/, + ); + payment.markPaid(orderId); + + await expect( + page.getByRole("alertdialog", { name: "Payment successful" }), + ).toBeVisible({ timeout: 10_000 }); +}); + +test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow", async ({ + page, +}) => { + const payment = await registerIndonesiaPaymentMocks(page); + await prepareIndonesiaUser(page); + await page.goto("/characters/elio/tip"); + + await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible(); + const orderId = await expectQrisOrder( + page, + /Order and Buy/i, + "tip_coffee_usd_4_99", + /Rp\s*89[.,]299/, + ); + 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/package.json b/package.json index 41e30358..a759bb15 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "next-auth": "^4.24.14", "ofetch": "^1.5.1", "pino": "^10.3.1", + "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", "react-icons": "^5.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcc6b8da..615cf8ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: pino: specifier: ^10.3.1 version: 10.3.1 + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -3290,6 +3293,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -7085,6 +7093,10 @@ snapshots: punycode@2.3.1: {} + qrcode.react@4.2.0(react@19.2.4): + dependencies: + react: 19.2.4 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} 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 55be9ac1..5ab418d0 100644 --- a/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx +++ b/src/app/_components/payment/__tests__/payment-launch-dialogs.test.tsx @@ -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( + , + ); + + 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( + , + ); + + expect(html).toContain("QRIS data is unavailable"); + expect(html).toContain("Payment failed or was cancelled."); + expect(html).toContain("Close"); + }); }); diff --git a/src/app/_components/payment/__tests__/payment-method-selector.test.tsx b/src/app/_components/payment/__tests__/payment-method-selector.test.tsx index 6f0e6ed0..21cba41e 100644 --- a/src/app/_components/payment/__tests__/payment-method-selector.test.tsx +++ b/src/app/_components/payment/__tests__/payment-method-selector.test.tsx @@ -62,6 +62,27 @@ describe("PaymentMethodSelector", () => { ); }); + it("labels Indonesia Ezpay as QRIS without the GCash logo", () => { + const html = renderToStaticMarkup( + 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( ; @@ -46,6 +52,14 @@ export function PaymentLaunchDialogs({ onConfirm={launch.handleEzpayConfirm} /> ) : null} + {launch.shouldShowQrisDialog && launch.qrisPayment ? ( + + ) : null} {launch.shouldShowStripeDialog && launch.stripeClientSecret ? ( void; + payment: NonNullable; + 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 ( +
+
+
+

+ Scan to pay with QRIS +

+

+ Open a QRIS-compatible banking or wallet app and scan this code. +

+
+
+ {payment.qrData ? ( + + ) : ( +

+ QRIS data is unavailable. Please close this dialog and try again. +

+ )} +
+
+

Order No. {payment.orderId}

+

+ {formatQrisAmount(payment.amountCents, payment.currency)} +

+

+ {statusMessage} +

+
+
+ +
+
+
+ ); +} + interface EzpayRedirectConfirmDialogProps { description: ReactNode; externalCheckoutAnalyticsKey: string; @@ -87,7 +197,7 @@ function EzpayRedirectConfirmDialog({

- Continue to GCash? + Continue to payment?

{description}

Order No. {orderId}

diff --git a/src/app/_components/payment/payment-method-selector.tsx b/src/app/_components/payment/payment-method-selector.tsx index 2e709a37..bf7f7077 100644 --- a/src/app/_components/payment/payment-method-selector.tsx +++ b/src/app/_components/payment/payment-method-selector.tsx @@ -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 (
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, + ...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, +): 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( + 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, }; diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index 12c595b6..83b52f93 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -95,7 +95,7 @@ export function SubscriptionCheckoutButton({ diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 960c12d3..8b339eb5 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -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({
diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx index 99eeb078..478e1575 100644 --- a/src/app/tip/tip-screen.tsx +++ b/src/app/tip/tip-screen.tsx @@ -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({ { @@ -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(); diff --git a/src/lib/payment/__tests__/payment_method.test.ts b/src/lib/payment/__tests__/payment_method.test.ts index f507a8ef..c6f78052 100644 --- a/src/lib/payment/__tests__/payment_method.test.ts +++ b/src/lib/payment/__tests__/payment_method.test.ts @@ -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({ diff --git a/src/lib/payment/default_pay_channel.ts b/src/lib/payment/default_pay_channel.ts index b332a40a..288dd556 100644 --- a/src/lib/payment/default_pay_channel.ts +++ b/src/lib/payment/default_pay_channel.ts @@ -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"; } diff --git a/src/lib/payment/payment_launch.ts b/src/lib/payment/payment_launch.ts index 4e9cd13d..71b93286 100644 --- a/src/lib/payment/payment_launch.ts +++ b/src/lib/payment/payment_launch.ts @@ -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, + ...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 | null { const keys = [ "cashierUrl", @@ -39,6 +69,61 @@ export function isEzpayPayment(payParams: Record): boolean { return typeof provider === "string" && provider.toLowerCase() === "ezpay"; } +export function resolveEzpayLaunchTarget( + payParams: Record, +): 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 | null { diff --git a/src/lib/payment/payment_method.ts b/src/lib/payment/payment_method.ts index 3dba5018..5537f109 100644 --- a/src/lib/payment/payment_method.ts +++ b/src/lib/payment/payment_method.ts @@ -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"; } diff --git a/src/stores/payment/__tests__/payment-order-flow.test.ts b/src/stores/payment/__tests__/payment-order-flow.test.ts index e8bd325f..c7256c87 100644 --- a/src/stores/payment/__tests__/payment-order-flow.test.ts +++ b/src/stores/payment/__tests__/payment-order-flow.test.ts @@ -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.", }); diff --git a/src/stores/payment/machine/order-flow.ts b/src/stores/payment/machine/order-flow.ts index 98f5931e..475e9417 100644 --- a/src/stores/payment/machine/order-flow.ts +++ b/src/stores/payment/machine/order-flow.ts @@ -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.", }));