chore(release): promote Indonesia QRIS to pre
Docker Image / Build and Push Docker Image (push) Failing after 3m46s
Docker Image / Build and Push Docker Image (push) Failing after 3m46s
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||
import { e2eEmailUser } from "@e2e/fixtures/data/user";
|
||||
import {
|
||||
clearBrowserState,
|
||||
seedEmailSession,
|
||||
} from "@e2e/fixtures/test-helpers";
|
||||
|
||||
const idrPlans = {
|
||||
plans: [
|
||||
{
|
||||
planId: "vip_monthly",
|
||||
planName: "Monthly",
|
||||
orderType: "vip_monthly",
|
||||
amountCents: 19_590_000,
|
||||
originalAmountCents: 19_590_000,
|
||||
dailyPriceCents: 653_000,
|
||||
currency: "IDR",
|
||||
vipDays: 30,
|
||||
dolAmount: null,
|
||||
creditBalance: 3_000,
|
||||
},
|
||||
{
|
||||
planId: "dol_1000",
|
||||
planName: "1,000 Credits",
|
||||
orderType: "dol",
|
||||
amountCents: 8_890_000,
|
||||
originalAmountCents: null,
|
||||
dailyPriceCents: null,
|
||||
currency: "IDR",
|
||||
vipDays: null,
|
||||
dolAmount: 1_000,
|
||||
creditBalance: 1_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const idrGiftCatalog = {
|
||||
characterId: "elio",
|
||||
categories: [
|
||||
{
|
||||
category: "coffee",
|
||||
name: "Coffee",
|
||||
productCount: 1,
|
||||
imageUrl: null,
|
||||
},
|
||||
],
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Velvet Espresso",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_small",
|
||||
category: "coffee",
|
||||
characterId: "elio",
|
||||
description: "Buy Elio a Velvet Espresso",
|
||||
imageUrl: null,
|
||||
amountCents: 8_929_900,
|
||||
currency: "IDR",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
promotionType: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function registerIndonesiaPaymentMocks(page: Page) {
|
||||
const orderStatuses = new Map<string, "pending" | "paid">();
|
||||
const orderPlans = new Map<string, { planId: string; orderType: string }>();
|
||||
|
||||
await page.route("**/api/user/profile", async (route) => {
|
||||
await route.fulfill({
|
||||
json: apiEnvelope({ ...e2eEmailUser, countryCode: "ID" }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/payment/plans**", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(idrPlans) });
|
||||
});
|
||||
await page.route("**/api/payment/gift-products**", async (route) => {
|
||||
await route.fulfill({ json: apiEnvelope(idrGiftCatalog) });
|
||||
});
|
||||
await page.route("**/api/payment/create-order", async (route) => {
|
||||
const body = route.request().postDataJSON() as { planId: string };
|
||||
const plan =
|
||||
idrPlans.plans.find((item) => item.planId === body.planId) ??
|
||||
idrGiftCatalog.plans.find((item) => item.planId === body.planId);
|
||||
const orderId = `order_qris_${body.planId}`;
|
||||
orderStatuses.set(orderId, "pending");
|
||||
orderPlans.set(orderId, {
|
||||
planId: body.planId,
|
||||
orderType: plan?.orderType ?? "dol",
|
||||
});
|
||||
await route.fulfill({
|
||||
json: apiEnvelope({
|
||||
orderId,
|
||||
payParams: {
|
||||
provider: "ezpay",
|
||||
countryCode: "ID",
|
||||
channelCode: "ID_QRIS_DYNAMIC_QR",
|
||||
channelType: "QR",
|
||||
payData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
firstChargeAmountCents: plan?.amountCents ?? 0,
|
||||
currency: "IDR",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/payment/order-status**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const orderId = url.searchParams.get("order_id") ?? "";
|
||||
const plan = orderPlans.get(orderId);
|
||||
await route.fulfill({
|
||||
json: apiEnvelope({
|
||||
orderId,
|
||||
status: orderStatuses.get(orderId) ?? "pending",
|
||||
orderType: plan?.orderType ?? "dol",
|
||||
planId: plan?.planId ?? null,
|
||||
creditsAdded: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/payment/tip-message", async (route) => {
|
||||
const body = route.request().postDataJSON() as { orderId: string };
|
||||
await route.fulfill({
|
||||
json: apiEnvelope({
|
||||
orderId: body.orderId,
|
||||
characterId: "elio",
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
productName: "Velvet Espresso",
|
||||
tipCount: 1,
|
||||
poolIndex: 0,
|
||||
message: "Thank you. Your gift made me smile.",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
markPaid(orderId: string) {
|
||||
orderStatuses.set(orderId, "paid");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareIndonesiaUser(page: Page) {
|
||||
await seedEmailSession(page);
|
||||
await page.evaluate(() => {
|
||||
const rawUser = localStorage.getItem("cozsweet:user");
|
||||
const user = rawUser ? JSON.parse(rawUser) : {};
|
||||
localStorage.setItem(
|
||||
"cozsweet:user",
|
||||
JSON.stringify({ ...user, countryCode: "ID" }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectQrisOrder(
|
||||
page: Page,
|
||||
buttonName: RegExp,
|
||||
planId: string,
|
||||
formattedAmount: RegExp,
|
||||
) {
|
||||
const requestPromise = page.waitForRequest("**/api/payment/create-order");
|
||||
await page.getByRole("button", { name: buttonName }).click();
|
||||
const request = await requestPromise;
|
||||
expect(request.postDataJSON()).toMatchObject({
|
||||
planId,
|
||||
payChannel: "ezpay",
|
||||
});
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText(formattedAmount);
|
||||
await expect(dialog).toContainText("Waiting for payment");
|
||||
await expect(
|
||||
dialog.getByRole("img", { name: "QRIS payment QR code" }),
|
||||
).toBeVisible();
|
||||
return `order_qris_${planId}`;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||
await clearBrowserState(context, page, baseURL);
|
||||
await mockCoreApis(page);
|
||||
});
|
||||
|
||||
test("Indonesia VIP defaults to QRIS and completes after status polling", async ({
|
||||
page,
|
||||
}) => {
|
||||
const payment = await registerIndonesiaPaymentMocks(page);
|
||||
await prepareIndonesiaUser(page);
|
||||
await page.goto("/subscription?type=vip");
|
||||
|
||||
await expect(page.getByRole("button", { name: "QRIS" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
const orderId = await expectQrisOrder(
|
||||
page,
|
||||
/Pay and Activ/i,
|
||||
"vip_monthly",
|
||||
/Rp\s*195[.,]900/,
|
||||
);
|
||||
payment.markPaid(orderId);
|
||||
|
||||
const success = page.getByRole("alertdialog", { name: "Payment successful" });
|
||||
await expect(success).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
||||
const payment = await registerIndonesiaPaymentMocks(page);
|
||||
await prepareIndonesiaUser(page);
|
||||
await page.goto("/subscription?type=topup");
|
||||
|
||||
const orderId = await expectQrisOrder(
|
||||
page,
|
||||
/Pay and Top Up/i,
|
||||
"dol_1000",
|
||||
/Rp\s*88[.,]900/,
|
||||
);
|
||||
payment.markPaid(orderId);
|
||||
|
||||
await expect(
|
||||
page.getByRole("alertdialog", { name: "Payment successful" }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow", async ({
|
||||
page,
|
||||
}) => {
|
||||
const payment = await registerIndonesiaPaymentMocks(page);
|
||||
await prepareIndonesiaUser(page);
|
||||
await page.goto("/characters/elio/tip");
|
||||
|
||||
await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible();
|
||||
const orderId = await expectQrisOrder(
|
||||
page,
|
||||
/Order and Buy/i,
|
||||
"tip_coffee_usd_4_99",
|
||||
/Rp\s*89[.,]299/,
|
||||
);
|
||||
payment.markPaid(orderId);
|
||||
|
||||
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: "Send another gift" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
|
||||
).toBeHidden();
|
||||
});
|
||||
@@ -50,6 +50,7 @@
|
||||
"next-auth": "^4.24.14",
|
||||
"ofetch": "^1.5.1",
|
||||
"pino": "^10.3.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
|
||||
Generated
+12
@@ -50,6 +50,9 @@ importers:
|
||||
pino:
|
||||
specifier: ^10.3.1
|
||||
version: 10.3.1
|
||||
qrcode.react:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0(react@19.2.4)
|
||||
react:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4
|
||||
@@ -3290,6 +3293,11 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qrcode.react@4.2.0:
|
||||
resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@@ -7085,6 +7093,10 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qrcode.react@4.2.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
@@ -6,10 +6,15 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
|
||||
const hiddenLaunch = {
|
||||
handleEzpayCancel: vi.fn(),
|
||||
handleEzpayConfirm: vi.fn(),
|
||||
handleQrisClose: vi.fn(),
|
||||
handleStripeClose: vi.fn(),
|
||||
handleStripeConfirmed: vi.fn(),
|
||||
isConfirmingEzpay: false,
|
||||
qrisErrorMessage: null,
|
||||
qrisPayment: null,
|
||||
qrisStatus: null,
|
||||
shouldShowEzpayConfirmDialog: false,
|
||||
shouldShowQrisDialog: false,
|
||||
shouldShowStripeDialog: false,
|
||||
stripeClientSecret: null,
|
||||
};
|
||||
@@ -43,10 +48,65 @@ describe("PaymentLaunchDialogs", () => {
|
||||
);
|
||||
|
||||
expect(html).toContain('role="alertdialog"');
|
||||
expect(html).toContain("Continue to GCash?");
|
||||
expect(html).toContain("Continue to payment?");
|
||||
expect(html).toContain("Your coffee order is ready.");
|
||||
expect(html).toContain("Order No. order-123");
|
||||
expect(html).toContain('data-analytics-key="tip.external_checkout"');
|
||||
expect(html).toContain("Opening...");
|
||||
});
|
||||
|
||||
it("renders an accessible QRIS dialog with display cents and order status", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PaymentLaunchDialogs
|
||||
currentOrderId="order-id-qris"
|
||||
externalCheckoutAnalyticsKey="tip.external_checkout"
|
||||
ezpayDescription="Scan QRIS to finish the payment."
|
||||
launch={{
|
||||
...hiddenLaunch,
|
||||
qrisPayment: {
|
||||
qrData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
orderId: "order-id-qris",
|
||||
amountCents: 5_000_000,
|
||||
currency: "IDR",
|
||||
},
|
||||
qrisStatus: "pending",
|
||||
shouldShowQrisDialog: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('role="dialog"');
|
||||
expect(html).toContain("Scan to pay with QRIS");
|
||||
expect(html).toContain("QRIS payment QR code");
|
||||
expect(html).toContain("Order No. order-id-qris");
|
||||
expect(html).toContain("Rp");
|
||||
expect(html).toContain("50.000");
|
||||
expect(html).toContain("Waiting for payment");
|
||||
});
|
||||
|
||||
it("shows QRIS failure state and handles empty QR data safely", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PaymentLaunchDialogs
|
||||
currentOrderId="order-id-qris"
|
||||
externalCheckoutAnalyticsKey="payment.external_checkout"
|
||||
ezpayDescription="Scan QRIS to finish the payment."
|
||||
launch={{
|
||||
...hiddenLaunch,
|
||||
qrisErrorMessage: "Payment failed or was cancelled.",
|
||||
qrisPayment: {
|
||||
qrData: "",
|
||||
orderId: "order-id-qris",
|
||||
amountCents: 5_000_000,
|
||||
currency: "IDR",
|
||||
},
|
||||
qrisStatus: "failed",
|
||||
shouldShowQrisDialog: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("QRIS data is unavailable");
|
||||
expect(html).toContain("Payment failed or was cancelled.");
|
||||
expect(html).toContain("Close");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,27 @@ describe("PaymentMethodSelector", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("labels Indonesia Ezpay as QRIS without the GCash logo", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PaymentMethodSelector
|
||||
config={{
|
||||
canChoosePaymentMethod: true,
|
||||
initialPayChannel: "ezpay",
|
||||
showPaymentMethodSelector: true,
|
||||
ezpayDisplayName: "QRIS",
|
||||
}}
|
||||
value="ezpay"
|
||||
caption="QRIS by default in Indonesia"
|
||||
analyticsKey="subscription.payment_method"
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('aria-label="QRIS"');
|
||||
expect(html).toContain("QRIS by default in Indonesia");
|
||||
expect(html).not.toContain("gcash-logo.svg");
|
||||
});
|
||||
|
||||
it("renders compact controls without the caption", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PaymentMethodSelector
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useId, type ReactNode } from "react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
|
||||
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||
|
||||
@@ -11,10 +12,15 @@ type PaymentLaunchDialogFlow = Pick<
|
||||
PaymentLaunchFlow,
|
||||
| "handleEzpayCancel"
|
||||
| "handleEzpayConfirm"
|
||||
| "handleQrisClose"
|
||||
| "handleStripeClose"
|
||||
| "handleStripeConfirmed"
|
||||
| "isConfirmingEzpay"
|
||||
| "qrisErrorMessage"
|
||||
| "qrisPayment"
|
||||
| "qrisStatus"
|
||||
| "shouldShowEzpayConfirmDialog"
|
||||
| "shouldShowQrisDialog"
|
||||
| "shouldShowStripeDialog"
|
||||
| "stripeClientSecret"
|
||||
>;
|
||||
@@ -46,6 +52,14 @@ export function PaymentLaunchDialogs({
|
||||
onConfirm={launch.handleEzpayConfirm}
|
||||
/>
|
||||
) : null}
|
||||
{launch.shouldShowQrisDialog && launch.qrisPayment ? (
|
||||
<QrisPaymentDialog
|
||||
payment={launch.qrisPayment}
|
||||
status={launch.qrisStatus}
|
||||
errorMessage={launch.qrisErrorMessage}
|
||||
onClose={launch.handleQrisClose}
|
||||
/>
|
||||
) : null}
|
||||
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
||||
<LazyStripePaymentDialog
|
||||
clientSecret={launch.stripeClientSecret}
|
||||
@@ -58,6 +72,102 @@ export function PaymentLaunchDialogs({
|
||||
);
|
||||
}
|
||||
|
||||
interface QrisPaymentDialogProps {
|
||||
errorMessage: string | null;
|
||||
onClose: () => void;
|
||||
payment: NonNullable<PaymentLaunchFlow["qrisPayment"]>;
|
||||
status: PaymentLaunchFlow["qrisStatus"];
|
||||
}
|
||||
|
||||
function formatQrisAmount(amountCents: number, currency: string): string {
|
||||
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
|
||||
try {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: normalizedCurrency,
|
||||
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
|
||||
}).format(amountCents / 100);
|
||||
} catch {
|
||||
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function qrisStatusMessage(
|
||||
status: PaymentLaunchFlow["qrisStatus"],
|
||||
errorMessage: string | null,
|
||||
): string {
|
||||
if (status === "failed") return errorMessage || "Payment failed. Please try again.";
|
||||
if (status === "expired") return errorMessage || "This QRIS order has expired.";
|
||||
return "Waiting for payment";
|
||||
}
|
||||
|
||||
function QrisPaymentDialog({
|
||||
errorMessage,
|
||||
onClose,
|
||||
payment,
|
||||
status,
|
||||
}: QrisPaymentDialogProps) {
|
||||
const titleId = useId();
|
||||
const statusMessage = qrisStatusMessage(status, errorMessage);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className={styles.dialog}>
|
||||
<div className={`${styles.header} text-center`}>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
Scan to pay with QRIS
|
||||
</h2>
|
||||
<p className={styles.content}>
|
||||
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="QRIS payment QR code"
|
||||
size={256}
|
||||
level="M"
|
||||
marginSize={2}
|
||||
className="h-auto w-full max-w-64"
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.error} role="alert">
|
||||
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">
|
||||
{formatQrisAmount(payment.amountCents, payment.currency)}
|
||||
</p>
|
||||
<p
|
||||
className={status === "failed" || status === "expired" ? styles.error : styles.content}
|
||||
role="status"
|
||||
>
|
||||
{statusMessage}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.button} ${styles.secondary}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EzpayRedirectConfirmDialogProps {
|
||||
description: ReactNode;
|
||||
externalCheckoutAnalyticsKey: string;
|
||||
@@ -87,7 +197,7 @@ function EzpayRedirectConfirmDialog({
|
||||
<div className={styles.dialog}>
|
||||
<div className={styles.header}>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
Continue to GCash?
|
||||
Continue to payment?
|
||||
</h2>
|
||||
<p className={styles.content}>{description}</p>
|
||||
<p className={styles.content}>Order No. {orderId}</p>
|
||||
|
||||
@@ -44,13 +44,24 @@ export function PaymentMethodSelector({
|
||||
}: PaymentMethodSelectorProps) {
|
||||
if (!config.showPaymentMethodSelector) return null;
|
||||
const isCompact = density === "compact";
|
||||
const ezpayDisplayName = config.ezpayDisplayName ?? "GCash";
|
||||
const configuredPaymentMethods = PAYMENT_METHODS.map((method) =>
|
||||
method.channel === "ezpay"
|
||||
? {
|
||||
...method,
|
||||
title: ezpayDisplayName,
|
||||
logoSrc:
|
||||
ezpayDisplayName === "GCash" ? method.logoSrc : undefined,
|
||||
}
|
||||
: method,
|
||||
);
|
||||
|
||||
const paymentMethods =
|
||||
config.initialPayChannel === "ezpay"
|
||||
? [...PAYMENT_METHODS].sort((a, b) =>
|
||||
? [...configuredPaymentMethods].sort((a, b) =>
|
||||
a.channel === "ezpay" ? -1 : b.channel === "ezpay" ? 1 : 0,
|
||||
)
|
||||
: PAYMENT_METHODS;
|
||||
: configuredPaymentMethods;
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getStripeClientSecret,
|
||||
isEzpayPayment,
|
||||
launchEzpayRedirect,
|
||||
resolveEzpayLaunchTarget,
|
||||
} from "@/lib/payment/payment_launch";
|
||||
import { behaviorAnalytics } from "@/lib/analytics";
|
||||
import type {
|
||||
@@ -46,15 +47,55 @@ export interface PaymentLaunchFlow {
|
||||
ezpayPaymentUrl: string | null;
|
||||
handleEzpayCancel: () => void;
|
||||
handleEzpayConfirm: () => void;
|
||||
handleQrisClose: () => void;
|
||||
handleStripeClose: () => void;
|
||||
handleStripeConfirmed: () => void;
|
||||
isConfirmingEzpay: boolean;
|
||||
qrisErrorMessage: string | null;
|
||||
qrisPayment: QrisPaymentDetails | null;
|
||||
qrisStatus: PaymentContextState["orderStatus"];
|
||||
resetLaunchState: () => void;
|
||||
shouldShowEzpayConfirmDialog: boolean;
|
||||
shouldShowQrisDialog: boolean;
|
||||
shouldShowStripeDialog: boolean;
|
||||
stripeClientSecret: string | null;
|
||||
}
|
||||
|
||||
export interface QrisPaymentDetails {
|
||||
amountCents: number;
|
||||
currency: string;
|
||||
orderId: string;
|
||||
qrData: string;
|
||||
}
|
||||
|
||||
function paymentParamString(
|
||||
payParams: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): string | null {
|
||||
for (const key of keys) {
|
||||
const value = payParams[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function paymentParamAmountCents(
|
||||
payParams: Record<string, unknown>,
|
||||
): number | null {
|
||||
for (const key of [
|
||||
"firstChargeAmountCents",
|
||||
"first_charge_amount_cents",
|
||||
"amountCents",
|
||||
"amount_cents",
|
||||
]) {
|
||||
const value = payParams[key];
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function trackPaymentCheckoutOpened(
|
||||
payment: PaymentContextState,
|
||||
checkoutUrl: string,
|
||||
@@ -118,11 +159,39 @@ export function usePaymentLaunchFlow({
|
||||
string | null
|
||||
>(null);
|
||||
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
|
||||
const [hiddenQrisOrderId, setHiddenQrisOrderId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const stripeClientSecret = payment.payParams
|
||||
? getStripeClientSecret(payment.payParams)
|
||||
: null;
|
||||
const ezpayPaymentUrl = payment.payParams
|
||||
? getPaymentUrl(payment.payParams)
|
||||
const ezpayLaunchTarget =
|
||||
payment.payParams && isEzpayPayment(payment.payParams)
|
||||
? resolveEzpayLaunchTarget(payment.payParams)
|
||||
: null;
|
||||
const ezpayPaymentUrl =
|
||||
ezpayLaunchTarget?.kind === "url"
|
||||
? ezpayLaunchTarget.paymentUrl
|
||||
: null;
|
||||
const selectedPlan = payment.plans.find(
|
||||
(item) => item.planId === payment.selectedPlanId,
|
||||
);
|
||||
const qrisPayment: QrisPaymentDetails | null =
|
||||
payment.payParams &&
|
||||
payment.currentOrderId &&
|
||||
ezpayLaunchTarget?.kind === "qr"
|
||||
? {
|
||||
amountCents:
|
||||
paymentParamAmountCents(payment.payParams) ??
|
||||
selectedPlan?.amountCents ??
|
||||
0,
|
||||
currency:
|
||||
paymentParamString(payment.payParams, "currency") ??
|
||||
selectedPlan?.currency ??
|
||||
"IDR",
|
||||
orderId: payment.currentOrderId,
|
||||
qrData: ezpayLaunchTarget.qrData,
|
||||
}
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
|
||||
@@ -136,11 +205,33 @@ export function usePaymentLaunchFlow({
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentUrl = getPaymentUrl(payment.payParams);
|
||||
if (paymentUrl) {
|
||||
const isEzpay = isEzpayPayment(payment.payParams);
|
||||
if (isEzpay) {
|
||||
const target = resolveEzpayLaunchTarget(payment.payParams);
|
||||
if (target.kind === "error") {
|
||||
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
|
||||
paymentDispatch({
|
||||
type: "PaymentLaunchFailed",
|
||||
errorMessage: target.errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AppEnvUtil.isProduction() && isEzpay) {
|
||||
if (target.kind === "qr") {
|
||||
if (!payment.currentOrderId) {
|
||||
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
|
||||
paymentDispatch({
|
||||
type: "PaymentLaunchFailed",
|
||||
errorMessage: "Missing order id before showing QRIS.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
trackPaymentCheckoutOpened(payment, "qris_embedded");
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentUrl = target.paymentUrl;
|
||||
if (!AppEnvUtil.isProduction()) {
|
||||
log.debug(`[${logScope}] ezpay confirmation required`, {
|
||||
orderId: payment.currentOrderId,
|
||||
paymentUrl,
|
||||
@@ -149,7 +240,6 @@ export function usePaymentLaunchFlow({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEzpay) {
|
||||
void launchEzpayRedirect({
|
||||
orderId: payment.currentOrderId,
|
||||
paymentUrl,
|
||||
@@ -167,6 +257,8 @@ export function usePaymentLaunchFlow({
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentUrl = getPaymentUrl(payment.payParams);
|
||||
if (paymentUrl) {
|
||||
try {
|
||||
window.location.href = paymentUrl;
|
||||
trackPaymentCheckoutOpened(payment, paymentUrl);
|
||||
@@ -213,10 +305,16 @@ export function usePaymentLaunchFlow({
|
||||
payParams: payment.payParams,
|
||||
paymentUrl: ezpayPaymentUrl,
|
||||
});
|
||||
const shouldShowQrisDialog = Boolean(
|
||||
qrisPayment &&
|
||||
qrisPayment.orderId !== hiddenQrisOrderId &&
|
||||
!payment.isPaid,
|
||||
);
|
||||
|
||||
function resetLaunchState(): void {
|
||||
setIsConfirmingEzpay(false);
|
||||
setHiddenStripeClientSecret(null);
|
||||
setHiddenQrisOrderId(null);
|
||||
}
|
||||
|
||||
function handleStripeClose(): void {
|
||||
@@ -263,15 +361,28 @@ export function usePaymentLaunchFlow({
|
||||
paymentDispatch({ type: "PaymentReset" });
|
||||
}
|
||||
|
||||
function handleQrisClose(): void {
|
||||
log.debug(`[${logScope}] qris dialog closed`, {
|
||||
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
|
||||
subscriptionType,
|
||||
});
|
||||
setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
|
||||
}
|
||||
|
||||
return {
|
||||
ezpayPaymentUrl,
|
||||
handleEzpayCancel,
|
||||
handleEzpayConfirm,
|
||||
handleQrisClose,
|
||||
handleStripeClose,
|
||||
handleStripeConfirmed,
|
||||
isConfirmingEzpay,
|
||||
qrisErrorMessage: payment.errorMessage,
|
||||
qrisPayment,
|
||||
qrisStatus: payment.orderStatus,
|
||||
resetLaunchState,
|
||||
shouldShowEzpayConfirmDialog,
|
||||
shouldShowQrisDialog,
|
||||
shouldShowStripeDialog,
|
||||
stripeClientSecret,
|
||||
};
|
||||
|
||||
@@ -95,7 +95,7 @@ export function SubscriptionCheckoutButton({
|
||||
<PaymentLaunchDialogs
|
||||
currentOrderId={payment.currentOrderId}
|
||||
externalCheckoutAnalyticsKey="subscription.external_checkout"
|
||||
ezpayDescription="Your order has been created. Continue to GCash to finish the payment."
|
||||
ezpayDescription="Your order has been created. Continue to the secure payment page to finish."
|
||||
launch={paymentLaunch}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type PaymentAnalyticsContext,
|
||||
} from "@/lib/analytics";
|
||||
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
|
||||
import { useHasHydrated } from "@/hooks/use-has-hydrated";
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
|
||||
import {
|
||||
@@ -55,11 +56,15 @@ export function SubscriptionScreen({
|
||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
}: SubscriptionScreenProps) {
|
||||
const userState = useUserState();
|
||||
const hasHydrated = useHasHydrated();
|
||||
const countryCode = userState.currentUser?.countryCode;
|
||||
const paymentMethodConfig = getPaymentMethodConfig({
|
||||
countryCode,
|
||||
requestedPayChannel: initialPayChannel,
|
||||
});
|
||||
const renderedPaymentMethodConfig = hasHydrated
|
||||
? paymentMethodConfig
|
||||
: { ...paymentMethodConfig, showPaymentMethodSelector: false };
|
||||
const {
|
||||
payment,
|
||||
paymentDispatch,
|
||||
@@ -225,10 +230,14 @@ export function SubscriptionScreen({
|
||||
</div>
|
||||
|
||||
<PaymentMethodSelector
|
||||
config={paymentMethodConfig}
|
||||
config={renderedPaymentMethodConfig}
|
||||
value={payment.payChannel}
|
||||
disabled={isPaymentBusy}
|
||||
caption="GCash by default in the Philippines"
|
||||
caption={
|
||||
paymentMethodConfig.ezpayDisplayName === "QRIS"
|
||||
? "QRIS by default in Indonesia"
|
||||
: "GCash by default in the Philippines"
|
||||
}
|
||||
className={styles.paymentMethodSlot}
|
||||
analyticsKey="subscription.payment_method"
|
||||
onChange={handlePaymentMethodChange}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function TipCheckoutButton({
|
||||
<PaymentLaunchDialogs
|
||||
currentOrderId={payment.currentOrderId}
|
||||
externalCheckoutAnalyticsKey="tip.external_checkout"
|
||||
ezpayDescription="Your coffee order is ready. Continue to GCash to finish the payment."
|
||||
ezpayDescription="Your gift order is ready. Continue to the secure payment page to finish."
|
||||
launch={paymentLaunch}
|
||||
stripeReturnPath={returnPath}
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type PaymentAnalyticsContext,
|
||||
} from "@/lib/analytics";
|
||||
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
|
||||
import { useHasHydrated } from "@/hooks/use-has-hydrated";
|
||||
import { buildTipGiftPath } from "@/lib/tip/tip_gift";
|
||||
import {
|
||||
useActiveCharacter,
|
||||
@@ -50,11 +51,15 @@ export function TipScreen({
|
||||
const character = useActiveCharacter();
|
||||
const characterRoutes = useActiveCharacterRoutes();
|
||||
const userState = useUserState();
|
||||
const hasHydrated = useHasHydrated();
|
||||
const supportPrompt = useTipSupportPrompt();
|
||||
const paymentMethodConfig = getPaymentMethodConfig({
|
||||
countryCode: userState.currentUser?.countryCode,
|
||||
requestedPayChannel: initialPayChannel,
|
||||
});
|
||||
const renderedPaymentMethodConfig = hasHydrated
|
||||
? paymentMethodConfig
|
||||
: { ...paymentMethodConfig, showPaymentMethodSelector: false };
|
||||
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
||||
catalog: "tip",
|
||||
characterId: character.id,
|
||||
@@ -281,7 +286,7 @@ export function TipScreen({
|
||||
</section>
|
||||
|
||||
<PaymentMethodSelector
|
||||
config={paymentMethodConfig}
|
||||
config={renderedPaymentMethodConfig}
|
||||
value={payment.payChannel}
|
||||
density="compact"
|
||||
disabled={isPaymentBusy}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getStripeClientSecret,
|
||||
isEzpayPayment,
|
||||
launchEzpayRedirect,
|
||||
resolveEzpayLaunchTarget,
|
||||
} from "../payment_launch";
|
||||
|
||||
describe("payment launch helpers", () => {
|
||||
@@ -42,6 +43,51 @@ describe("payment launch helpers", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => {
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({
|
||||
provider: "ezpay",
|
||||
channelType: "URL",
|
||||
payData: "https://pay.example/qris",
|
||||
}),
|
||||
).toEqual({ kind: "url", paymentUrl: "https://pay.example/qris" });
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({
|
||||
provider: "ezpay",
|
||||
channelType: "QR",
|
||||
payData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "qr",
|
||||
qrData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||
});
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({
|
||||
provider: "ezpay",
|
||||
channelType: "QR",
|
||||
payData: "",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "error",
|
||||
errorMessage: "QRIS payment data is missing. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects VA and unknown Ezpay channel types with explicit errors", () => {
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }),
|
||||
).toEqual({
|
||||
kind: "error",
|
||||
errorMessage: "Virtual account payments are not supported in this app.",
|
||||
});
|
||||
expect(
|
||||
resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "OTHER" }),
|
||||
).toEqual({
|
||||
kind: "error",
|
||||
errorMessage: "Unsupported Ezpay payment channel: OTHER.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a launch failure before redirecting without an order id", async () => {
|
||||
const onOpened = vi.fn();
|
||||
const onFailed = vi.fn();
|
||||
|
||||
@@ -12,18 +12,21 @@ describe("payment method rules", () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("defaults Philippines users to GCash and other countries to Stripe", () => {
|
||||
it("defaults Philippines and Indonesia users to their regional Ezpay method", () => {
|
||||
expect(getDefaultPayChannelForCountryCode("PH")).toBe("ezpay");
|
||||
expect(getDefaultPayChannelForCountryCode("ph")).toBe("ezpay");
|
||||
expect(getDefaultPayChannelForCountryCode("ID")).toBe("ezpay");
|
||||
expect(getDefaultPayChannelForCountryCode(" id ")).toBe("ezpay");
|
||||
expect(getDefaultPayChannelForCountryCode("HK")).toBe("stripe");
|
||||
expect(getDefaultPayChannelForCountryCode(null)).toBe("stripe");
|
||||
});
|
||||
|
||||
it("only allows Philippines users to choose in production", () => {
|
||||
it("allows Philippines and Indonesia users to choose in production", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
|
||||
|
||||
expect(canChoosePayChannel("PH")).toBe(true);
|
||||
expect(canChoosePayChannel("ph")).toBe(true);
|
||||
expect(canChoosePayChannel("ID")).toBe(true);
|
||||
expect(canChoosePayChannel("HK")).toBe(false);
|
||||
expect(canChoosePayChannel(null)).toBe(false);
|
||||
expect(
|
||||
@@ -35,6 +38,7 @@ describe("payment method rules", () => {
|
||||
canChoosePaymentMethod: false,
|
||||
initialPayChannel: "stripe",
|
||||
showPaymentMethodSelector: false,
|
||||
ezpayDisplayName: "GCash",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +56,25 @@ describe("payment method rules", () => {
|
||||
).toBe("stripe");
|
||||
});
|
||||
|
||||
it("defaults Indonesia to QRIS but permits an explicit Stripe choice", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
|
||||
|
||||
expect(
|
||||
getPaymentMethodConfig({
|
||||
countryCode: "ID",
|
||||
requestedPayChannel: null,
|
||||
}),
|
||||
).toEqual({
|
||||
canChoosePaymentMethod: true,
|
||||
initialPayChannel: "ezpay",
|
||||
showPaymentMethodSelector: true,
|
||||
ezpayDisplayName: "QRIS",
|
||||
});
|
||||
expect(
|
||||
resolvePayChannel({ countryCode: "ID", requestedPayChannel: "stripe" }),
|
||||
).toBe("stripe");
|
||||
});
|
||||
|
||||
it("keeps test channels but shows the selector only for Philippines", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
|
||||
|
||||
@@ -71,6 +94,7 @@ describe("payment method rules", () => {
|
||||
canChoosePaymentMethod: true,
|
||||
initialPayChannel: "ezpay",
|
||||
showPaymentMethodSelector: false,
|
||||
ezpayDisplayName: "GCash",
|
||||
});
|
||||
expect(
|
||||
getPaymentMethodConfig({
|
||||
|
||||
@@ -3,5 +3,8 @@ import type { PayChannel } from "@/data/schemas/payment";
|
||||
export function getDefaultPayChannelForCountryCode(
|
||||
countryCode: string | null | undefined,
|
||||
): PayChannel {
|
||||
return countryCode?.trim().toUpperCase() === "PH" ? "ezpay" : "stripe";
|
||||
const normalizedCountryCode = countryCode?.trim().toUpperCase();
|
||||
return normalizedCountryCode === "PH" || normalizedCountryCode === "ID"
|
||||
? "ezpay"
|
||||
: "stripe";
|
||||
}
|
||||
|
||||
@@ -11,6 +11,36 @@ import {
|
||||
|
||||
const log = new Logger("LibPaymentPaymentLaunch");
|
||||
|
||||
export type EzpayLaunchTarget =
|
||||
| { kind: "url"; paymentUrl: string }
|
||||
| { kind: "qr"; qrData: string }
|
||||
| { kind: "error"; errorMessage: string };
|
||||
|
||||
function getNonEmptyString(
|
||||
payParams: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): string | null {
|
||||
for (const key of keys) {
|
||||
const value = payParams[key];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getHttpPaymentUrl(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "https:" || parsed.protocol === "http:"
|
||||
? value
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPaymentUrl(payParams: Record<string, unknown>): string | null {
|
||||
const keys = [
|
||||
"cashierUrl",
|
||||
@@ -39,6 +69,61 @@ export function isEzpayPayment(payParams: Record<string, unknown>): boolean {
|
||||
return typeof provider === "string" && provider.toLowerCase() === "ezpay";
|
||||
}
|
||||
|
||||
export function resolveEzpayLaunchTarget(
|
||||
payParams: Record<string, unknown>,
|
||||
): EzpayLaunchTarget {
|
||||
const rawChannelType = getNonEmptyString(
|
||||
payParams,
|
||||
"channelType",
|
||||
"channel_type",
|
||||
);
|
||||
const channelType = rawChannelType?.toUpperCase() ?? null;
|
||||
const payData = getNonEmptyString(payParams, "payData", "pay_data");
|
||||
|
||||
if (channelType === "QR") {
|
||||
return payData
|
||||
? { kind: "qr", qrData: payData }
|
||||
: {
|
||||
kind: "error",
|
||||
errorMessage: "QRIS payment data is missing. Please try again.",
|
||||
};
|
||||
}
|
||||
|
||||
if (channelType === "VA") {
|
||||
return {
|
||||
kind: "error",
|
||||
errorMessage: "Virtual account payments are not supported in this app.",
|
||||
};
|
||||
}
|
||||
|
||||
if (channelType === "URL") {
|
||||
const paymentUrl =
|
||||
getHttpPaymentUrl(payData) ?? getHttpPaymentUrl(getPaymentUrl(payParams));
|
||||
return paymentUrl
|
||||
? { kind: "url", paymentUrl }
|
||||
: {
|
||||
kind: "error",
|
||||
errorMessage: "Ezpay payment URL is missing or invalid. Please try again.",
|
||||
};
|
||||
}
|
||||
|
||||
if (channelType) {
|
||||
return {
|
||||
kind: "error",
|
||||
errorMessage: `Unsupported Ezpay payment channel: ${channelType}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
|
||||
return legacyPaymentUrl
|
||||
? { kind: "url", paymentUrl: legacyPaymentUrl }
|
||||
: {
|
||||
kind: "error",
|
||||
errorMessage:
|
||||
"Ezpay payment parameters did not include a supported URL or QRIS code.",
|
||||
};
|
||||
}
|
||||
|
||||
export function getStripeClientSecret(
|
||||
payParams: Record<string, unknown>,
|
||||
): string | null {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getDefaultPayChannelForCountryCode } from "./default_pay_channel";
|
||||
|
||||
export interface PaymentMethodConfig {
|
||||
canChoosePaymentMethod: boolean;
|
||||
ezpayDisplayName?: "GCash" | "QRIS";
|
||||
initialPayChannel: PayChannel;
|
||||
showPaymentMethodSelector: boolean;
|
||||
}
|
||||
@@ -13,7 +14,7 @@ export function canChoosePayChannel(
|
||||
countryCode: string | null | undefined,
|
||||
): boolean {
|
||||
if (!AppEnvUtil.isProduction()) return true;
|
||||
return isPhilippinesCountryCode(countryCode);
|
||||
return isRegionalEzpayCountryCode(countryCode);
|
||||
}
|
||||
|
||||
export function resolvePayChannel(input: {
|
||||
@@ -33,13 +34,23 @@ export function getPaymentMethodConfig(input: {
|
||||
}): PaymentMethodConfig {
|
||||
return {
|
||||
canChoosePaymentMethod: canChoosePayChannel(input.countryCode),
|
||||
ezpayDisplayName: isIndonesiaCountryCode(input.countryCode)
|
||||
? "QRIS"
|
||||
: "GCash",
|
||||
initialPayChannel: resolvePayChannel(input),
|
||||
showPaymentMethodSelector: isPhilippinesCountryCode(input.countryCode),
|
||||
showPaymentMethodSelector: isRegionalEzpayCountryCode(input.countryCode),
|
||||
};
|
||||
}
|
||||
|
||||
function isPhilippinesCountryCode(
|
||||
function isRegionalEzpayCountryCode(
|
||||
countryCode: string | null | undefined,
|
||||
): boolean {
|
||||
return countryCode?.trim().toUpperCase() === "PH";
|
||||
const normalizedCountryCode = countryCode?.trim().toUpperCase();
|
||||
return normalizedCountryCode === "PH" || normalizedCountryCode === "ID";
|
||||
}
|
||||
|
||||
function isIndonesiaCountryCode(
|
||||
countryCode: string | null | undefined,
|
||||
): boolean {
|
||||
return countryCode?.trim().toUpperCase() === "ID";
|
||||
}
|
||||
|
||||
@@ -256,7 +256,10 @@ describe("payment order flow", () => {
|
||||
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
orderStatus: "expired",
|
||||
payParams: null,
|
||||
payParams: {
|
||||
provider: "stripe",
|
||||
clientSecret: "pi_test_secret_test",
|
||||
},
|
||||
errorMessage:
|
||||
"This payment order has expired. Please create a new order.",
|
||||
});
|
||||
|
||||
@@ -156,7 +156,6 @@ const applyFailedOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
|
||||
|
||||
const applyExpiredOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
|
||||
orderStatus: event.output.status,
|
||||
payParams: null,
|
||||
orderPollingStartedAt: null,
|
||||
errorMessage: "This payment order has expired. Please create a new order.",
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user