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",
|
"next-auth": "^4.24.14",
|
||||||
"ofetch": "^1.5.1",
|
"ofetch": "^1.5.1",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
|
|||||||
Generated
+12
@@ -50,6 +50,9 @@ importers:
|
|||||||
pino:
|
pino:
|
||||||
specifier: ^10.3.1
|
specifier: ^10.3.1
|
||||||
version: 10.3.1
|
version: 10.3.1
|
||||||
|
qrcode.react:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0(react@19.2.4)
|
||||||
react:
|
react:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
@@ -3290,6 +3293,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
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:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
@@ -7085,6 +7093,10 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
qrcode.react@4.2.0(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
quick-format-unescaped@4.0.4: {}
|
quick-format-unescaped@4.0.4: {}
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
|
|||||||
const hiddenLaunch = {
|
const hiddenLaunch = {
|
||||||
handleEzpayCancel: vi.fn(),
|
handleEzpayCancel: vi.fn(),
|
||||||
handleEzpayConfirm: vi.fn(),
|
handleEzpayConfirm: vi.fn(),
|
||||||
|
handleQrisClose: vi.fn(),
|
||||||
handleStripeClose: vi.fn(),
|
handleStripeClose: vi.fn(),
|
||||||
handleStripeConfirmed: vi.fn(),
|
handleStripeConfirmed: vi.fn(),
|
||||||
isConfirmingEzpay: false,
|
isConfirmingEzpay: false,
|
||||||
|
qrisErrorMessage: null,
|
||||||
|
qrisPayment: null,
|
||||||
|
qrisStatus: null,
|
||||||
shouldShowEzpayConfirmDialog: false,
|
shouldShowEzpayConfirmDialog: false,
|
||||||
|
shouldShowQrisDialog: false,
|
||||||
shouldShowStripeDialog: false,
|
shouldShowStripeDialog: false,
|
||||||
stripeClientSecret: null,
|
stripeClientSecret: null,
|
||||||
};
|
};
|
||||||
@@ -43,10 +48,65 @@ describe("PaymentLaunchDialogs", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain('role="alertdialog"');
|
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("Your coffee order is ready.");
|
||||||
expect(html).toContain("Order No. order-123");
|
expect(html).toContain("Order No. order-123");
|
||||||
expect(html).toContain('data-analytics-key="tip.external_checkout"');
|
expect(html).toContain('data-analytics-key="tip.external_checkout"');
|
||||||
expect(html).toContain("Opening...");
|
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", () => {
|
it("renders compact controls without the caption", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<PaymentMethodSelector
|
<PaymentMethodSelector
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useId, type ReactNode } from "react";
|
import { useId, type ReactNode } from "react";
|
||||||
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
|
||||||
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||||
|
|
||||||
@@ -11,10 +12,15 @@ type PaymentLaunchDialogFlow = Pick<
|
|||||||
PaymentLaunchFlow,
|
PaymentLaunchFlow,
|
||||||
| "handleEzpayCancel"
|
| "handleEzpayCancel"
|
||||||
| "handleEzpayConfirm"
|
| "handleEzpayConfirm"
|
||||||
|
| "handleQrisClose"
|
||||||
| "handleStripeClose"
|
| "handleStripeClose"
|
||||||
| "handleStripeConfirmed"
|
| "handleStripeConfirmed"
|
||||||
| "isConfirmingEzpay"
|
| "isConfirmingEzpay"
|
||||||
|
| "qrisErrorMessage"
|
||||||
|
| "qrisPayment"
|
||||||
|
| "qrisStatus"
|
||||||
| "shouldShowEzpayConfirmDialog"
|
| "shouldShowEzpayConfirmDialog"
|
||||||
|
| "shouldShowQrisDialog"
|
||||||
| "shouldShowStripeDialog"
|
| "shouldShowStripeDialog"
|
||||||
| "stripeClientSecret"
|
| "stripeClientSecret"
|
||||||
>;
|
>;
|
||||||
@@ -46,6 +52,14 @@ export function PaymentLaunchDialogs({
|
|||||||
onConfirm={launch.handleEzpayConfirm}
|
onConfirm={launch.handleEzpayConfirm}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{launch.shouldShowQrisDialog && launch.qrisPayment ? (
|
||||||
|
<QrisPaymentDialog
|
||||||
|
payment={launch.qrisPayment}
|
||||||
|
status={launch.qrisStatus}
|
||||||
|
errorMessage={launch.qrisErrorMessage}
|
||||||
|
onClose={launch.handleQrisClose}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
||||||
<LazyStripePaymentDialog
|
<LazyStripePaymentDialog
|
||||||
clientSecret={launch.stripeClientSecret}
|
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 {
|
interface EzpayRedirectConfirmDialogProps {
|
||||||
description: ReactNode;
|
description: ReactNode;
|
||||||
externalCheckoutAnalyticsKey: string;
|
externalCheckoutAnalyticsKey: string;
|
||||||
@@ -87,7 +197,7 @@ function EzpayRedirectConfirmDialog({
|
|||||||
<div className={styles.dialog}>
|
<div className={styles.dialog}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h2 id={titleId} className={styles.title}>
|
<h2 id={titleId} className={styles.title}>
|
||||||
Continue to GCash?
|
Continue to payment?
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.content}>{description}</p>
|
<p className={styles.content}>{description}</p>
|
||||||
<p className={styles.content}>Order No. {orderId}</p>
|
<p className={styles.content}>Order No. {orderId}</p>
|
||||||
|
|||||||
@@ -44,13 +44,24 @@ export function PaymentMethodSelector({
|
|||||||
}: PaymentMethodSelectorProps) {
|
}: PaymentMethodSelectorProps) {
|
||||||
if (!config.showPaymentMethodSelector) return null;
|
if (!config.showPaymentMethodSelector) return null;
|
||||||
const isCompact = density === "compact";
|
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 =
|
const paymentMethods =
|
||||||
config.initialPayChannel === "ezpay"
|
config.initialPayChannel === "ezpay"
|
||||||
? [...PAYMENT_METHODS].sort((a, b) =>
|
? [...configuredPaymentMethods].sort((a, b) =>
|
||||||
a.channel === "ezpay" ? -1 : b.channel === "ezpay" ? 1 : 0,
|
a.channel === "ezpay" ? -1 : b.channel === "ezpay" ? 1 : 0,
|
||||||
)
|
)
|
||||||
: PAYMENT_METHODS;
|
: configuredPaymentMethods;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getStripeClientSecret,
|
getStripeClientSecret,
|
||||||
isEzpayPayment,
|
isEzpayPayment,
|
||||||
launchEzpayRedirect,
|
launchEzpayRedirect,
|
||||||
|
resolveEzpayLaunchTarget,
|
||||||
} from "@/lib/payment/payment_launch";
|
} from "@/lib/payment/payment_launch";
|
||||||
import { behaviorAnalytics } from "@/lib/analytics";
|
import { behaviorAnalytics } from "@/lib/analytics";
|
||||||
import type {
|
import type {
|
||||||
@@ -46,15 +47,55 @@ export interface PaymentLaunchFlow {
|
|||||||
ezpayPaymentUrl: string | null;
|
ezpayPaymentUrl: string | null;
|
||||||
handleEzpayCancel: () => void;
|
handleEzpayCancel: () => void;
|
||||||
handleEzpayConfirm: () => void;
|
handleEzpayConfirm: () => void;
|
||||||
|
handleQrisClose: () => void;
|
||||||
handleStripeClose: () => void;
|
handleStripeClose: () => void;
|
||||||
handleStripeConfirmed: () => void;
|
handleStripeConfirmed: () => void;
|
||||||
isConfirmingEzpay: boolean;
|
isConfirmingEzpay: boolean;
|
||||||
|
qrisErrorMessage: string | null;
|
||||||
|
qrisPayment: QrisPaymentDetails | null;
|
||||||
|
qrisStatus: PaymentContextState["orderStatus"];
|
||||||
resetLaunchState: () => void;
|
resetLaunchState: () => void;
|
||||||
shouldShowEzpayConfirmDialog: boolean;
|
shouldShowEzpayConfirmDialog: boolean;
|
||||||
|
shouldShowQrisDialog: boolean;
|
||||||
shouldShowStripeDialog: boolean;
|
shouldShowStripeDialog: boolean;
|
||||||
stripeClientSecret: string | null;
|
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(
|
function trackPaymentCheckoutOpened(
|
||||||
payment: PaymentContextState,
|
payment: PaymentContextState,
|
||||||
checkoutUrl: string,
|
checkoutUrl: string,
|
||||||
@@ -118,11 +159,39 @@ export function usePaymentLaunchFlow({
|
|||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
|
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
|
||||||
|
const [hiddenQrisOrderId, setHiddenQrisOrderId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const stripeClientSecret = payment.payParams
|
const stripeClientSecret = payment.payParams
|
||||||
? getStripeClientSecret(payment.payParams)
|
? getStripeClientSecret(payment.payParams)
|
||||||
: null;
|
: null;
|
||||||
const ezpayPaymentUrl = payment.payParams
|
const ezpayLaunchTarget =
|
||||||
? getPaymentUrl(payment.payParams)
|
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;
|
: null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
|
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
|
||||||
@@ -136,11 +205,33 @@ export function usePaymentLaunchFlow({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentUrl = getPaymentUrl(payment.payParams);
|
|
||||||
if (paymentUrl) {
|
|
||||||
const isEzpay = isEzpayPayment(payment.payParams);
|
const isEzpay = isEzpayPayment(payment.payParams);
|
||||||
|
if (isEzpay) {
|
||||||
|
const target = resolveEzpayLaunchTarget(payment.payParams);
|
||||||
|
if (target.kind === "error") {
|
||||||
|
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
|
||||||
|
paymentDispatch({
|
||||||
|
type: "PaymentLaunchFailed",
|
||||||
|
errorMessage: target.errorMessage,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!AppEnvUtil.isProduction() && isEzpay) {
|
if (target.kind === "qr") {
|
||||||
|
if (!payment.currentOrderId) {
|
||||||
|
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
|
||||||
|
paymentDispatch({
|
||||||
|
type: "PaymentLaunchFailed",
|
||||||
|
errorMessage: "Missing order id before showing QRIS.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackPaymentCheckoutOpened(payment, "qris_embedded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentUrl = target.paymentUrl;
|
||||||
|
if (!AppEnvUtil.isProduction()) {
|
||||||
log.debug(`[${logScope}] ezpay confirmation required`, {
|
log.debug(`[${logScope}] ezpay confirmation required`, {
|
||||||
orderId: payment.currentOrderId,
|
orderId: payment.currentOrderId,
|
||||||
paymentUrl,
|
paymentUrl,
|
||||||
@@ -149,7 +240,6 @@ export function usePaymentLaunchFlow({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEzpay) {
|
|
||||||
void launchEzpayRedirect({
|
void launchEzpayRedirect({
|
||||||
orderId: payment.currentOrderId,
|
orderId: payment.currentOrderId,
|
||||||
paymentUrl,
|
paymentUrl,
|
||||||
@@ -167,6 +257,8 @@ export function usePaymentLaunchFlow({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paymentUrl = getPaymentUrl(payment.payParams);
|
||||||
|
if (paymentUrl) {
|
||||||
try {
|
try {
|
||||||
window.location.href = paymentUrl;
|
window.location.href = paymentUrl;
|
||||||
trackPaymentCheckoutOpened(payment, paymentUrl);
|
trackPaymentCheckoutOpened(payment, paymentUrl);
|
||||||
@@ -213,10 +305,16 @@ export function usePaymentLaunchFlow({
|
|||||||
payParams: payment.payParams,
|
payParams: payment.payParams,
|
||||||
paymentUrl: ezpayPaymentUrl,
|
paymentUrl: ezpayPaymentUrl,
|
||||||
});
|
});
|
||||||
|
const shouldShowQrisDialog = Boolean(
|
||||||
|
qrisPayment &&
|
||||||
|
qrisPayment.orderId !== hiddenQrisOrderId &&
|
||||||
|
!payment.isPaid,
|
||||||
|
);
|
||||||
|
|
||||||
function resetLaunchState(): void {
|
function resetLaunchState(): void {
|
||||||
setIsConfirmingEzpay(false);
|
setIsConfirmingEzpay(false);
|
||||||
setHiddenStripeClientSecret(null);
|
setHiddenStripeClientSecret(null);
|
||||||
|
setHiddenQrisOrderId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStripeClose(): void {
|
function handleStripeClose(): void {
|
||||||
@@ -263,15 +361,28 @@ export function usePaymentLaunchFlow({
|
|||||||
paymentDispatch({ type: "PaymentReset" });
|
paymentDispatch({ type: "PaymentReset" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleQrisClose(): void {
|
||||||
|
log.debug(`[${logScope}] qris dialog closed`, {
|
||||||
|
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
|
||||||
|
subscriptionType,
|
||||||
|
});
|
||||||
|
setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ezpayPaymentUrl,
|
ezpayPaymentUrl,
|
||||||
handleEzpayCancel,
|
handleEzpayCancel,
|
||||||
handleEzpayConfirm,
|
handleEzpayConfirm,
|
||||||
|
handleQrisClose,
|
||||||
handleStripeClose,
|
handleStripeClose,
|
||||||
handleStripeConfirmed,
|
handleStripeConfirmed,
|
||||||
isConfirmingEzpay,
|
isConfirmingEzpay,
|
||||||
|
qrisErrorMessage: payment.errorMessage,
|
||||||
|
qrisPayment,
|
||||||
|
qrisStatus: payment.orderStatus,
|
||||||
resetLaunchState,
|
resetLaunchState,
|
||||||
shouldShowEzpayConfirmDialog,
|
shouldShowEzpayConfirmDialog,
|
||||||
|
shouldShowQrisDialog,
|
||||||
shouldShowStripeDialog,
|
shouldShowStripeDialog,
|
||||||
stripeClientSecret,
|
stripeClientSecret,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function SubscriptionCheckoutButton({
|
|||||||
<PaymentLaunchDialogs
|
<PaymentLaunchDialogs
|
||||||
currentOrderId={payment.currentOrderId}
|
currentOrderId={payment.currentOrderId}
|
||||||
externalCheckoutAnalyticsKey="subscription.external_checkout"
|
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}
|
launch={paymentLaunch}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type PaymentAnalyticsContext,
|
type PaymentAnalyticsContext,
|
||||||
} from "@/lib/analytics";
|
} from "@/lib/analytics";
|
||||||
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
|
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
|
||||||
|
import { useHasHydrated } from "@/hooks/use-has-hydrated";
|
||||||
import { useUserState } from "@/stores/user/user-context";
|
import { useUserState } from "@/stores/user/user-context";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -55,11 +56,15 @@ export function SubscriptionScreen({
|
|||||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||||
}: SubscriptionScreenProps) {
|
}: SubscriptionScreenProps) {
|
||||||
const userState = useUserState();
|
const userState = useUserState();
|
||||||
|
const hasHydrated = useHasHydrated();
|
||||||
const countryCode = userState.currentUser?.countryCode;
|
const countryCode = userState.currentUser?.countryCode;
|
||||||
const paymentMethodConfig = getPaymentMethodConfig({
|
const paymentMethodConfig = getPaymentMethodConfig({
|
||||||
countryCode,
|
countryCode,
|
||||||
requestedPayChannel: initialPayChannel,
|
requestedPayChannel: initialPayChannel,
|
||||||
});
|
});
|
||||||
|
const renderedPaymentMethodConfig = hasHydrated
|
||||||
|
? paymentMethodConfig
|
||||||
|
: { ...paymentMethodConfig, showPaymentMethodSelector: false };
|
||||||
const {
|
const {
|
||||||
payment,
|
payment,
|
||||||
paymentDispatch,
|
paymentDispatch,
|
||||||
@@ -225,10 +230,14 @@ export function SubscriptionScreen({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PaymentMethodSelector
|
<PaymentMethodSelector
|
||||||
config={paymentMethodConfig}
|
config={renderedPaymentMethodConfig}
|
||||||
value={payment.payChannel}
|
value={payment.payChannel}
|
||||||
disabled={isPaymentBusy}
|
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}
|
className={styles.paymentMethodSlot}
|
||||||
analyticsKey="subscription.payment_method"
|
analyticsKey="subscription.payment_method"
|
||||||
onChange={handlePaymentMethodChange}
|
onChange={handlePaymentMethodChange}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function TipCheckoutButton({
|
|||||||
<PaymentLaunchDialogs
|
<PaymentLaunchDialogs
|
||||||
currentOrderId={payment.currentOrderId}
|
currentOrderId={payment.currentOrderId}
|
||||||
externalCheckoutAnalyticsKey="tip.external_checkout"
|
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}
|
launch={paymentLaunch}
|
||||||
stripeReturnPath={returnPath}
|
stripeReturnPath={returnPath}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type PaymentAnalyticsContext,
|
type PaymentAnalyticsContext,
|
||||||
} from "@/lib/analytics";
|
} from "@/lib/analytics";
|
||||||
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
|
import { getPaymentMethodConfig } from "@/lib/payment/payment_method";
|
||||||
|
import { useHasHydrated } from "@/hooks/use-has-hydrated";
|
||||||
import { buildTipGiftPath } from "@/lib/tip/tip_gift";
|
import { buildTipGiftPath } from "@/lib/tip/tip_gift";
|
||||||
import {
|
import {
|
||||||
useActiveCharacter,
|
useActiveCharacter,
|
||||||
@@ -50,11 +51,15 @@ export function TipScreen({
|
|||||||
const character = useActiveCharacter();
|
const character = useActiveCharacter();
|
||||||
const characterRoutes = useActiveCharacterRoutes();
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const userState = useUserState();
|
const userState = useUserState();
|
||||||
|
const hasHydrated = useHasHydrated();
|
||||||
const supportPrompt = useTipSupportPrompt();
|
const supportPrompt = useTipSupportPrompt();
|
||||||
const paymentMethodConfig = getPaymentMethodConfig({
|
const paymentMethodConfig = getPaymentMethodConfig({
|
||||||
countryCode: userState.currentUser?.countryCode,
|
countryCode: userState.currentUser?.countryCode,
|
||||||
requestedPayChannel: initialPayChannel,
|
requestedPayChannel: initialPayChannel,
|
||||||
});
|
});
|
||||||
|
const renderedPaymentMethodConfig = hasHydrated
|
||||||
|
? paymentMethodConfig
|
||||||
|
: { ...paymentMethodConfig, showPaymentMethodSelector: false };
|
||||||
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
const { payment, paymentDispatch } = usePaymentRouteFlow({
|
||||||
catalog: "tip",
|
catalog: "tip",
|
||||||
characterId: character.id,
|
characterId: character.id,
|
||||||
@@ -281,7 +286,7 @@ export function TipScreen({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<PaymentMethodSelector
|
<PaymentMethodSelector
|
||||||
config={paymentMethodConfig}
|
config={renderedPaymentMethodConfig}
|
||||||
value={payment.payChannel}
|
value={payment.payChannel}
|
||||||
density="compact"
|
density="compact"
|
||||||
disabled={isPaymentBusy}
|
disabled={isPaymentBusy}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getStripeClientSecret,
|
getStripeClientSecret,
|
||||||
isEzpayPayment,
|
isEzpayPayment,
|
||||||
launchEzpayRedirect,
|
launchEzpayRedirect,
|
||||||
|
resolveEzpayLaunchTarget,
|
||||||
} from "../payment_launch";
|
} from "../payment_launch";
|
||||||
|
|
||||||
describe("payment launch helpers", () => {
|
describe("payment launch helpers", () => {
|
||||||
@@ -42,6 +43,51 @@ describe("payment launch helpers", () => {
|
|||||||
).toBeNull();
|
).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 () => {
|
it("reports a launch failure before redirecting without an order id", async () => {
|
||||||
const onOpened = vi.fn();
|
const onOpened = vi.fn();
|
||||||
const onFailed = vi.fn();
|
const onFailed = vi.fn();
|
||||||
|
|||||||
@@ -12,18 +12,21 @@ describe("payment method rules", () => {
|
|||||||
vi.unstubAllEnvs();
|
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("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("HK")).toBe("stripe");
|
||||||
expect(getDefaultPayChannelForCountryCode(null)).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");
|
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
|
||||||
|
|
||||||
expect(canChoosePayChannel("PH")).toBe(true);
|
expect(canChoosePayChannel("PH")).toBe(true);
|
||||||
expect(canChoosePayChannel("ph")).toBe(true);
|
expect(canChoosePayChannel("ph")).toBe(true);
|
||||||
|
expect(canChoosePayChannel("ID")).toBe(true);
|
||||||
expect(canChoosePayChannel("HK")).toBe(false);
|
expect(canChoosePayChannel("HK")).toBe(false);
|
||||||
expect(canChoosePayChannel(null)).toBe(false);
|
expect(canChoosePayChannel(null)).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
@@ -35,6 +38,7 @@ describe("payment method rules", () => {
|
|||||||
canChoosePaymentMethod: false,
|
canChoosePaymentMethod: false,
|
||||||
initialPayChannel: "stripe",
|
initialPayChannel: "stripe",
|
||||||
showPaymentMethodSelector: false,
|
showPaymentMethodSelector: false,
|
||||||
|
ezpayDisplayName: "GCash",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,6 +56,25 @@ describe("payment method rules", () => {
|
|||||||
).toBe("stripe");
|
).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", () => {
|
it("keeps test channels but shows the selector only for Philippines", () => {
|
||||||
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
|
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
|
||||||
|
|
||||||
@@ -71,6 +94,7 @@ describe("payment method rules", () => {
|
|||||||
canChoosePaymentMethod: true,
|
canChoosePaymentMethod: true,
|
||||||
initialPayChannel: "ezpay",
|
initialPayChannel: "ezpay",
|
||||||
showPaymentMethodSelector: false,
|
showPaymentMethodSelector: false,
|
||||||
|
ezpayDisplayName: "GCash",
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
getPaymentMethodConfig({
|
getPaymentMethodConfig({
|
||||||
|
|||||||
@@ -3,5 +3,8 @@ import type { PayChannel } from "@/data/schemas/payment";
|
|||||||
export function getDefaultPayChannelForCountryCode(
|
export function getDefaultPayChannelForCountryCode(
|
||||||
countryCode: string | null | undefined,
|
countryCode: string | null | undefined,
|
||||||
): PayChannel {
|
): 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");
|
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 {
|
export function getPaymentUrl(payParams: Record<string, unknown>): string | null {
|
||||||
const keys = [
|
const keys = [
|
||||||
"cashierUrl",
|
"cashierUrl",
|
||||||
@@ -39,6 +69,61 @@ export function isEzpayPayment(payParams: Record<string, unknown>): boolean {
|
|||||||
return typeof provider === "string" && provider.toLowerCase() === "ezpay";
|
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(
|
export function getStripeClientSecret(
|
||||||
payParams: Record<string, unknown>,
|
payParams: Record<string, unknown>,
|
||||||
): string | null {
|
): string | null {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getDefaultPayChannelForCountryCode } from "./default_pay_channel";
|
|||||||
|
|
||||||
export interface PaymentMethodConfig {
|
export interface PaymentMethodConfig {
|
||||||
canChoosePaymentMethod: boolean;
|
canChoosePaymentMethod: boolean;
|
||||||
|
ezpayDisplayName?: "GCash" | "QRIS";
|
||||||
initialPayChannel: PayChannel;
|
initialPayChannel: PayChannel;
|
||||||
showPaymentMethodSelector: boolean;
|
showPaymentMethodSelector: boolean;
|
||||||
}
|
}
|
||||||
@@ -13,7 +14,7 @@ export function canChoosePayChannel(
|
|||||||
countryCode: string | null | undefined,
|
countryCode: string | null | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!AppEnvUtil.isProduction()) return true;
|
if (!AppEnvUtil.isProduction()) return true;
|
||||||
return isPhilippinesCountryCode(countryCode);
|
return isRegionalEzpayCountryCode(countryCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePayChannel(input: {
|
export function resolvePayChannel(input: {
|
||||||
@@ -33,13 +34,23 @@ export function getPaymentMethodConfig(input: {
|
|||||||
}): PaymentMethodConfig {
|
}): PaymentMethodConfig {
|
||||||
return {
|
return {
|
||||||
canChoosePaymentMethod: canChoosePayChannel(input.countryCode),
|
canChoosePaymentMethod: canChoosePayChannel(input.countryCode),
|
||||||
|
ezpayDisplayName: isIndonesiaCountryCode(input.countryCode)
|
||||||
|
? "QRIS"
|
||||||
|
: "GCash",
|
||||||
initialPayChannel: resolvePayChannel(input),
|
initialPayChannel: resolvePayChannel(input),
|
||||||
showPaymentMethodSelector: isPhilippinesCountryCode(input.countryCode),
|
showPaymentMethodSelector: isRegionalEzpayCountryCode(input.countryCode),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPhilippinesCountryCode(
|
function isRegionalEzpayCountryCode(
|
||||||
countryCode: string | null | undefined,
|
countryCode: string | null | undefined,
|
||||||
): boolean {
|
): 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({
|
expect(actor.getSnapshot().context).toMatchObject({
|
||||||
orderStatus: "expired",
|
orderStatus: "expired",
|
||||||
payParams: null,
|
payParams: {
|
||||||
|
provider: "stripe",
|
||||||
|
clientSecret: "pi_test_secret_test",
|
||||||
|
},
|
||||||
errorMessage:
|
errorMessage:
|
||||||
"This payment order has expired. Please create a new order.",
|
"This payment order has expired. Please create a new order.",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ const applyFailedOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
|
|||||||
|
|
||||||
const applyExpiredOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
|
const applyExpiredOrderAction = pollOrderDoneSetup.assign(({ event }) => ({
|
||||||
orderStatus: event.output.status,
|
orderStatus: event.output.status,
|
||||||
payParams: null,
|
|
||||||
orderPollingStartedAt: null,
|
orderPollingStartedAt: null,
|
||||||
errorMessage: "This payment order has expired. Please create a new order.",
|
errorMessage: "This payment order has expired. Please create a new order.",
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user