10 Commits

Author SHA1 Message Date
Codex dae04f75dc fix(payment): restore wallet order and English labels
Docker Image / Build and Push Docker Image (push) Successful in 2m1s
2026-07-29 15:29:22 +08:00
Codex 8f8e067d82 fix(payment): allow Stripe after closing QRIS
Docker Image / Build and Push Docker Image (push) Successful in 2m4s
2026-07-29 14:33:53 +08:00
Codex 3c74d30189 fix(payment): keep character and mobile actions visible
Docker Image / Build and Push Docker Image (push) Successful in 2m3s
2026-07-29 13:11:33 +08:00
Codex 4cbdd0da7c fix(payment): resume QRIS after closing dialog
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
2026-07-29 12:27:56 +08:00
Codex e071f83474 feat(payment): confirm renewal only at checkout
Docker Image / Build and Push Docker Image (push) Successful in 2m9s
2026-07-29 11:32:23 +08:00
Codex 3536045794 fix(payment): inherit chat recipient without selector
Docker Image / Build and Push Docker Image (push) Successful in 2m42s
2026-07-28 19:37:58 +08:00
Codex 74b7eae18b feat(payment): add chat support discounts and gratitude
Docker Image / Build and Push Docker Image (push) Successful in 2m14s
(cherry picked from commit ef9b79bc83)
2026-07-28 17:54:36 +08:00
Codex 59e4eac736 feat(payment): expand Stripe methods and checkout handoff
Docker Image / Build and Push Docker Image (push) Successful in 2m10s
2026-07-28 16:48:11 +08:00
Codex 2e402de15b feat(payment): accept support character guidance scene
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
2026-07-28 12:16:28 +08:00
Codex a530850039 fix(payment): hide first recharge banner
Docker Image / Build and Push Docker Image (push) Successful in 2m11s
2026-07-28 11:15:21 +08:00
17 changed files with 341 additions and 618 deletions
@@ -26,10 +26,7 @@ test("guest unlocks a promoted image through email login and top-up", async ({
);
await expect(page).toHaveURL(defaultCharacterChatUrl);
const promotedImageCard = page
.getByRole("group", { name: "Locked private image" })
.last();
const unlockButton = promotedImageCard.getByRole("button", {
const unlockButton = page.getByRole("button", {
name: "Unlock private image",
});
await expect(unlockButton).toBeVisible({ timeout: 10_000 });
+84 -20
View File
@@ -85,11 +85,14 @@ async function registerIndonesiaPaymentMocks(page: Page) {
});
await page.route("**/api/payment/create-order", async (route) => {
createOrderCount += 1;
const body = route.request().postDataJSON() as { planId: string };
const body = route.request().postDataJSON() as {
planId: string;
payChannel: "ezpay" | "stripe";
};
const plan =
idrPlans.plans.find((item) => item.planId === body.planId) ??
idrGiftCatalog.plans.find((item) => item.planId === body.planId);
const orderId = `order_qris_${body.planId}`;
const orderId = `order_${body.payChannel}_${body.planId}`;
orderStatuses.set(orderId, "pending");
orderPlans.set(orderId, {
planId: body.planId,
@@ -98,7 +101,13 @@ async function registerIndonesiaPaymentMocks(page: Page) {
await route.fulfill({
json: apiEnvelope({
orderId,
payParams: {
payParams:
body.payChannel === "stripe"
? {
provider: "stripe",
clientSecret: "pi_e2e_secret_mock",
}
: {
provider: "ezpay",
countryCode: "ID",
channelCode: "ID_QRIS_DYNAMIC_QR",
@@ -183,7 +192,7 @@ async function expectQrisOrder(
await expect(
dialog.getByRole("img", { name: "QRIS payment QR code" }),
).toBeVisible();
return `order_qris_${planId}`;
return `order_ezpay_${planId}`;
}
async function expectCheckoutButtonLayout(page: Page) {
@@ -266,7 +275,40 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
).toBeVisible({ timeout: 10_000 });
});
test("closing QRIS restores payment selection and allows switching to Stripe", async ({
test("closing QRIS restores a resumable checkout without creating a duplicate order", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup&character=elio");
const orderId = await expectQrisOrder(
page,
/Pay and Top Up/i,
"dol_1000",
/Rp\s*88[.,]900/,
);
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
const resumeButton = page.getByRole("button", {
name: "Resume QRIS payment",
});
await expect(resumeButton).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
await resumeButton.click();
await expect(dialog).toBeVisible();
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
await expect(
page.getByRole("alertdialog", { name: "Payment successful" }),
).toBeVisible({ timeout: 10_000 });
});
test("closing QRIS allows switching to Stripe and creating a Stripe order", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
@@ -281,16 +323,30 @@ test("closing QRIS restores payment selection and allows switching to Stripe", a
);
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
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",
const stripeButton = page.getByRole("button", {
name: "Stripe",
exact: true,
});
await expect(stripeButton).toBeEnabled();
await stripeButton.click();
await expect(stripeButton).toHaveAttribute("aria-pressed", "true");
await expect(
page.getByRole("button", { name: "Resume QRIS payment" }),
).toHaveCount(0);
const stripeRequestPromise = page.waitForRequest(
"**/api/payment/create-order",
);
await expect(page.getByRole("button", { name: "Pay and Top Up" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
await page.getByRole("button", { name: "Pay and Top Up" }).click();
const stripeRequest = await stripeRequestPromise;
expect(stripeRequest.postDataJSON()).toMatchObject({
planId: "dol_1000",
payChannel: "stripe",
recipientCharacterId: "elio",
});
expect(payment.getCreateOrderCount()).toBe(2);
});
test("payment issue submits Other to the feedback API without creating an order", async ({
@@ -344,7 +400,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();
await expectQrisOrder(
const orderId = await expectQrisOrder(
page,
/Order and Buy/i,
"tip_coffee_usd_4_99",
@@ -354,11 +410,19 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
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",
);
const resumeButton = page.getByRole("button", {
name: "Resume QRIS payment",
});
await expect(resumeButton).toBeEnabled();
await resumeButton.click();
await expect(dialog).toBeVisible();
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();
});
@@ -1,153 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { apiEnvelope } from "@e2e/fixtures/data/common";
import { e2eEmailUser } from "@e2e/fixtures/data/user";
import {
clearBrowserState,
seedEmailSession,
} from "@e2e/fixtures/test-helpers";
const phpPlans = {
plans: [
{
planId: "dol_1000",
planName: "1,000 Credits",
orderType: "dol",
amountCents: 49_990,
originalAmountCents: 74_990,
dailyPriceCents: null,
currency: "PHP",
vipDays: null,
dolAmount: 1_000,
creditBalance: 1_000,
},
],
};
async function registerPhilippinesPaymentMocks(
page: Page,
response: "hosted" | "qr",
) {
let createOrderCount = 0;
await page.route("**/api/user/profile", async (route) => {
await route.fulfill({
json: apiEnvelope({ ...e2eEmailUser, countryCode: "PH" }),
});
});
await page.route("**/api/payment/plans**", async (route) => {
await route.fulfill({ json: apiEnvelope(phpPlans) });
});
await page.route("**/api/payment/create-order", async (route) => {
createOrderCount += 1;
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
payParams: {
provider: "ezpay",
countryCode: "PH",
channelCode: "PH_QRPH_DYNAMIC",
channelType: "QR",
payData: "000201010212ph-qr-payload",
...(response === "hosted"
? { cashierUrl: "https://pay.example/gcash-hosted" }
: {}),
firstChargeAmountCents: 49_990,
currency: "PHP",
},
}),
});
});
await page.route("**/api/payment/order-status**", async (route) => {
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
status: "pending",
orderType: "dol",
planId: "dol_1000",
creditsAdded: 0,
}),
});
});
return {
getCreateOrderCount: () => createOrderCount,
};
}
async function preparePhilippinesUser(page: Page) {
await seedEmailSession(page);
await page.evaluate(() => {
const rawUser = localStorage.getItem("cozsweet:user");
const user = rawUser ? JSON.parse(rawUser) : {};
localStorage.setItem(
"cozsweet:user",
JSON.stringify({ ...user, countryCode: "PH" }),
);
});
}
test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page);
});
test("Philippines checkout prefers the hosted GCash URL even for a QR response", async ({
page,
}) => {
const payment = await registerPhilippinesPaymentMocks(page, "hosted");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await expect(page.getByRole("button", { name: "GCash" })).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByRole("button", { name: "Pay and Top Up" }).click();
await expect(
page.getByRole("alertdialog", { name: "Continue to payment?" }),
).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Pay with GCash / QR Ph" }),
).toHaveCount(0);
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toHaveCount(0);
expect(payment.getCreateOrderCount()).toBe(1);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
});
test("Philippines QR-only fallback is labeled GCash / QR Ph and can switch to Stripe", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const payment = await registerPhilippinesPaymentMocks(page, "qr");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await page.getByRole("button", { name: "Pay and Top Up" }).click();
const dialog = page.getByRole("dialog", {
name: "Pay with GCash / QR Ph",
});
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(/₱\s*499\.90/);
await expect(dialog).not.toContainText("QRIS");
await expect(
dialog.getByRole("img", { name: "GCash / QR Ph payment QR code" }),
).toBeVisible();
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(payment.getCreateOrderCount()).toBe(1);
});
@@ -7,15 +7,15 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
const hiddenLaunch = {
handleEzpayCancel: vi.fn(),
handleEzpayConfirm: vi.fn(),
handleRegionalQrClose: vi.fn(),
handleQrisClose: vi.fn(),
handleStripeClose: vi.fn(),
handleStripeConfirmed: vi.fn(),
isConfirmingEzpay: false,
regionalQrErrorMessage: null,
regionalQrPayment: null,
regionalQrStatus: null,
qrisErrorMessage: null,
qrisPayment: null,
qrisStatus: null,
shouldShowEzpayConfirmDialog: false,
shouldShowRegionalQrDialog: false,
shouldShowQrisDialog: false,
shouldShowStripeDialog: false,
stripeClientSecret: null,
stripeCustomerSessionClientSecret: null,
@@ -98,15 +98,14 @@ describe("PaymentLaunchDialogs", () => {
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
regionalQrPayment: {
qrisPayment: {
qrData: "00020101021226670016COM.NOBUBANK.WWW",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
experience: "qris",
},
regionalQrStatus: "pending",
shouldShowRegionalQrDialog: true,
qrisStatus: "pending",
shouldShowQrisDialog: true,
}}
/>,
),
@@ -125,44 +124,6 @@ describe("PaymentLaunchDialogs", () => {
expect(dialog?.textContent).toContain("Waiting for payment");
});
it("labels a Philippine QR fallback as GCash / QR Ph", () => {
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-ph-qr"
externalCheckoutAnalyticsKey="payment.external_checkout"
ezpayDescription="Pay with GCash."
launch={{
...hiddenLaunch,
regionalQrPayment: {
qrData: "000201010212ph-qr-payload",
orderId: "order-ph-qr",
amountCents: 49_990,
currency: "PHP",
experience: "gcashQrPh",
},
regionalQrStatus: "pending",
shouldShowRegionalQrDialog: true,
}}
/>,
),
);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Pay with GCash / QR Ph");
expect(dialog?.textContent).not.toContain("QRIS");
expect(dialog?.querySelector("svg title")?.textContent).toBe(
"GCash / QR Ph payment QR code",
);
expect(dialog?.textContent).toContain("₱");
const closeButton = Array.from(
dialog?.querySelectorAll("button") ?? [],
).find((button) => button.textContent?.trim() === "Close");
act(() => closeButton?.click());
expect(hiddenLaunch.handleRegionalQrClose).toHaveBeenCalledOnce();
});
it("shows QRIS failure state and handles empty QR data safely", () => {
act(() =>
root.render(
@@ -172,16 +133,15 @@ describe("PaymentLaunchDialogs", () => {
ezpayDescription="Scan QRIS to finish the payment."
launch={{
...hiddenLaunch,
regionalQrErrorMessage: "Payment failed or was cancelled.",
regionalQrPayment: {
qrisErrorMessage: "Payment failed or was cancelled.",
qrisPayment: {
qrData: "",
orderId: "order-id-qris",
amountCents: 5_000_000,
currency: "IDR",
experience: "qris",
},
regionalQrStatus: "failed",
shouldShowRegionalQrDialog: true,
qrisStatus: "failed",
shouldShowQrisDialog: true,
}}
/>,
),
@@ -63,7 +63,7 @@ describe("StripePaymentDialog", () => {
document.body.style.overflow = "";
});
it("renders Express Checkout first and a collapsed Pay by card section last", () => {
it("renders express methods and wallets before card with English Stripe copy", () => {
act(() =>
root.render(
<StripePaymentDialog
@@ -78,19 +78,18 @@ describe("StripePaymentDialog", () => {
expect(capturedElementsOptions).toMatchObject({
clientSecret: "pi_123_secret_payment",
customerSessionClientSecret: "cuss_123_secret_saved",
locale: "en",
});
expect(capturedExpressProps?.options).toMatchObject({
paymentMethodOrder: [
"apple_pay",
"google_pay",
"link",
"paypal",
"amazon_pay",
"klarna",
],
paymentMethods: {
applePay: "always",
googlePay: "always",
applePay: "never",
googlePay: "never",
link: "auto",
paypal: "auto",
amazonPay: "auto",
@@ -99,11 +98,11 @@ describe("StripePaymentDialog", () => {
});
expect(capturedPaymentProps?.options).toMatchObject({
layout: { type: "accordion", defaultCollapsed: true },
paymentMethodOrder: ["card"],
wallets: { applePay: "never", googlePay: "never", link: "never" },
paymentMethodOrder: ["wechat_pay", "alipay", "card"],
wallets: { applePay: "auto", googlePay: "auto", link: "never" },
});
const text = document.body.textContent ?? "";
expect(text).toContain("Pay by card");
expect(text).toContain("Choose a payment method");
const express = document.body.querySelector('[data-testid="express-checkout"]');
const card = document.body.querySelector('[data-testid="payment-element"]');
expect(express).not.toBeNull();
@@ -114,6 +113,35 @@ describe("StripePaymentDialog", () => {
).toBeTruthy();
});
it("keeps Payment Element wallet fallbacks when Express Checkout is unavailable", () => {
act(() =>
root.render(
<StripePaymentDialog
clientSecret="pi_123_secret_payment"
onClose={vi.fn()}
/>,
),
);
const onReady = capturedExpressProps?.onReady as
| ((event: { availablePaymentMethods: undefined }) => void)
| undefined;
expect(onReady).toBeTypeOf("function");
act(() => onReady?.({ availablePaymentMethods: undefined }));
const expressSection = document.body.querySelector(
'[aria-label="Express payment methods"]',
);
expect(expressSection).toHaveProperty("hidden", true);
expect(
document.body.querySelector('[data-testid="payment-element"]'),
).not.toBeNull();
expect(capturedPaymentProps?.options).toMatchObject({
wallets: { applePay: "auto", googlePay: "auto", link: "never" },
paymentMethodOrder: ["wechat_pay", "alipay", "card"],
});
});
it("does not pass an invalid or disabled Customer Session secret", () => {
act(() =>
root.render(
@@ -13,15 +13,15 @@ type PaymentLaunchDialogFlow = Pick<
PaymentLaunchFlow,
| "handleEzpayCancel"
| "handleEzpayConfirm"
| "handleRegionalQrClose"
| "handleQrisClose"
| "handleStripeClose"
| "handleStripeConfirmed"
| "isConfirmingEzpay"
| "regionalQrErrorMessage"
| "regionalQrPayment"
| "regionalQrStatus"
| "qrisErrorMessage"
| "qrisPayment"
| "qrisStatus"
| "shouldShowEzpayConfirmDialog"
| "shouldShowRegionalQrDialog"
| "shouldShowQrisDialog"
| "shouldShowStripeDialog"
| "stripeClientSecret"
| "stripeCustomerSessionClientSecret"
@@ -55,12 +55,12 @@ export function PaymentLaunchDialogs({
onConfirm={launch.handleEzpayConfirm}
/>
) : null}
{launch.shouldShowRegionalQrDialog && launch.regionalQrPayment ? (
<RegionalQrPaymentDialog
payment={launch.regionalQrPayment}
status={launch.regionalQrStatus}
errorMessage={launch.regionalQrErrorMessage}
onClose={launch.handleRegionalQrClose}
{launch.shouldShowQrisDialog && launch.qrisPayment ? (
<QrisPaymentDialog
payment={launch.qrisPayment}
status={launch.qrisStatus}
errorMessage={launch.qrisErrorMessage}
onClose={launch.handleQrisClose}
/>
) : null}
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
@@ -79,56 +79,43 @@ export function PaymentLaunchDialogs({
);
}
interface RegionalQrPaymentDialogProps {
interface QrisPaymentDialogProps {
errorMessage: string | null;
onClose: () => void;
payment: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>;
status: PaymentLaunchFlow["regionalQrStatus"];
payment: NonNullable<PaymentLaunchFlow["qrisPayment"]>;
status: PaymentLaunchFlow["qrisStatus"];
}
function formatRegionalQrAmount(amountCents: number, currency: string): string {
function formatQrisAmount(amountCents: number, currency: string): string {
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
try {
return new Intl.NumberFormat(
normalizedCurrency === "PHP" ? "en-PH" : "id-ID",
{
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: normalizedCurrency,
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
},
).format(amountCents / 100);
}).format(amountCents / 100);
} catch {
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
}
}
function regionalQrStatusMessage(
status: PaymentLaunchFlow["regionalQrStatus"],
function qrisStatusMessage(
status: PaymentLaunchFlow["qrisStatus"],
errorMessage: string | null,
paymentName: string,
): string {
if (status === "failed") {
return errorMessage || "Payment failed. Please try again.";
}
if (status === "expired") {
return errorMessage || `This ${paymentName} order has expired.`;
}
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 RegionalQrPaymentDialog({
function QrisPaymentDialog({
errorMessage,
onClose,
payment,
status,
}: RegionalQrPaymentDialogProps) {
}: QrisPaymentDialogProps) {
const titleId = useId();
const copy = regionalQrCopy(payment.experience);
const statusMessage = regionalQrStatusMessage(
status,
errorMessage,
copy.paymentName,
);
const statusMessage = qrisStatusMessage(status, errorMessage);
return (
<ModalPortal
@@ -142,17 +129,17 @@ function RegionalQrPaymentDialog({
>
<div className={`${styles.header} text-center`}>
<h2 id={titleId} className={styles.title}>
{copy.title}
Scan to pay with QRIS
</h2>
<p className={styles.content}>
{copy.description}
Open a QRIS-compatible banking or wallet app and scan this code.
</p>
</div>
<div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4">
{payment.qrData ? (
<QRCodeSVG
value={payment.qrData}
title={copy.qrTitle}
title="QRIS payment QR code"
size={256}
level="M"
marginSize={2}
@@ -160,14 +147,14 @@ function RegionalQrPaymentDialog({
/>
) : (
<p className={styles.error} role="alert">
{copy.unavailableMessage}
QRIS data is unavailable. Please close this dialog and try again.
</p>
)}
</div>
<div className="mb-4 flex flex-col gap-2 text-center">
<p className={styles.content}>Order No. {payment.orderId}</p>
<p className="m-0 text-xl font-bold text-text-foreground">
{formatRegionalQrAmount(payment.amountCents, payment.currency)}
{formatQrisAmount(payment.amountCents, payment.currency)}
</p>
<p
className={status === "failed" || status === "expired" ? styles.error : styles.content}
@@ -189,41 +176,6 @@ function RegionalQrPaymentDialog({
);
}
function regionalQrCopy(
experience: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>["experience"],
) {
if (experience === "gcashQrPh") {
return {
paymentName: "GCash / QR Ph",
title: "Pay with GCash / QR Ph",
description:
"Open GCash or another QR Ph-compatible app and scan this code.",
qrTitle: "GCash / QR Ph payment QR code",
unavailableMessage:
"GCash / QR Ph data is unavailable. Please close this dialog and try again.",
};
}
if (experience === "qris") {
return {
paymentName: "QRIS",
title: "Scan to pay with QRIS",
description:
"Open a QRIS-compatible banking or wallet app and scan this code.",
qrTitle: "QRIS payment QR code",
unavailableMessage:
"QRIS data is unavailable. Please close this dialog and try again.",
};
}
return {
paymentName: "payment QR",
title: "Scan to pay",
description: "Open a compatible banking or wallet app and scan this code.",
qrTitle: "Payment QR code",
unavailableMessage:
"Payment QR data is unavailable. Please close this dialog and try again.",
};
}
interface EzpayRedirectConfirmDialogProps {
description: ReactNode;
externalCheckoutAnalyticsKey: string;
@@ -124,6 +124,7 @@ export function StripePaymentDialog({
stripe={stripePromise}
options={{
clientSecret,
locale: "en",
...(savedCardClientSecret
? { customerSessionClientSecret: savedCardClientSecret }
: {}),
@@ -325,16 +326,14 @@ function StripePaymentForm({
<ExpressCheckoutElement
options={{
paymentMethodOrder: [
"apple_pay",
"google_pay",
"link",
"paypal",
"amazon_pay",
"klarna",
],
paymentMethods: {
applePay: "always",
googlePay: "always",
applePay: "never",
googlePay: "never",
link: "auto",
paypal: "auto",
amazonPay: "auto",
@@ -361,17 +360,20 @@ function StripePaymentForm({
}
/>
</section>
<section className={styles.cardSection} aria-labelledby="pay-by-card-title">
<h3 id="pay-by-card-title" className={styles.cardTitle}>
Pay by card
<section
className={styles.cardSection}
aria-labelledby="payment-method-title"
>
<h3 id="payment-method-title" className={styles.cardTitle}>
Choose a payment method
</h3>
<PaymentElement
options={{
layout: { type: "accordion", defaultCollapsed: true },
paymentMethodOrder: ["card"],
paymentMethodOrder: ["wechat_pay", "alipay", "card"],
wallets: {
applePay: "never",
googlePay: "never",
applePay: "auto",
googlePay: "auto",
link: "never",
},
}}
+55 -78
View File
@@ -4,7 +4,6 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
import {
getPaymentUrl,
getPaymentUrlHostname,
getStripeCustomerSessionClientSecret,
getStripeClientSecret,
isEzpayPayment,
@@ -43,33 +42,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;
handleRegionalQrClose: () => void;
handleQrisClose: () => void;
handleQrisResume: () => void;
handleStripeClose: () => void;
handleStripeConfirmed: () => void;
hasHiddenQrisPayment: boolean;
isConfirmingEzpay: boolean;
regionalQrErrorMessage: string | null;
regionalQrPayment: RegionalQrPaymentDetails | null;
regionalQrStatus: PaymentContextState["orderStatus"];
qrisErrorMessage: string | null;
qrisPayment: QrisPaymentDetails | null;
qrisStatus: PaymentContextState["orderStatus"];
resetLaunchState: () => void;
shouldShowEzpayConfirmDialog: boolean;
shouldShowRegionalQrDialog: boolean;
shouldShowQrisDialog: boolean;
shouldShowStripeDialog: boolean;
stripeClientSecret: string | null;
stripeCustomerSessionClientSecret: string | null;
savedPaymentMethodsEnabled: boolean;
}
export interface RegionalQrPaymentDetails {
export interface QrisPaymentDetails {
amountCents: number;
currency: string;
experience: "gcashQrPh" | "qris" | "paymentQr";
orderId: string;
qrData: string;
}
@@ -159,39 +158,33 @@ export function usePaymentLaunchFlow({
subscriptionType,
giftCategory,
giftPlanId,
countryCode,
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
const launchedNonceRef = useRef(0);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
string | null
>(null);
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
const [hiddenQrisOrderId, setHiddenQrisOrderId] = useState<string | null>(
null,
);
const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams)
: null;
const 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, {
countryCode,
currency: paymentCurrency,
})
? resolveEzpayLaunchTarget(payment.payParams)
: null;
const ezpayPaymentUrl =
ezpayLaunchTarget?.kind === "url"
? ezpayLaunchTarget.paymentUrl
: null;
const regionalQrPayment: RegionalQrPaymentDetails | null =
const selectedPlan = payment.plans.find(
(item) => item.planId === payment.selectedPlanId,
);
const qrisPayment: QrisPaymentDetails | null =
payment.payParams &&
payment.currentOrderId &&
ezpayLaunchTarget?.kind === "qr"
@@ -201,9 +194,9 @@ export function usePaymentLaunchFlow({
selectedPlan?.amountCents ??
0,
currency:
paymentCurrency ??
(ezpayLaunchTarget.experience === "gcashQrPh" ? "PHP" : "IDR"),
experience: ezpayLaunchTarget.experience,
paymentParamString(payment.payParams, "currency") ??
selectedPlan?.currency ??
"IDR",
orderId: payment.currentOrderId,
qrData: ezpayLaunchTarget.qrData,
}
@@ -222,33 +215,7 @@ export function usePaymentLaunchFlow({
const isEzpay = isEzpayPayment(payment.payParams);
if (isEzpay) {
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,
});
const target = resolveEzpayLaunchTarget(payment.payParams);
if (target.kind === "error") {
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({
@@ -263,18 +230,11 @@ export function usePaymentLaunchFlow({
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage: "Missing order id before showing payment QR code.",
errorMessage: "Missing order id before showing QRIS.",
});
return;
}
trackPaymentCheckoutOpened(
payment,
target.experience === "qris"
? "qris_embedded"
: target.experience === "gcashQrPh"
? "gcash_qrph_embedded"
: "payment_qr_embedded",
);
trackPaymentCheckoutOpened(payment, "qris_embedded");
return;
}
@@ -282,7 +242,7 @@ export function usePaymentLaunchFlow({
if (!AppEnvUtil.isProduction()) {
log.debug(`[${logScope}] ezpay confirmation required`, {
orderId: payment.currentOrderId,
paymentUrlHost: getPaymentUrlHostname(paymentUrl),
paymentUrl,
subscriptionType,
});
return;
@@ -308,7 +268,7 @@ export function usePaymentLaunchFlow({
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
try {
window.location.assign(paymentUrl);
window.location.href = paymentUrl;
trackPaymentCheckoutOpened(payment, paymentUrl);
} catch {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
@@ -329,7 +289,6 @@ export function usePaymentLaunchFlow({
log,
logScope,
characterSlug,
countryCode,
payment.currentOrderId,
payment,
payment.launchNonce,
@@ -338,7 +297,6 @@ export function usePaymentLaunchFlow({
payment.plans,
payment.selectedPlanId,
paymentDispatch,
paymentCurrency,
returnTo,
subscriptionType,
giftCategory,
@@ -355,13 +313,22 @@ export function usePaymentLaunchFlow({
payParams: payment.payParams,
paymentUrl: ezpayPaymentUrl,
});
const shouldShowRegionalQrDialog = Boolean(
regionalQrPayment && !payment.isPaid,
const shouldShowQrisDialog = Boolean(
qrisPayment &&
qrisPayment.orderId !== hiddenQrisOrderId &&
!payment.isPaid,
);
const hasHiddenQrisPayment = Boolean(
qrisPayment &&
qrisPayment.orderId === hiddenQrisOrderId &&
payment.isPollingOrder &&
!payment.isPaid,
);
function resetLaunchState(): void {
setIsConfirmingEzpay(false);
setHiddenStripeClientSecret(null);
setHiddenQrisOrderId(null);
}
function handleStripeClose(): void {
@@ -408,29 +375,39 @@ export function usePaymentLaunchFlow({
paymentDispatch({ type: "PaymentReset" });
}
function handleRegionalQrClose(): void {
log.debug(`[${logScope}] regional payment QR dialog closed`, {
orderId: regionalQrPayment?.orderId ?? payment.currentOrderId,
experience: regionalQrPayment?.experience ?? null,
function handleQrisClose(): void {
log.debug(`[${logScope}] qris dialog closed`, {
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
subscriptionType,
});
paymentDispatch({ type: "PaymentReset" });
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);
}
return {
ezpayPaymentUrl,
handleEzpayCancel,
handleEzpayConfirm,
handleRegionalQrClose,
handleQrisClose,
handleQrisResume,
handleStripeClose,
handleStripeConfirmed,
hasHiddenQrisPayment,
isConfirmingEzpay,
regionalQrErrorMessage: payment.errorMessage,
regionalQrPayment,
regionalQrStatus: payment.orderStatus,
qrisErrorMessage: payment.errorMessage,
qrisPayment,
qrisStatus: payment.orderStatus,
resetLaunchState,
shouldShowEzpayConfirmDialog,
shouldShowRegionalQrDialog,
shouldShowQrisDialog,
shouldShowStripeDialog,
stripeClientSecret,
stripeCustomerSessionClientSecret,
@@ -4,6 +4,8 @@ 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",
@@ -25,6 +27,8 @@ 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: {},
}),
@@ -91,6 +95,8 @@ 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;
@@ -183,9 +189,10 @@ describe("SubscriptionCheckoutButton renewal confirmation", () => {
});
});
it("keeps checkout disabled while a payment order is still being polled", () => {
it("reopens a hidden QRIS order without creating another order", () => {
mocks.payment.payChannel = "ezpay";
mocks.payment.isPollingOrder = true;
mocks.hasHiddenQrisPayment = true;
act(() =>
root.render(
@@ -197,8 +204,11 @@ describe("SubscriptionCheckoutButton renewal confirmation", () => {
),
);
const checkoutButton = getButton(container, "Processing payment...");
expect(checkoutButton.disabled).toBe(true);
const resumeButton = getButton(container, "Resume QRIS payment");
expect(resumeButton.disabled).toBe(false);
act(() => resumeButton.click());
expect(mocks.handleQrisResume).toHaveBeenCalledOnce();
expect(mocks.resetLaunchState).not.toHaveBeenCalled();
expect(mocks.dispatch).not.toHaveBeenCalled();
});
@@ -35,7 +35,6 @@ export interface SubscriptionCheckoutButtonProps {
sourceCharacterSlug?: string;
renewalPlan?: VipOfferPlanView | null;
renewalConsentSubjectId?: string | null;
countryCode?: string | null;
}
export function SubscriptionCheckoutButton({
@@ -45,7 +44,6 @@ export function SubscriptionCheckoutButton({
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
renewalPlan = null,
renewalConsentSubjectId = null,
countryCode = null,
}: SubscriptionCheckoutButtonProps) {
const [showRenewalConfirmation, setShowRenewalConfirmation] =
useState(false);
@@ -59,12 +57,15 @@ export function SubscriptionCheckoutButton({
returnTo: returnTo ?? undefined,
characterSlug: sourceCharacterSlug,
subscriptionType,
countryCode,
});
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
const isLoading =
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
const readyLabel = "Pay and Top Up";
const label = payment.isPollingOrder
const label = isResumingQris
? "Resume QRIS payment"
: payment.isPollingOrder
? "Processing payment..."
: payment.isCreatingOrder
? "Creating order..."
@@ -77,6 +78,10 @@ export function SubscriptionCheckoutButton({
};
const handleClick = () => {
if (isResumingQris) {
paymentLaunch.handleQrisResume();
return;
}
if (disabled || isLoading) return;
if (
payment.payChannel === "stripe" &&
@@ -118,7 +123,7 @@ export function SubscriptionCheckoutButton({
type="button"
data-analytics-key="subscription.checkout"
data-analytics-label="Start subscription checkout"
disabled={disabled}
disabled={disabled && !isResumingQris}
isLoading={isLoading}
onClick={handleClick}
>
+1 -2
View File
@@ -286,7 +286,7 @@ export function SubscriptionScreen({
<PaymentMethodSelector
config={renderedPaymentMethodConfig}
value={payment.payChannel}
disabled={isPaymentBusy}
disabled={payment.isCreatingOrder}
caption={
paymentMethodConfig.ezpayDisplayName === "QRIS"
? "QRIS by default in Indonesia"
@@ -306,7 +306,6 @@ export function SubscriptionScreen({
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
renewalPlan={selectedVipPlan}
renewalConsentSubjectId={userState.currentUser?.id || null}
countryCode={countryCode}
/>
</div>
+8 -7
View File
@@ -20,7 +20,6 @@ export interface TipCheckoutButtonProps {
disabled?: boolean;
onOrder: () => void;
returnPath: string;
countryCode?: string | null;
}
export function TipCheckoutButton({
@@ -29,7 +28,6 @@ export function TipCheckoutButton({
disabled = false,
onOrder,
returnPath,
countryCode = null,
}: TipCheckoutButtonProps) {
const character = useActiveCharacter();
const payment = usePaymentState();
@@ -43,11 +41,14 @@ export function TipCheckoutButton({
giftCategory,
giftPlanId,
characterSlug: character.slug,
countryCode,
});
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
const label = payment.isPollingOrder
const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
const isLoading =
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
const label = isResumingQris
? "Resume QRIS payment"
: payment.isPollingOrder
? "Processing payment..."
: payment.isCreatingOrder
? "Creating order..."
@@ -62,8 +63,8 @@ export function TipCheckoutButton({
data-analytics-key="tip.checkout"
data-analytics-label="Buy coffee tip"
className={styles.checkoutButton}
disabled={disabled || isLoading}
onClick={onOrder}
disabled={(disabled && !isResumingQris) || isLoading}
onClick={isResumingQris ? paymentLaunch.handleQrisResume : onOrder}
>
{label}
</button>
+1 -2
View File
@@ -292,7 +292,7 @@ export function TipScreen({
config={renderedPaymentMethodConfig}
value={payment.payChannel}
density="compact"
disabled={isPaymentBusy}
disabled={payment.isCreatingOrder}
className={styles.paymentMethodSlot}
analyticsKey="tip.payment_method"
onChange={handlePaymentMethodChange}
@@ -305,7 +305,6 @@ export function TipScreen({
disabled={!canCreateOrder}
onOrder={handleOrder}
returnPath={returnPath}
countryCode={userState.currentUser?.countryCode}
/>
</div>
</>
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
import {
getPaymentUrl,
getPaymentUrlHostname,
getStripeCustomerSessionClientSecret,
getStripeClientSecret,
isEzpayPayment,
@@ -67,103 +66,36 @@ describe("payment launch helpers", () => {
).toBeNull();
});
it("prioritizes a hosted GCash URL for Philippine checkout", () => {
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/gcash",
}, { countryCode: "PH", currency: "PHP" }),
).toEqual({ kind: "url", paymentUrl: "https://pay.example/gcash" });
payData: "https://pay.example/qris",
}),
).toEqual({ kind: "url", paymentUrl: "https://pay.example/qris" });
expect(
resolveEzpayLaunchTarget({
provider: "ezpay",
channelType: "QR",
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(
{
resolveEzpayLaunchTarget({
provider: "ezpay",
channelType: "QR",
payData: "",
},
{ countryCode: "PH" },
),
}),
).toEqual({
kind: "error",
errorMessage:
"GCash / QR Ph payment data is missing. Please try again.",
errorMessage: "QRIS 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" }),
+13 -102
View File
@@ -13,20 +13,9 @@ const log = new Logger("LibPaymentPaymentLaunch");
export type EzpayLaunchTarget =
| { kind: "url"; paymentUrl: string }
| {
kind: "qr";
experience: EzpayQrExperience;
qrData: string;
}
| { kind: "qr"; qrData: string }
| { kind: "error"; errorMessage: string };
export type EzpayQrExperience = "gcashQrPh" | "qris" | "paymentQr";
export interface ResolveEzpayLaunchContext {
countryCode?: string | null;
currency?: string | null;
}
function getNonEmptyString(
payParams: Record<string, unknown>,
...keys: string[]
@@ -52,12 +41,6 @@ function getHttpPaymentUrl(value: string | null): string | null {
}
}
export function getPaymentUrlHostname(value: string | null): string | null {
const paymentUrl = getHttpPaymentUrl(value);
if (!paymentUrl) return null;
return new URL(paymentUrl).hostname;
}
export function getPaymentUrl(payParams: Record<string, unknown>): string | null {
const keys = [
"cashierUrl",
@@ -88,7 +71,6 @@ export function isEzpayPayment(payParams: Record<string, unknown>): boolean {
export function resolveEzpayLaunchTarget(
payParams: Record<string, unknown>,
context: ResolveEzpayLaunchContext = {},
): EzpayLaunchTarget {
const rawChannelType = getNonEmptyString(
payParams,
@@ -97,33 +79,13 @@ 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") {
if (qrData) {
return {
kind: "qr",
experience: resolveEzpayQrExperience(region),
qrData,
};
}
return paymentUrl
? { kind: "url", paymentUrl }
return payData
? { kind: "qr", qrData: payData }
: {
kind: "error",
errorMessage: regionalQrMissingMessage(region),
errorMessage: "QRIS payment data is missing. Please try again.",
};
}
@@ -135,6 +97,8 @@ export function resolveEzpayLaunchTarget(
}
if (channelType === "URL") {
const paymentUrl =
getHttpPaymentUrl(payData) ?? getHttpPaymentUrl(getPaymentUrl(payParams));
return paymentUrl
? { kind: "url", paymentUrl }
: {
@@ -150,68 +114,16 @@ export function resolveEzpayLaunchTarget(
};
}
if (paymentUrl) return { kind: "url", paymentUrl };
if (qrData && region !== null) {
return {
kind: "qr",
experience: resolveEzpayQrExperience(region),
qrData,
};
}
return {
const legacyPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
return legacyPaymentUrl
? { kind: "url", paymentUrl: legacyPaymentUrl }
: {
kind: "error",
errorMessage:
"Ezpay payment parameters did not include a supported URL or payment QR code.",
"Ezpay payment parameters did not include a supported URL or QRIS 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(
payParams: Record<string, unknown>,
): string | null {
@@ -275,19 +187,18 @@ export async function launchEzpayRedirect({
onOpened,
onFailed,
}: LaunchEzpayRedirectInput): Promise<void> {
const paymentUrlHost = getPaymentUrlHostname(paymentUrl);
log.debug("[payment-launch] launchEzpayRedirect START", {
hasOrderId: Boolean(orderId),
orderId,
subscriptionType,
paymentUrlHost,
paymentUrl,
});
if (!orderId) {
const errorMessage = "Missing order id before opening Ezpay.";
log.error("[payment-launch] pending ezpay order save skipped", {
subscriptionType,
paymentUrlHost,
paymentUrl,
errorMessage,
});
onFailed(errorMessage);
@@ -301,6 +301,37 @@ describe("payment order flow", () => {
actor.stop();
});
it("can abandon a pending QRIS order and switch to Stripe", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
createTestPaymentMachine({ createOrderSpy, orderStatus: "pending" }),
).start();
await initialize(actor);
actor.send({ type: "PaymentPayChannelChanged", payChannel: "ezpay" });
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment"));
actor.send({ type: "PaymentPayChannelChanged", payChannel: "stripe" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
expect(actor.getSnapshot().context).toMatchObject({
payChannel: "stripe",
currentOrderId: null,
payParams: null,
orderStatus: null,
orderPollingStartedAt: null,
});
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("waitingForPayment"));
expect(createOrderSpy).toHaveBeenNthCalledWith(2, {
planId: "vip_monthly",
payChannel: "stripe",
autoRenew: true,
});
actor.stop();
});
it("moves to failed when polling reports failure", async () => {
const actor = createActor(
createTestPaymentMachine({ orderStatus: "failed" }),
+8
View File
@@ -265,6 +265,10 @@ const pollingOrderState = paymentMachineSetup.createStateConfig({
},
},
on: {
PaymentPayChannelChanged: {
target: "ready",
actions: "changePayChannel",
},
PaymentReset: {
target: "ready",
actions: "resetOrder",
@@ -277,6 +281,10 @@ const waitingForPaymentState = paymentMachineSetup.createStateConfig({
[POLL_DELAY_MS]: "pollingOrder",
},
on: {
PaymentPayChannelChanged: {
target: "ready",
actions: "changePayChannel",
},
PaymentReset: {
target: "ready",
actions: "resetOrder",