fix(payment): route Philippine GCash separately from QRIS
Docker Image / Build and Push Docker Image (push) Successful in 2m7s

This commit is contained in:
Codex
2026-07-29 19:13:14 +08:00
parent 089259e5e0
commit aeebd7f704
12 changed files with 582 additions and 189 deletions
+16 -30
View File
@@ -266,14 +266,14 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
).toBeVisible({ timeout: 10_000 }); ).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, page,
}) => { }) => {
const payment = await registerIndonesiaPaymentMocks(page); const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page); await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup&character=elio"); await page.goto("/subscription?type=topup&character=elio");
const orderId = await expectQrisOrder( await expectQrisOrder(
page, page,
/Pay and Top Up/i, /Pay and Top Up/i,
"dol_1000", "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 dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden(); await expect(dialog).toBeHidden();
const resumeButton = page.getByRole("button", { await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
name: "Resume QRIS payment", await page.getByRole("button", { name: "Stripe" }).click();
}); await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
await expect(resumeButton).toBeEnabled(); "aria-pressed",
"true",
);
await expect(page.getByRole("button", { name: "Pay and Top Up" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1); 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 ({ 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 page.goto("/characters/elio/tip");
await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible(); await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible();
const orderId = await expectQrisOrder( await expectQrisOrder(
page, page,
/Order and Buy/i, /Order and Buy/i,
"tip_coffee_usd_4_99", "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 dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden(); await expect(dialog).toBeHidden();
const resumeButton = page.getByRole("button", { await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
name: "Resume QRIS payment", await page.getByRole("button", { name: "Stripe" }).click();
}); await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
await expect(resumeButton).toBeEnabled(); "aria-pressed",
await resumeButton.click(); "true",
await expect(dialog).toBeVisible(); );
expect(payment.getCreateOrderCount()).toBe(1); 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 = { const hiddenLaunch = {
handleEzpayCancel: vi.fn(), handleEzpayCancel: vi.fn(),
handleEzpayConfirm: vi.fn(), handleEzpayConfirm: vi.fn(),
handleQrisClose: vi.fn(), handleRegionalQrClose: vi.fn(),
handleStripeClose: vi.fn(), handleStripeClose: vi.fn(),
handleStripeConfirmed: vi.fn(), handleStripeConfirmed: vi.fn(),
isConfirmingEzpay: false, isConfirmingEzpay: false,
qrisErrorMessage: null, regionalQrErrorMessage: null,
qrisPayment: null, regionalQrPayment: null,
qrisStatus: null, regionalQrStatus: null,
shouldShowEzpayConfirmDialog: false, shouldShowEzpayConfirmDialog: false,
shouldShowQrisDialog: false, shouldShowRegionalQrDialog: false,
shouldShowStripeDialog: false, shouldShowStripeDialog: false,
stripeClientSecret: null, stripeClientSecret: null,
stripeCustomerSessionClientSecret: null, stripeCustomerSessionClientSecret: null,
@@ -98,14 +98,15 @@ describe("PaymentLaunchDialogs", () => {
ezpayDescription="Scan QRIS to finish the payment." ezpayDescription="Scan QRIS to finish the payment."
launch={{ launch={{
...hiddenLaunch, ...hiddenLaunch,
qrisPayment: { regionalQrPayment: {
qrData: "00020101021226670016COM.NOBUBANK.WWW", qrData: "00020101021226670016COM.NOBUBANK.WWW",
orderId: "order-id-qris", orderId: "order-id-qris",
amountCents: 5_000_000, amountCents: 5_000_000,
currency: "IDR", currency: "IDR",
experience: "qris",
}, },
qrisStatus: "pending", regionalQrStatus: "pending",
shouldShowQrisDialog: true, shouldShowRegionalQrDialog: true,
}} }}
/>, />,
), ),
@@ -124,6 +125,44 @@ describe("PaymentLaunchDialogs", () => {
expect(dialog?.textContent).toContain("Waiting for payment"); 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", () => { it("shows QRIS failure state and handles empty QR data safely", () => {
act(() => act(() =>
root.render( root.render(
@@ -133,15 +172,16 @@ describe("PaymentLaunchDialogs", () => {
ezpayDescription="Scan QRIS to finish the payment." ezpayDescription="Scan QRIS to finish the payment."
launch={{ launch={{
...hiddenLaunch, ...hiddenLaunch,
qrisErrorMessage: "Payment failed or was cancelled.", regionalQrErrorMessage: "Payment failed or was cancelled.",
qrisPayment: { regionalQrPayment: {
qrData: "", qrData: "",
orderId: "order-id-qris", orderId: "order-id-qris",
amountCents: 5_000_000, amountCents: 5_000_000,
currency: "IDR", currency: "IDR",
experience: "qris",
}, },
qrisStatus: "failed", regionalQrStatus: "failed",
shouldShowQrisDialog: true, shouldShowRegionalQrDialog: true,
}} }}
/>, />,
), ),
@@ -13,15 +13,15 @@ type PaymentLaunchDialogFlow = Pick<
PaymentLaunchFlow, PaymentLaunchFlow,
| "handleEzpayCancel" | "handleEzpayCancel"
| "handleEzpayConfirm" | "handleEzpayConfirm"
| "handleQrisClose" | "handleRegionalQrClose"
| "handleStripeClose" | "handleStripeClose"
| "handleStripeConfirmed" | "handleStripeConfirmed"
| "isConfirmingEzpay" | "isConfirmingEzpay"
| "qrisErrorMessage" | "regionalQrErrorMessage"
| "qrisPayment" | "regionalQrPayment"
| "qrisStatus" | "regionalQrStatus"
| "shouldShowEzpayConfirmDialog" | "shouldShowEzpayConfirmDialog"
| "shouldShowQrisDialog" | "shouldShowRegionalQrDialog"
| "shouldShowStripeDialog" | "shouldShowStripeDialog"
| "stripeClientSecret" | "stripeClientSecret"
| "stripeCustomerSessionClientSecret" | "stripeCustomerSessionClientSecret"
@@ -55,12 +55,12 @@ export function PaymentLaunchDialogs({
onConfirm={launch.handleEzpayConfirm} onConfirm={launch.handleEzpayConfirm}
/> />
) : null} ) : null}
{launch.shouldShowQrisDialog && launch.qrisPayment ? ( {launch.shouldShowRegionalQrDialog && launch.regionalQrPayment ? (
<QrisPaymentDialog <RegionalQrPaymentDialog
payment={launch.qrisPayment} payment={launch.regionalQrPayment}
status={launch.qrisStatus} status={launch.regionalQrStatus}
errorMessage={launch.qrisErrorMessage} errorMessage={launch.regionalQrErrorMessage}
onClose={launch.handleQrisClose} onClose={launch.handleRegionalQrClose}
/> />
) : null} ) : null}
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? ( {launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
@@ -79,43 +79,56 @@ export function PaymentLaunchDialogs({
); );
} }
interface QrisPaymentDialogProps { interface RegionalQrPaymentDialogProps {
errorMessage: string | null; errorMessage: string | null;
onClose: () => void; onClose: () => void;
payment: NonNullable<PaymentLaunchFlow["qrisPayment"]>; payment: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>;
status: PaymentLaunchFlow["qrisStatus"]; status: PaymentLaunchFlow["regionalQrStatus"];
} }
function formatQrisAmount(amountCents: number, currency: string): string { function formatRegionalQrAmount(amountCents: number, currency: string): string {
const normalizedCurrency = currency.trim().toUpperCase() || "IDR"; const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
try { try {
return new Intl.NumberFormat("id-ID", { return new Intl.NumberFormat(
style: "currency", normalizedCurrency === "PHP" ? "en-PH" : "id-ID",
currency: normalizedCurrency, {
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2, style: "currency",
}).format(amountCents / 100); currency: normalizedCurrency,
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
},
).format(amountCents / 100);
} catch { } catch {
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`; return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
} }
} }
function qrisStatusMessage( function regionalQrStatusMessage(
status: PaymentLaunchFlow["qrisStatus"], status: PaymentLaunchFlow["regionalQrStatus"],
errorMessage: string | null, errorMessage: string | null,
paymentName: string,
): string { ): string {
if (status === "failed") return errorMessage || "Payment failed. Please try again."; if (status === "failed") {
if (status === "expired") return errorMessage || "This QRIS order has expired."; return errorMessage || "Payment failed. Please try again.";
}
if (status === "expired") {
return errorMessage || `This ${paymentName} order has expired.`;
}
return "Waiting for payment"; return "Waiting for payment";
} }
function QrisPaymentDialog({ function RegionalQrPaymentDialog({
errorMessage, errorMessage,
onClose, onClose,
payment, payment,
status, status,
}: QrisPaymentDialogProps) { }: RegionalQrPaymentDialogProps) {
const titleId = useId(); const titleId = useId();
const statusMessage = qrisStatusMessage(status, errorMessage); const copy = regionalQrCopy(payment.experience);
const statusMessage = regionalQrStatusMessage(
status,
errorMessage,
copy.paymentName,
);
return ( return (
<ModalPortal <ModalPortal
@@ -129,17 +142,17 @@ function QrisPaymentDialog({
> >
<div className={`${styles.header} text-center`}> <div className={`${styles.header} text-center`}>
<h2 id={titleId} className={styles.title}> <h2 id={titleId} className={styles.title}>
Scan to pay with QRIS {copy.title}
</h2> </h2>
<p className={styles.content}> <p className={styles.content}>
Open a QRIS-compatible banking or wallet app and scan this code. {copy.description}
</p> </p>
</div> </div>
<div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4"> <div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4">
{payment.qrData ? ( {payment.qrData ? (
<QRCodeSVG <QRCodeSVG
value={payment.qrData} value={payment.qrData}
title="QRIS payment QR code" title={copy.qrTitle}
size={256} size={256}
level="M" level="M"
marginSize={2} marginSize={2}
@@ -147,14 +160,14 @@ function QrisPaymentDialog({
/> />
) : ( ) : (
<p className={styles.error} role="alert"> <p className={styles.error} role="alert">
QRIS data is unavailable. Please close this dialog and try again. {copy.unavailableMessage}
</p> </p>
)} )}
</div> </div>
<div className="mb-4 flex flex-col gap-2 text-center"> <div className="mb-4 flex flex-col gap-2 text-center">
<p className={styles.content}>Order No. {payment.orderId}</p> <p className={styles.content}>Order No. {payment.orderId}</p>
<p className="m-0 text-xl font-bold text-text-foreground"> <p className="m-0 text-xl font-bold text-text-foreground">
{formatQrisAmount(payment.amountCents, payment.currency)} {formatRegionalQrAmount(payment.amountCents, payment.currency)}
</p> </p>
<p <p
className={status === "failed" || status === "expired" ? styles.error : styles.content} className={status === "failed" || status === "expired" ? styles.error : styles.content}
@@ -176,6 +189,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 { interface EzpayRedirectConfirmDialogProps {
description: ReactNode; description: ReactNode;
externalCheckoutAnalyticsKey: string; externalCheckoutAnalyticsKey: string;
+78 -55
View File
@@ -4,6 +4,7 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
import { import {
getPaymentUrl, getPaymentUrl,
getPaymentUrlHostname,
getStripeCustomerSessionClientSecret, getStripeCustomerSessionClientSecret,
getStripeClientSecret, getStripeClientSecret,
isEzpayPayment, isEzpayPayment,
@@ -42,33 +43,33 @@ export interface UsePaymentLaunchFlowInput {
subscriptionType: PendingPaymentSubscriptionType; subscriptionType: PendingPaymentSubscriptionType;
giftCategory?: string | null; giftCategory?: string | null;
giftPlanId?: string | null; giftPlanId?: string | null;
countryCode?: string | null;
} }
export interface PaymentLaunchFlow { export interface PaymentLaunchFlow {
ezpayPaymentUrl: string | null; ezpayPaymentUrl: string | null;
handleEzpayCancel: () => void; handleEzpayCancel: () => void;
handleEzpayConfirm: () => void; handleEzpayConfirm: () => void;
handleQrisClose: () => void; handleRegionalQrClose: () => void;
handleQrisResume: () => void;
handleStripeClose: () => void; handleStripeClose: () => void;
handleStripeConfirmed: () => void; handleStripeConfirmed: () => void;
hasHiddenQrisPayment: boolean;
isConfirmingEzpay: boolean; isConfirmingEzpay: boolean;
qrisErrorMessage: string | null; regionalQrErrorMessage: string | null;
qrisPayment: QrisPaymentDetails | null; regionalQrPayment: RegionalQrPaymentDetails | null;
qrisStatus: PaymentContextState["orderStatus"]; regionalQrStatus: PaymentContextState["orderStatus"];
resetLaunchState: () => void; resetLaunchState: () => void;
shouldShowEzpayConfirmDialog: boolean; shouldShowEzpayConfirmDialog: boolean;
shouldShowQrisDialog: boolean; shouldShowRegionalQrDialog: boolean;
shouldShowStripeDialog: boolean; shouldShowStripeDialog: boolean;
stripeClientSecret: string | null; stripeClientSecret: string | null;
stripeCustomerSessionClientSecret: string | null; stripeCustomerSessionClientSecret: string | null;
savedPaymentMethodsEnabled: boolean; savedPaymentMethodsEnabled: boolean;
} }
export interface QrisPaymentDetails { export interface RegionalQrPaymentDetails {
amountCents: number; amountCents: number;
currency: string; currency: string;
experience: "gcashQrPh" | "qris" | "paymentQr";
orderId: string; orderId: string;
qrData: string; qrData: string;
} }
@@ -158,33 +159,39 @@ export function usePaymentLaunchFlow({
subscriptionType, subscriptionType,
giftCategory, giftCategory,
giftPlanId, giftPlanId,
countryCode,
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow { }: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
const launchedNonceRef = useRef(0); const launchedNonceRef = useRef(0);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
string | null string | null
>(null); >(null);
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false); const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
const [hiddenQrisOrderId, setHiddenQrisOrderId] = useState<string | null>(
null,
);
const stripeClientSecret = payment.payParams const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams) ? getStripeClientSecret(payment.payParams)
: null; : null;
const stripeCustomerSessionClientSecret = payment.payParams const stripeCustomerSessionClientSecret = payment.payParams
? getStripeCustomerSessionClientSecret(payment.payParams) ? getStripeCustomerSessionClientSecret(payment.payParams)
: null; : 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 = const ezpayLaunchTarget =
payment.payParams && isEzpayPayment(payment.payParams) payment.payParams && isEzpayPayment(payment.payParams)
? resolveEzpayLaunchTarget(payment.payParams) ? resolveEzpayLaunchTarget(payment.payParams, {
countryCode,
currency: paymentCurrency,
})
: null; : null;
const ezpayPaymentUrl = const ezpayPaymentUrl =
ezpayLaunchTarget?.kind === "url" ezpayLaunchTarget?.kind === "url"
? ezpayLaunchTarget.paymentUrl ? ezpayLaunchTarget.paymentUrl
: null; : null;
const selectedPlan = payment.plans.find( const regionalQrPayment: RegionalQrPaymentDetails | null =
(item) => item.planId === payment.selectedPlanId,
);
const qrisPayment: QrisPaymentDetails | null =
payment.payParams && payment.payParams &&
payment.currentOrderId && payment.currentOrderId &&
ezpayLaunchTarget?.kind === "qr" ezpayLaunchTarget?.kind === "qr"
@@ -194,9 +201,9 @@ export function usePaymentLaunchFlow({
selectedPlan?.amountCents ?? selectedPlan?.amountCents ??
0, 0,
currency: currency:
paymentParamString(payment.payParams, "currency") ?? paymentCurrency ??
selectedPlan?.currency ?? (ezpayLaunchTarget.experience === "gcashQrPh" ? "PHP" : "IDR"),
"IDR", experience: ezpayLaunchTarget.experience,
orderId: payment.currentOrderId, orderId: payment.currentOrderId,
qrData: ezpayLaunchTarget.qrData, qrData: ezpayLaunchTarget.qrData,
} }
@@ -215,7 +222,33 @@ export function usePaymentLaunchFlow({
const isEzpay = isEzpayPayment(payment.payParams); const isEzpay = isEzpayPayment(payment.payParams);
if (isEzpay) { 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") { if (target.kind === "error") {
trackPaymentCheckoutFailed(payment, "missing_checkout_url"); trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({ paymentDispatch({
@@ -230,11 +263,18 @@ export function usePaymentLaunchFlow({
trackPaymentCheckoutFailed(payment, "missing_checkout_url"); trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({ paymentDispatch({
type: "PaymentLaunchFailed", type: "PaymentLaunchFailed",
errorMessage: "Missing order id before showing QRIS.", errorMessage: "Missing order id before showing payment QR code.",
}); });
return; return;
} }
trackPaymentCheckoutOpened(payment, "qris_embedded"); trackPaymentCheckoutOpened(
payment,
target.experience === "qris"
? "qris_embedded"
: target.experience === "gcashQrPh"
? "gcash_qrph_embedded"
: "payment_qr_embedded",
);
return; return;
} }
@@ -242,7 +282,7 @@ export function usePaymentLaunchFlow({
if (!AppEnvUtil.isProduction()) { if (!AppEnvUtil.isProduction()) {
log.debug(`[${logScope}] ezpay confirmation required`, { log.debug(`[${logScope}] ezpay confirmation required`, {
orderId: payment.currentOrderId, orderId: payment.currentOrderId,
paymentUrl, paymentUrlHost: getPaymentUrlHostname(paymentUrl),
subscriptionType, subscriptionType,
}); });
return; return;
@@ -268,7 +308,7 @@ export function usePaymentLaunchFlow({
const paymentUrl = getPaymentUrl(payment.payParams); const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) { if (paymentUrl) {
try { try {
window.location.href = paymentUrl; window.location.assign(paymentUrl);
trackPaymentCheckoutOpened(payment, paymentUrl); trackPaymentCheckoutOpened(payment, paymentUrl);
} catch { } catch {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed"); trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
@@ -289,6 +329,7 @@ export function usePaymentLaunchFlow({
log, log,
logScope, logScope,
characterSlug, characterSlug,
countryCode,
payment.currentOrderId, payment.currentOrderId,
payment, payment,
payment.launchNonce, payment.launchNonce,
@@ -297,6 +338,7 @@ export function usePaymentLaunchFlow({
payment.plans, payment.plans,
payment.selectedPlanId, payment.selectedPlanId,
paymentDispatch, paymentDispatch,
paymentCurrency,
returnTo, returnTo,
subscriptionType, subscriptionType,
giftCategory, giftCategory,
@@ -313,22 +355,13 @@ export function usePaymentLaunchFlow({
payParams: payment.payParams, payParams: payment.payParams,
paymentUrl: ezpayPaymentUrl, paymentUrl: ezpayPaymentUrl,
}); });
const shouldShowQrisDialog = Boolean( const shouldShowRegionalQrDialog = Boolean(
qrisPayment && regionalQrPayment && !payment.isPaid,
qrisPayment.orderId !== hiddenQrisOrderId &&
!payment.isPaid,
);
const hasHiddenQrisPayment = Boolean(
qrisPayment &&
qrisPayment.orderId === hiddenQrisOrderId &&
payment.isPollingOrder &&
!payment.isPaid,
); );
function resetLaunchState(): void { function resetLaunchState(): void {
setIsConfirmingEzpay(false); setIsConfirmingEzpay(false);
setHiddenStripeClientSecret(null); setHiddenStripeClientSecret(null);
setHiddenQrisOrderId(null);
} }
function handleStripeClose(): void { function handleStripeClose(): void {
@@ -375,39 +408,29 @@ export function usePaymentLaunchFlow({
paymentDispatch({ type: "PaymentReset" }); paymentDispatch({ type: "PaymentReset" });
} }
function handleQrisClose(): void { function handleRegionalQrClose(): void {
log.debug(`[${logScope}] qris dialog closed`, { log.debug(`[${logScope}] regional payment QR dialog closed`, {
orderId: qrisPayment?.orderId ?? payment.currentOrderId, orderId: regionalQrPayment?.orderId ?? payment.currentOrderId,
experience: regionalQrPayment?.experience ?? null,
subscriptionType, subscriptionType,
}); });
setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId); paymentDispatch({ type: "PaymentReset" });
}
function handleQrisResume(): void {
if (!hasHiddenQrisPayment) return;
log.debug(`[${logScope}] qris dialog resumed`, {
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
subscriptionType,
});
setHiddenQrisOrderId(null);
} }
return { return {
ezpayPaymentUrl, ezpayPaymentUrl,
handleEzpayCancel, handleEzpayCancel,
handleEzpayConfirm, handleEzpayConfirm,
handleQrisClose, handleRegionalQrClose,
handleQrisResume,
handleStripeClose, handleStripeClose,
handleStripeConfirmed, handleStripeConfirmed,
hasHiddenQrisPayment,
isConfirmingEzpay, isConfirmingEzpay,
qrisErrorMessage: payment.errorMessage, regionalQrErrorMessage: payment.errorMessage,
qrisPayment, regionalQrPayment,
qrisStatus: payment.orderStatus, regionalQrStatus: payment.orderStatus,
resetLaunchState, resetLaunchState,
shouldShowEzpayConfirmDialog, shouldShowEzpayConfirmDialog,
shouldShowQrisDialog, shouldShowRegionalQrDialog,
shouldShowStripeDialog, shouldShowStripeDialog,
stripeClientSecret, stripeClientSecret,
stripeCustomerSessionClientSecret, stripeCustomerSessionClientSecret,
@@ -4,8 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
dispatch: vi.fn(), dispatch: vi.fn(),
handleQrisResume: vi.fn(),
hasHiddenQrisPayment: false,
resetLaunchState: vi.fn(), resetLaunchState: vi.fn(),
payment: { payment: {
selectedPlanId: "vip_monthly", selectedPlanId: "vip_monthly",
@@ -27,8 +25,6 @@ vi.mock("@/stores/payment/payment-context", () => ({
vi.mock("@/app/_hooks/use-payment-launch-flow", () => ({ vi.mock("@/app/_hooks/use-payment-launch-flow", () => ({
usePaymentLaunchFlow: () => ({ usePaymentLaunchFlow: () => ({
handleQrisResume: mocks.handleQrisResume,
hasHiddenQrisPayment: mocks.hasHiddenQrisPayment,
resetLaunchState: mocks.resetLaunchState, resetLaunchState: mocks.resetLaunchState,
launch: {}, launch: {},
}), }),
@@ -95,8 +91,6 @@ describe("SubscriptionCheckoutButton renewal confirmation", () => {
.IS_REACT_ACT_ENVIRONMENT = true; .IS_REACT_ACT_ENVIRONMENT = true;
localStorage.clear(); localStorage.clear();
mocks.dispatch.mockClear(); mocks.dispatch.mockClear();
mocks.handleQrisResume.mockClear();
mocks.hasHiddenQrisPayment = false;
mocks.resetLaunchState.mockClear(); mocks.resetLaunchState.mockClear();
mocks.payment.selectedPlanId = "vip_monthly"; mocks.payment.selectedPlanId = "vip_monthly";
mocks.payment.autoRenew = true; 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.payChannel = "ezpay";
mocks.payment.isPollingOrder = true; mocks.payment.isPollingOrder = true;
mocks.hasHiddenQrisPayment = true;
act(() => act(() =>
root.render( root.render(
@@ -204,11 +197,8 @@ describe("SubscriptionCheckoutButton renewal confirmation", () => {
), ),
); );
const resumeButton = getButton(container, "Resume QRIS payment"); const checkoutButton = getButton(container, "Processing payment...");
expect(resumeButton.disabled).toBe(false); expect(checkoutButton.disabled).toBe(true);
act(() => resumeButton.click());
expect(mocks.handleQrisResume).toHaveBeenCalledOnce();
expect(mocks.resetLaunchState).not.toHaveBeenCalled(); expect(mocks.resetLaunchState).not.toHaveBeenCalled();
expect(mocks.dispatch).not.toHaveBeenCalled(); expect(mocks.dispatch).not.toHaveBeenCalled();
}); });
@@ -35,6 +35,7 @@ export interface SubscriptionCheckoutButtonProps {
sourceCharacterSlug?: string; sourceCharacterSlug?: string;
renewalPlan?: VipOfferPlanView | null; renewalPlan?: VipOfferPlanView | null;
renewalConsentSubjectId?: string | null; renewalConsentSubjectId?: string | null;
countryCode?: string | null;
} }
export function SubscriptionCheckoutButton({ export function SubscriptionCheckoutButton({
@@ -44,6 +45,7 @@ export function SubscriptionCheckoutButton({
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG, sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
renewalPlan = null, renewalPlan = null,
renewalConsentSubjectId = null, renewalConsentSubjectId = null,
countryCode = null,
}: SubscriptionCheckoutButtonProps) { }: SubscriptionCheckoutButtonProps) {
const [showRenewalConfirmation, setShowRenewalConfirmation] = const [showRenewalConfirmation, setShowRenewalConfirmation] =
useState(false); useState(false);
@@ -57,15 +59,12 @@ export function SubscriptionCheckoutButton({
returnTo: returnTo ?? undefined, returnTo: returnTo ?? undefined,
characterSlug: sourceCharacterSlug, characterSlug: sourceCharacterSlug,
subscriptionType, subscriptionType,
countryCode,
}); });
const isResumingQris = paymentLaunch.hasHiddenQrisPayment; const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
const isLoading =
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
const readyLabel = "Pay and Top Up"; const readyLabel = "Pay and Top Up";
const label = isResumingQris const label = payment.isPollingOrder
? "Resume QRIS payment"
: payment.isPollingOrder
? "Processing payment..." ? "Processing payment..."
: payment.isCreatingOrder : payment.isCreatingOrder
? "Creating order..." ? "Creating order..."
@@ -78,10 +77,6 @@ export function SubscriptionCheckoutButton({
}; };
const handleClick = () => { const handleClick = () => {
if (isResumingQris) {
paymentLaunch.handleQrisResume();
return;
}
if (disabled || isLoading) return; if (disabled || isLoading) return;
if ( if (
payment.payChannel === "stripe" && payment.payChannel === "stripe" &&
@@ -123,7 +118,7 @@ export function SubscriptionCheckoutButton({
type="button" type="button"
data-analytics-key="subscription.checkout" data-analytics-key="subscription.checkout"
data-analytics-label="Start subscription checkout" data-analytics-label="Start subscription checkout"
disabled={disabled && !isResumingQris} disabled={disabled}
isLoading={isLoading} isLoading={isLoading}
onClick={handleClick} onClick={handleClick}
> >
@@ -306,6 +306,7 @@ export function SubscriptionScreen({
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG} sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
renewalPlan={selectedVipPlan} renewalPlan={selectedVipPlan}
renewalConsentSubjectId={userState.currentUser?.id || null} renewalConsentSubjectId={userState.currentUser?.id || null}
countryCode={countryCode}
/> />
</div> </div>
+7 -8
View File
@@ -20,6 +20,7 @@ export interface TipCheckoutButtonProps {
disabled?: boolean; disabled?: boolean;
onOrder: () => void; onOrder: () => void;
returnPath: string; returnPath: string;
countryCode?: string | null;
} }
export function TipCheckoutButton({ export function TipCheckoutButton({
@@ -28,6 +29,7 @@ export function TipCheckoutButton({
disabled = false, disabled = false,
onOrder, onOrder,
returnPath, returnPath,
countryCode = null,
}: TipCheckoutButtonProps) { }: TipCheckoutButtonProps) {
const character = useActiveCharacter(); const character = useActiveCharacter();
const payment = usePaymentState(); const payment = usePaymentState();
@@ -41,14 +43,11 @@ export function TipCheckoutButton({
giftCategory, giftCategory,
giftPlanId, giftPlanId,
characterSlug: character.slug, characterSlug: character.slug,
countryCode,
}); });
const isResumingQris = paymentLaunch.hasHiddenQrisPayment; const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
const isLoading = const label = payment.isPollingOrder
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
const label = isResumingQris
? "Resume QRIS payment"
: payment.isPollingOrder
? "Processing payment..." ? "Processing payment..."
: payment.isCreatingOrder : payment.isCreatingOrder
? "Creating order..." ? "Creating order..."
@@ -63,8 +62,8 @@ export function TipCheckoutButton({
data-analytics-key="tip.checkout" data-analytics-key="tip.checkout"
data-analytics-label="Buy coffee tip" data-analytics-label="Buy coffee tip"
className={styles.checkoutButton} className={styles.checkoutButton}
disabled={(disabled && !isResumingQris) || isLoading} disabled={disabled || isLoading}
onClick={isResumingQris ? paymentLaunch.handleQrisResume : onOrder} onClick={onOrder}
> >
{label} {label}
</button> </button>
+1
View File
@@ -305,6 +305,7 @@ export function TipScreen({
disabled={!canCreateOrder} disabled={!canCreateOrder}
onOrder={handleOrder} onOrder={handleOrder}
returnPath={returnPath} returnPath={returnPath}
countryCode={userState.currentUser?.countryCode}
/> />
</div> </div>
</> </>
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { import {
getPaymentUrl, getPaymentUrl,
getPaymentUrlHostname,
getStripeCustomerSessionClientSecret, getStripeCustomerSessionClientSecret,
getStripeClientSecret, getStripeClientSecret,
isEzpayPayment, isEzpayPayment,
@@ -66,36 +67,103 @@ describe("payment launch helpers", () => {
).toBeNull(); ).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( expect(
resolveEzpayLaunchTarget({ resolveEzpayLaunchTarget({
provider: "ezpay", provider: "ezpay",
channelType: "URL", channelType: "URL",
payData: "https://pay.example/qris", payData: "https://pay.example/gcash",
}), }, { countryCode: "PH", currency: "PHP" }),
).toEqual({ kind: "url", paymentUrl: "https://pay.example/qris" }); ).toEqual({ kind: "url", paymentUrl: "https://pay.example/gcash" });
expect( expect(
resolveEzpayLaunchTarget({ resolveEzpayLaunchTarget({
provider: "ezpay", provider: "ezpay",
channelType: "QR", 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({ ).toEqual({
kind: "qr", 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", qrData: "00020101021226670016COM.NOBUBANK.WWW",
}); });
});
it("reports a regional error only when both URL and QR data are absent", () => {
expect( expect(
resolveEzpayLaunchTarget({ resolveEzpayLaunchTarget(
provider: "ezpay", {
channelType: "QR", provider: "ezpay",
payData: "", channelType: "QR",
}), payData: "",
},
{ countryCode: "PH" },
),
).toEqual({ ).toEqual({
kind: "error", 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", () => { it("rejects VA and unknown Ezpay channel types with explicit errors", () => {
expect( expect(
resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }), resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }),
+105 -16
View File
@@ -13,9 +13,20 @@ const log = new Logger("LibPaymentPaymentLaunch");
export type EzpayLaunchTarget = export type EzpayLaunchTarget =
| { kind: "url"; paymentUrl: string } | { kind: "url"; paymentUrl: string }
| { kind: "qr"; qrData: string } | {
kind: "qr";
experience: EzpayQrExperience;
qrData: string;
}
| { kind: "error"; errorMessage: string }; | { kind: "error"; errorMessage: string };
export type EzpayQrExperience = "gcashQrPh" | "qris" | "paymentQr";
export interface ResolveEzpayLaunchContext {
countryCode?: string | null;
currency?: string | null;
}
function getNonEmptyString( function getNonEmptyString(
payParams: Record<string, unknown>, payParams: Record<string, unknown>,
...keys: string[] ...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 { export function getPaymentUrl(payParams: Record<string, unknown>): string | null {
const keys = [ const keys = [
"cashierUrl", "cashierUrl",
@@ -71,6 +88,7 @@ export function isEzpayPayment(payParams: Record<string, unknown>): boolean {
export function resolveEzpayLaunchTarget( export function resolveEzpayLaunchTarget(
payParams: Record<string, unknown>, payParams: Record<string, unknown>,
context: ResolveEzpayLaunchContext = {},
): EzpayLaunchTarget { ): EzpayLaunchTarget {
const rawChannelType = getNonEmptyString( const rawChannelType = getNonEmptyString(
payParams, payParams,
@@ -79,13 +97,33 @@ export function resolveEzpayLaunchTarget(
); );
const channelType = rawChannelType?.toUpperCase() ?? null; const channelType = rawChannelType?.toUpperCase() ?? null;
const payData = getNonEmptyString(payParams, "payData", "pay_data"); 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") { if (channelType === "QR") {
return payData if (qrData) {
? { kind: "qr", qrData: payData } return {
kind: "qr",
experience: resolveEzpayQrExperience(region),
qrData,
};
}
return paymentUrl
? { kind: "url", paymentUrl }
: { : {
kind: "error", kind: "error",
errorMessage: "QRIS payment data is missing. Please try again.", errorMessage: regionalQrMissingMessage(region),
}; };
} }
@@ -97,8 +135,6 @@ export function resolveEzpayLaunchTarget(
} }
if (channelType === "URL") { if (channelType === "URL") {
const paymentUrl =
getHttpPaymentUrl(payData) ?? getHttpPaymentUrl(getPaymentUrl(payParams));
return paymentUrl return paymentUrl
? { kind: "url", paymentUrl } ? { kind: "url", paymentUrl }
: { : {
@@ -114,14 +150,66 @@ export function resolveEzpayLaunchTarget(
}; };
} }
const legacyPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams)); if (paymentUrl) return { kind: "url", paymentUrl };
return legacyPaymentUrl if (qrData && region !== null) {
? { kind: "url", paymentUrl: legacyPaymentUrl } return {
: { kind: "qr",
kind: "error", experience: resolveEzpayQrExperience(region),
errorMessage: qrData,
"Ezpay payment parameters did not include a supported URL or QRIS code.", };
}; }
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( export function getStripeClientSecret(
@@ -187,18 +275,19 @@ export async function launchEzpayRedirect({
onOpened, onOpened,
onFailed, onFailed,
}: LaunchEzpayRedirectInput): Promise<void> { }: LaunchEzpayRedirectInput): Promise<void> {
const paymentUrlHost = getPaymentUrlHostname(paymentUrl);
log.debug("[payment-launch] launchEzpayRedirect START", { log.debug("[payment-launch] launchEzpayRedirect START", {
hasOrderId: Boolean(orderId), hasOrderId: Boolean(orderId),
orderId, orderId,
subscriptionType, subscriptionType,
paymentUrl, paymentUrlHost,
}); });
if (!orderId) { if (!orderId) {
const errorMessage = "Missing order id before opening Ezpay."; const errorMessage = "Missing order id before opening Ezpay.";
log.error("[payment-launch] pending ezpay order save skipped", { log.error("[payment-launch] pending ezpay order save skipped", {
subscriptionType, subscriptionType,
paymentUrl, paymentUrlHost,
errorMessage, errorMessage,
}); });
onFailed(errorMessage); onFailed(errorMessage);