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

This commit is contained in:
Codex
2026-07-29 19:13:14 +08:00
parent 2e04260ebd
commit 599f3e3d26
11 changed files with 583 additions and 180 deletions
+16 -30
View File
@@ -283,14 +283,14 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
).toBeVisible({ timeout: 10_000 });
});
test("closing QRIS restores a resumable checkout without creating a duplicate order", async ({
test("closing QRIS restores payment selection and allows switching to Stripe", async ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup&character=elio");
const orderId = await expectQrisOrder(
await expectQrisOrder(
page,
/Pay and Top Up/i,
"dol_1000",
@@ -300,20 +300,14 @@ test("closing QRIS restores a resumable checkout without creating a duplicate or
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
const resumeButton = page.getByRole("button", {
name: "Resume QRIS payment",
});
await expect(resumeButton).toBeEnabled();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.getByRole("button", { name: "Pay and Top Up" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
await resumeButton.click();
await expect(dialog).toBeVisible();
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
await expect(
page.getByRole("alertdialog", { name: "Payment successful" }),
).toBeVisible({ timeout: 10_000 });
});
test("closing QRIS allows switching to Stripe and creating a Stripe order", async ({
@@ -408,7 +402,7 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
await page.goto("/characters/elio/tip");
await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible();
const orderId = await expectQrisOrder(
await expectQrisOrder(
page,
/Order and Buy/i,
"tip_coffee_usd_4_99",
@@ -418,19 +412,11 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
const resumeButton = page.getByRole("button", {
name: "Resume QRIS payment",
});
await expect(resumeButton).toBeEnabled();
await resumeButton.click();
await expect(dialog).toBeVisible();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Send another gift" })).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toBeHidden();
});
@@ -0,0 +1,153 @@
import { expect, test, type Page } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { apiEnvelope } from "@e2e/fixtures/data/common";
import { e2eEmailUser } from "@e2e/fixtures/data/user";
import {
clearBrowserState,
seedEmailSession,
} from "@e2e/fixtures/test-helpers";
const phpPlans = {
plans: [
{
planId: "dol_1000",
planName: "1,000 Credits",
orderType: "dol",
amountCents: 49_990,
originalAmountCents: 74_990,
dailyPriceCents: null,
currency: "PHP",
vipDays: null,
dolAmount: 1_000,
creditBalance: 1_000,
},
],
};
async function registerPhilippinesPaymentMocks(
page: Page,
response: "hosted" | "qr",
) {
let createOrderCount = 0;
await page.route("**/api/user/profile", async (route) => {
await route.fulfill({
json: apiEnvelope({ ...e2eEmailUser, countryCode: "PH" }),
});
});
await page.route("**/api/payment/plans**", async (route) => {
await route.fulfill({ json: apiEnvelope(phpPlans) });
});
await page.route("**/api/payment/create-order", async (route) => {
createOrderCount += 1;
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
payParams: {
provider: "ezpay",
countryCode: "PH",
channelCode: "PH_QRPH_DYNAMIC",
channelType: "QR",
payData: "000201010212ph-qr-payload",
...(response === "hosted"
? { cashierUrl: "https://pay.example/gcash-hosted" }
: {}),
firstChargeAmountCents: 49_990,
currency: "PHP",
},
}),
});
});
await page.route("**/api/payment/order-status**", async (route) => {
await route.fulfill({
json: apiEnvelope({
orderId: `order_gcash_${response}`,
status: "pending",
orderType: "dol",
planId: "dol_1000",
creditsAdded: 0,
}),
});
});
return {
getCreateOrderCount: () => createOrderCount,
};
}
async function preparePhilippinesUser(page: Page) {
await seedEmailSession(page);
await page.evaluate(() => {
const rawUser = localStorage.getItem("cozsweet:user");
const user = rawUser ? JSON.parse(rawUser) : {};
localStorage.setItem(
"cozsweet:user",
JSON.stringify({ ...user, countryCode: "PH" }),
);
});
}
test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL);
await mockCoreApis(page);
});
test("Philippines checkout prefers the hosted GCash URL even for a QR response", async ({
page,
}) => {
const payment = await registerPhilippinesPaymentMocks(page, "hosted");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await expect(page.getByRole("button", { name: "GCash" })).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByRole("button", { name: "Pay and Top Up" }).click();
await expect(
page.getByRole("alertdialog", { name: "Continue to payment?" }),
).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Pay with GCash / QR Ph" }),
).toHaveCount(0);
await expect(
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
).toHaveCount(0);
expect(payment.getCreateOrderCount()).toBe(1);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
});
test("Philippines QR-only fallback is labeled GCash / QR Ph and can switch to Stripe", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const payment = await registerPhilippinesPaymentMocks(page, "qr");
await preparePhilippinesUser(page);
await page.goto("/subscription?type=topup&character=elio");
await page.getByRole("button", { name: "Pay and Top Up" }).click();
const dialog = page.getByRole("dialog", {
name: "Pay with GCash / QR Ph",
});
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(/₱\s*499\.90/);
await expect(dialog).not.toContainText("QRIS");
await expect(
dialog.getByRole("img", { name: "GCash / QR Ph payment QR code" }),
).toBeVisible();
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
await page.getByRole("button", { name: "Stripe" }).click();
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(payment.getCreateOrderCount()).toBe(1);
});