fix(payment): route Philippine GCash separately from QRIS
Docker Image / Build and Push Docker Image (push) Successful in 2m7s
Docker Image / Build and Push Docker Image (push) Successful in 2m7s
This commit is contained in:
@@ -266,14 +266,14 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => {
|
|||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("closing QRIS restores a resumable checkout without creating a duplicate order", async ({
|
test("closing QRIS restores payment selection and allows switching to Stripe", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const payment = await registerIndonesiaPaymentMocks(page);
|
const payment = await registerIndonesiaPaymentMocks(page);
|
||||||
await prepareIndonesiaUser(page);
|
await prepareIndonesiaUser(page);
|
||||||
await page.goto("/subscription?type=topup&character=elio");
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
|
||||||
const orderId = await expectQrisOrder(
|
await expectQrisOrder(
|
||||||
page,
|
page,
|
||||||
/Pay and Top Up/i,
|
/Pay and Top Up/i,
|
||||||
"dol_1000",
|
"dol_1000",
|
||||||
@@ -283,20 +283,14 @@ test("closing QRIS restores a resumable checkout without creating a duplicate or
|
|||||||
await dialog.getByRole("button", { name: "Close" }).click();
|
await dialog.getByRole("button", { name: "Close" }).click();
|
||||||
|
|
||||||
await expect(dialog).toBeHidden();
|
await expect(dialog).toBeHidden();
|
||||||
const resumeButton = page.getByRole("button", {
|
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
|
||||||
name: "Resume QRIS payment",
|
await page.getByRole("button", { name: "Stripe" }).click();
|
||||||
});
|
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
|
||||||
await expect(resumeButton).toBeEnabled();
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("button", { name: "Pay and Top Up" })).toBeEnabled();
|
||||||
expect(payment.getCreateOrderCount()).toBe(1);
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
|
||||||
await resumeButton.click();
|
|
||||||
await expect(dialog).toBeVisible();
|
|
||||||
expect(payment.getCreateOrderCount()).toBe(1);
|
|
||||||
|
|
||||||
payment.markPaid(orderId);
|
|
||||||
await expect(
|
|
||||||
page.getByRole("alertdialog", { name: "Payment successful" }),
|
|
||||||
).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("payment issue submits Other to the feedback API without creating an order", async ({
|
test("payment issue submits Other to the feedback API without creating an order", async ({
|
||||||
@@ -350,7 +344,7 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
|
|||||||
await page.goto("/characters/elio/tip");
|
await page.goto("/characters/elio/tip");
|
||||||
|
|
||||||
await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible();
|
await expect(page.getByText(/IDR\s*89[.,]299/)).toBeVisible();
|
||||||
const orderId = await expectQrisOrder(
|
await expectQrisOrder(
|
||||||
page,
|
page,
|
||||||
/Order and Buy/i,
|
/Order and Buy/i,
|
||||||
"tip_coffee_usd_4_99",
|
"tip_coffee_usd_4_99",
|
||||||
@@ -360,19 +354,11 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
|
|||||||
await dialog.getByRole("button", { name: "Close" }).click();
|
await dialog.getByRole("button", { name: "Close" }).click();
|
||||||
await expect(dialog).toBeHidden();
|
await expect(dialog).toBeHidden();
|
||||||
|
|
||||||
const resumeButton = page.getByRole("button", {
|
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
|
||||||
name: "Resume QRIS payment",
|
await page.getByRole("button", { name: "Stripe" }).click();
|
||||||
});
|
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
|
||||||
await expect(resumeButton).toBeEnabled();
|
"aria-pressed",
|
||||||
await resumeButton.click();
|
"true",
|
||||||
await expect(dialog).toBeVisible();
|
);
|
||||||
expect(payment.getCreateOrderCount()).toBe(1);
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
|
||||||
payment.markPaid(orderId);
|
|
||||||
|
|
||||||
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expect(page.getByRole("button", { name: "Send another gift" })).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
|
|
||||||
).toBeHidden();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import { e2eEmailUser } from "@e2e/fixtures/data/user";
|
||||||
|
import {
|
||||||
|
clearBrowserState,
|
||||||
|
seedEmailSession,
|
||||||
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
|
const phpPlans = {
|
||||||
|
plans: [
|
||||||
|
{
|
||||||
|
planId: "dol_1000",
|
||||||
|
planName: "1,000 Credits",
|
||||||
|
orderType: "dol",
|
||||||
|
amountCents: 49_990,
|
||||||
|
originalAmountCents: 74_990,
|
||||||
|
dailyPriceCents: null,
|
||||||
|
currency: "PHP",
|
||||||
|
vipDays: null,
|
||||||
|
dolAmount: 1_000,
|
||||||
|
creditBalance: 1_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function registerPhilippinesPaymentMocks(
|
||||||
|
page: Page,
|
||||||
|
response: "hosted" | "qr",
|
||||||
|
) {
|
||||||
|
let createOrderCount = 0;
|
||||||
|
|
||||||
|
await page.route("**/api/user/profile", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({ ...e2eEmailUser, countryCode: "PH" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/payment/plans**", async (route) => {
|
||||||
|
await route.fulfill({ json: apiEnvelope(phpPlans) });
|
||||||
|
});
|
||||||
|
await page.route("**/api/payment/create-order", async (route) => {
|
||||||
|
createOrderCount += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
orderId: `order_gcash_${response}`,
|
||||||
|
payParams: {
|
||||||
|
provider: "ezpay",
|
||||||
|
countryCode: "PH",
|
||||||
|
channelCode: "PH_QRPH_DYNAMIC",
|
||||||
|
channelType: "QR",
|
||||||
|
payData: "000201010212ph-qr-payload",
|
||||||
|
...(response === "hosted"
|
||||||
|
? { cashierUrl: "https://pay.example/gcash-hosted" }
|
||||||
|
: {}),
|
||||||
|
firstChargeAmountCents: 49_990,
|
||||||
|
currency: "PHP",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/payment/order-status**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: apiEnvelope({
|
||||||
|
orderId: `order_gcash_${response}`,
|
||||||
|
status: "pending",
|
||||||
|
orderType: "dol",
|
||||||
|
planId: "dol_1000",
|
||||||
|
creditsAdded: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCreateOrderCount: () => createOrderCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preparePhilippinesUser(page: Page) {
|
||||||
|
await seedEmailSession(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const rawUser = localStorage.getItem("cozsweet:user");
|
||||||
|
const user = rawUser ? JSON.parse(rawUser) : {};
|
||||||
|
localStorage.setItem(
|
||||||
|
"cozsweet:user",
|
||||||
|
JSON.stringify({ ...user, countryCode: "PH" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
await clearBrowserState(context, page, baseURL);
|
||||||
|
await mockCoreApis(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Philippines checkout prefers the hosted GCash URL even for a QR response", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const payment = await registerPhilippinesPaymentMocks(page, "hosted");
|
||||||
|
await preparePhilippinesUser(page);
|
||||||
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "GCash" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Pay and Top Up" }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("alertdialog", { name: "Continue to payment?" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "Pay with GCash / QR Ph" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog", { name: "Scan to pay with QRIS" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Philippines QR-only fallback is labeled GCash / QR Ph and can switch to Stripe", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const payment = await registerPhilippinesPaymentMocks(page, "qr");
|
||||||
|
await preparePhilippinesUser(page);
|
||||||
|
await page.goto("/subscription?type=topup&character=elio");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Pay and Top Up" }).click();
|
||||||
|
const dialog = page.getByRole("dialog", {
|
||||||
|
name: "Pay with GCash / QR Ph",
|
||||||
|
});
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toContainText(/₱\s*499\.90/);
|
||||||
|
await expect(dialog).not.toContainText("QRIS");
|
||||||
|
await expect(
|
||||||
|
dialog.getByRole("img", { name: "GCash / QR Ph payment QR code" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: "Close" }).click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
await expect(page.getByRole("button", { name: "Stripe" })).toBeEnabled();
|
||||||
|
await page.getByRole("button", { name: "Stripe" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Stripe" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(payment.getCreateOrderCount()).toBe(1);
|
||||||
|
});
|
||||||
@@ -7,15 +7,15 @@ import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
|
|||||||
const hiddenLaunch = {
|
const hiddenLaunch = {
|
||||||
handleEzpayCancel: vi.fn(),
|
handleEzpayCancel: vi.fn(),
|
||||||
handleEzpayConfirm: vi.fn(),
|
handleEzpayConfirm: vi.fn(),
|
||||||
handleQrisClose: vi.fn(),
|
handleRegionalQrClose: vi.fn(),
|
||||||
handleStripeClose: vi.fn(),
|
handleStripeClose: vi.fn(),
|
||||||
handleStripeConfirmed: vi.fn(),
|
handleStripeConfirmed: vi.fn(),
|
||||||
isConfirmingEzpay: false,
|
isConfirmingEzpay: false,
|
||||||
qrisErrorMessage: null,
|
regionalQrErrorMessage: null,
|
||||||
qrisPayment: null,
|
regionalQrPayment: null,
|
||||||
qrisStatus: null,
|
regionalQrStatus: null,
|
||||||
shouldShowEzpayConfirmDialog: false,
|
shouldShowEzpayConfirmDialog: false,
|
||||||
shouldShowQrisDialog: false,
|
shouldShowRegionalQrDialog: false,
|
||||||
shouldShowStripeDialog: false,
|
shouldShowStripeDialog: false,
|
||||||
stripeClientSecret: null,
|
stripeClientSecret: null,
|
||||||
stripeCustomerSessionClientSecret: null,
|
stripeCustomerSessionClientSecret: null,
|
||||||
@@ -98,14 +98,15 @@ describe("PaymentLaunchDialogs", () => {
|
|||||||
ezpayDescription="Scan QRIS to finish the payment."
|
ezpayDescription="Scan QRIS to finish the payment."
|
||||||
launch={{
|
launch={{
|
||||||
...hiddenLaunch,
|
...hiddenLaunch,
|
||||||
qrisPayment: {
|
regionalQrPayment: {
|
||||||
qrData: "00020101021226670016COM.NOBUBANK.WWW",
|
qrData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||||
orderId: "order-id-qris",
|
orderId: "order-id-qris",
|
||||||
amountCents: 5_000_000,
|
amountCents: 5_000_000,
|
||||||
currency: "IDR",
|
currency: "IDR",
|
||||||
|
experience: "qris",
|
||||||
},
|
},
|
||||||
qrisStatus: "pending",
|
regionalQrStatus: "pending",
|
||||||
shouldShowQrisDialog: true,
|
shouldShowRegionalQrDialog: true,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
@@ -124,6 +125,44 @@ describe("PaymentLaunchDialogs", () => {
|
|||||||
expect(dialog?.textContent).toContain("Waiting for payment");
|
expect(dialog?.textContent).toContain("Waiting for payment");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels a Philippine QR fallback as GCash / QR Ph", () => {
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
<PaymentLaunchDialogs
|
||||||
|
currentOrderId="order-ph-qr"
|
||||||
|
externalCheckoutAnalyticsKey="payment.external_checkout"
|
||||||
|
ezpayDescription="Pay with GCash."
|
||||||
|
launch={{
|
||||||
|
...hiddenLaunch,
|
||||||
|
regionalQrPayment: {
|
||||||
|
qrData: "000201010212ph-qr-payload",
|
||||||
|
orderId: "order-ph-qr",
|
||||||
|
amountCents: 49_990,
|
||||||
|
currency: "PHP",
|
||||||
|
experience: "gcashQrPh",
|
||||||
|
},
|
||||||
|
regionalQrStatus: "pending",
|
||||||
|
shouldShowRegionalQrDialog: true,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = document.body.querySelector('[role="dialog"]');
|
||||||
|
expect(dialog?.textContent).toContain("Pay with GCash / QR Ph");
|
||||||
|
expect(dialog?.textContent).not.toContain("QRIS");
|
||||||
|
expect(dialog?.querySelector("svg title")?.textContent).toBe(
|
||||||
|
"GCash / QR Ph payment QR code",
|
||||||
|
);
|
||||||
|
expect(dialog?.textContent).toContain("₱");
|
||||||
|
|
||||||
|
const closeButton = Array.from(
|
||||||
|
dialog?.querySelectorAll("button") ?? [],
|
||||||
|
).find((button) => button.textContent?.trim() === "Close");
|
||||||
|
act(() => closeButton?.click());
|
||||||
|
expect(hiddenLaunch.handleRegionalQrClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows QRIS failure state and handles empty QR data safely", () => {
|
it("shows QRIS failure state and handles empty QR data safely", () => {
|
||||||
act(() =>
|
act(() =>
|
||||||
root.render(
|
root.render(
|
||||||
@@ -133,15 +172,16 @@ describe("PaymentLaunchDialogs", () => {
|
|||||||
ezpayDescription="Scan QRIS to finish the payment."
|
ezpayDescription="Scan QRIS to finish the payment."
|
||||||
launch={{
|
launch={{
|
||||||
...hiddenLaunch,
|
...hiddenLaunch,
|
||||||
qrisErrorMessage: "Payment failed or was cancelled.",
|
regionalQrErrorMessage: "Payment failed or was cancelled.",
|
||||||
qrisPayment: {
|
regionalQrPayment: {
|
||||||
qrData: "",
|
qrData: "",
|
||||||
orderId: "order-id-qris",
|
orderId: "order-id-qris",
|
||||||
amountCents: 5_000_000,
|
amountCents: 5_000_000,
|
||||||
currency: "IDR",
|
currency: "IDR",
|
||||||
|
experience: "qris",
|
||||||
},
|
},
|
||||||
qrisStatus: "failed",
|
regionalQrStatus: "failed",
|
||||||
shouldShowQrisDialog: true,
|
shouldShowRegionalQrDialog: true,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ type PaymentLaunchDialogFlow = Pick<
|
|||||||
PaymentLaunchFlow,
|
PaymentLaunchFlow,
|
||||||
| "handleEzpayCancel"
|
| "handleEzpayCancel"
|
||||||
| "handleEzpayConfirm"
|
| "handleEzpayConfirm"
|
||||||
| "handleQrisClose"
|
| "handleRegionalQrClose"
|
||||||
| "handleStripeClose"
|
| "handleStripeClose"
|
||||||
| "handleStripeConfirmed"
|
| "handleStripeConfirmed"
|
||||||
| "isConfirmingEzpay"
|
| "isConfirmingEzpay"
|
||||||
| "qrisErrorMessage"
|
| "regionalQrErrorMessage"
|
||||||
| "qrisPayment"
|
| "regionalQrPayment"
|
||||||
| "qrisStatus"
|
| "regionalQrStatus"
|
||||||
| "shouldShowEzpayConfirmDialog"
|
| "shouldShowEzpayConfirmDialog"
|
||||||
| "shouldShowQrisDialog"
|
| "shouldShowRegionalQrDialog"
|
||||||
| "shouldShowStripeDialog"
|
| "shouldShowStripeDialog"
|
||||||
| "stripeClientSecret"
|
| "stripeClientSecret"
|
||||||
| "stripeCustomerSessionClientSecret"
|
| "stripeCustomerSessionClientSecret"
|
||||||
@@ -55,12 +55,12 @@ export function PaymentLaunchDialogs({
|
|||||||
onConfirm={launch.handleEzpayConfirm}
|
onConfirm={launch.handleEzpayConfirm}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{launch.shouldShowQrisDialog && launch.qrisPayment ? (
|
{launch.shouldShowRegionalQrDialog && launch.regionalQrPayment ? (
|
||||||
<QrisPaymentDialog
|
<RegionalQrPaymentDialog
|
||||||
payment={launch.qrisPayment}
|
payment={launch.regionalQrPayment}
|
||||||
status={launch.qrisStatus}
|
status={launch.regionalQrStatus}
|
||||||
errorMessage={launch.qrisErrorMessage}
|
errorMessage={launch.regionalQrErrorMessage}
|
||||||
onClose={launch.handleQrisClose}
|
onClose={launch.handleRegionalQrClose}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
{launch.shouldShowStripeDialog && launch.stripeClientSecret ? (
|
||||||
@@ -79,43 +79,56 @@ export function PaymentLaunchDialogs({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QrisPaymentDialogProps {
|
interface RegionalQrPaymentDialogProps {
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
payment: NonNullable<PaymentLaunchFlow["qrisPayment"]>;
|
payment: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>;
|
||||||
status: PaymentLaunchFlow["qrisStatus"];
|
status: PaymentLaunchFlow["regionalQrStatus"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatQrisAmount(amountCents: number, currency: string): string {
|
function formatRegionalQrAmount(amountCents: number, currency: string): string {
|
||||||
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
|
const normalizedCurrency = currency.trim().toUpperCase() || "IDR";
|
||||||
try {
|
try {
|
||||||
return new Intl.NumberFormat("id-ID", {
|
return new Intl.NumberFormat(
|
||||||
|
normalizedCurrency === "PHP" ? "en-PH" : "id-ID",
|
||||||
|
{
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: normalizedCurrency,
|
currency: normalizedCurrency,
|
||||||
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
|
maximumFractionDigits: normalizedCurrency === "IDR" ? 0 : 2,
|
||||||
}).format(amountCents / 100);
|
},
|
||||||
|
).format(amountCents / 100);
|
||||||
} catch {
|
} catch {
|
||||||
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
|
return `${normalizedCurrency} ${(amountCents / 100).toFixed(2)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function qrisStatusMessage(
|
function regionalQrStatusMessage(
|
||||||
status: PaymentLaunchFlow["qrisStatus"],
|
status: PaymentLaunchFlow["regionalQrStatus"],
|
||||||
errorMessage: string | null,
|
errorMessage: string | null,
|
||||||
|
paymentName: string,
|
||||||
): string {
|
): string {
|
||||||
if (status === "failed") return errorMessage || "Payment failed. Please try again.";
|
if (status === "failed") {
|
||||||
if (status === "expired") return errorMessage || "This QRIS order has expired.";
|
return errorMessage || "Payment failed. Please try again.";
|
||||||
|
}
|
||||||
|
if (status === "expired") {
|
||||||
|
return errorMessage || `This ${paymentName} order has expired.`;
|
||||||
|
}
|
||||||
return "Waiting for payment";
|
return "Waiting for payment";
|
||||||
}
|
}
|
||||||
|
|
||||||
function QrisPaymentDialog({
|
function RegionalQrPaymentDialog({
|
||||||
errorMessage,
|
errorMessage,
|
||||||
onClose,
|
onClose,
|
||||||
payment,
|
payment,
|
||||||
status,
|
status,
|
||||||
}: QrisPaymentDialogProps) {
|
}: RegionalQrPaymentDialogProps) {
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const statusMessage = qrisStatusMessage(status, errorMessage);
|
const copy = regionalQrCopy(payment.experience);
|
||||||
|
const statusMessage = regionalQrStatusMessage(
|
||||||
|
status,
|
||||||
|
errorMessage,
|
||||||
|
copy.paymentName,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalPortal
|
<ModalPortal
|
||||||
@@ -129,17 +142,17 @@ function QrisPaymentDialog({
|
|||||||
>
|
>
|
||||||
<div className={`${styles.header} text-center`}>
|
<div className={`${styles.header} text-center`}>
|
||||||
<h2 id={titleId} className={styles.title}>
|
<h2 id={titleId} className={styles.title}>
|
||||||
Scan to pay with QRIS
|
{copy.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.content}>
|
<p className={styles.content}>
|
||||||
Open a QRIS-compatible banking or wallet app and scan this code.
|
{copy.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4">
|
<div className="mb-4 flex min-h-64 items-center justify-center rounded-2xl bg-white p-4">
|
||||||
{payment.qrData ? (
|
{payment.qrData ? (
|
||||||
<QRCodeSVG
|
<QRCodeSVG
|
||||||
value={payment.qrData}
|
value={payment.qrData}
|
||||||
title="QRIS payment QR code"
|
title={copy.qrTitle}
|
||||||
size={256}
|
size={256}
|
||||||
level="M"
|
level="M"
|
||||||
marginSize={2}
|
marginSize={2}
|
||||||
@@ -147,14 +160,14 @@ function QrisPaymentDialog({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className={styles.error} role="alert">
|
<p className={styles.error} role="alert">
|
||||||
QRIS data is unavailable. Please close this dialog and try again.
|
{copy.unavailableMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 flex flex-col gap-2 text-center">
|
<div className="mb-4 flex flex-col gap-2 text-center">
|
||||||
<p className={styles.content}>Order No. {payment.orderId}</p>
|
<p className={styles.content}>Order No. {payment.orderId}</p>
|
||||||
<p className="m-0 text-xl font-bold text-text-foreground">
|
<p className="m-0 text-xl font-bold text-text-foreground">
|
||||||
{formatQrisAmount(payment.amountCents, payment.currency)}
|
{formatRegionalQrAmount(payment.amountCents, payment.currency)}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className={status === "failed" || status === "expired" ? styles.error : styles.content}
|
className={status === "failed" || status === "expired" ? styles.error : styles.content}
|
||||||
@@ -176,6 +189,41 @@ function QrisPaymentDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function regionalQrCopy(
|
||||||
|
experience: NonNullable<PaymentLaunchFlow["regionalQrPayment"]>["experience"],
|
||||||
|
) {
|
||||||
|
if (experience === "gcashQrPh") {
|
||||||
|
return {
|
||||||
|
paymentName: "GCash / QR Ph",
|
||||||
|
title: "Pay with GCash / QR Ph",
|
||||||
|
description:
|
||||||
|
"Open GCash or another QR Ph-compatible app and scan this code.",
|
||||||
|
qrTitle: "GCash / QR Ph payment QR code",
|
||||||
|
unavailableMessage:
|
||||||
|
"GCash / QR Ph data is unavailable. Please close this dialog and try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (experience === "qris") {
|
||||||
|
return {
|
||||||
|
paymentName: "QRIS",
|
||||||
|
title: "Scan to pay with QRIS",
|
||||||
|
description:
|
||||||
|
"Open a QRIS-compatible banking or wallet app and scan this code.",
|
||||||
|
qrTitle: "QRIS payment QR code",
|
||||||
|
unavailableMessage:
|
||||||
|
"QRIS data is unavailable. Please close this dialog and try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
paymentName: "payment QR",
|
||||||
|
title: "Scan to pay",
|
||||||
|
description: "Open a compatible banking or wallet app and scan this code.",
|
||||||
|
qrTitle: "Payment QR code",
|
||||||
|
unavailableMessage:
|
||||||
|
"Payment QR data is unavailable. Please close this dialog and try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface EzpayRedirectConfirmDialogProps {
|
interface EzpayRedirectConfirmDialogProps {
|
||||||
description: ReactNode;
|
description: ReactNode;
|
||||||
externalCheckoutAnalyticsKey: string;
|
externalCheckoutAnalyticsKey: string;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type Dispatch, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getPaymentUrl,
|
getPaymentUrl,
|
||||||
|
getPaymentUrlHostname,
|
||||||
getStripeCustomerSessionClientSecret,
|
getStripeCustomerSessionClientSecret,
|
||||||
getStripeClientSecret,
|
getStripeClientSecret,
|
||||||
isEzpayPayment,
|
isEzpayPayment,
|
||||||
@@ -42,33 +43,33 @@ export interface UsePaymentLaunchFlowInput {
|
|||||||
subscriptionType: PendingPaymentSubscriptionType;
|
subscriptionType: PendingPaymentSubscriptionType;
|
||||||
giftCategory?: string | null;
|
giftCategory?: string | null;
|
||||||
giftPlanId?: string | null;
|
giftPlanId?: string | null;
|
||||||
|
countryCode?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentLaunchFlow {
|
export interface PaymentLaunchFlow {
|
||||||
ezpayPaymentUrl: string | null;
|
ezpayPaymentUrl: string | null;
|
||||||
handleEzpayCancel: () => void;
|
handleEzpayCancel: () => void;
|
||||||
handleEzpayConfirm: () => void;
|
handleEzpayConfirm: () => void;
|
||||||
handleQrisClose: () => void;
|
handleRegionalQrClose: () => void;
|
||||||
handleQrisResume: () => void;
|
|
||||||
handleStripeClose: () => void;
|
handleStripeClose: () => void;
|
||||||
handleStripeConfirmed: () => void;
|
handleStripeConfirmed: () => void;
|
||||||
hasHiddenQrisPayment: boolean;
|
|
||||||
isConfirmingEzpay: boolean;
|
isConfirmingEzpay: boolean;
|
||||||
qrisErrorMessage: string | null;
|
regionalQrErrorMessage: string | null;
|
||||||
qrisPayment: QrisPaymentDetails | null;
|
regionalQrPayment: RegionalQrPaymentDetails | null;
|
||||||
qrisStatus: PaymentContextState["orderStatus"];
|
regionalQrStatus: PaymentContextState["orderStatus"];
|
||||||
resetLaunchState: () => void;
|
resetLaunchState: () => void;
|
||||||
shouldShowEzpayConfirmDialog: boolean;
|
shouldShowEzpayConfirmDialog: boolean;
|
||||||
shouldShowQrisDialog: boolean;
|
shouldShowRegionalQrDialog: boolean;
|
||||||
shouldShowStripeDialog: boolean;
|
shouldShowStripeDialog: boolean;
|
||||||
stripeClientSecret: string | null;
|
stripeClientSecret: string | null;
|
||||||
stripeCustomerSessionClientSecret: string | null;
|
stripeCustomerSessionClientSecret: string | null;
|
||||||
savedPaymentMethodsEnabled: boolean;
|
savedPaymentMethodsEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QrisPaymentDetails {
|
export interface RegionalQrPaymentDetails {
|
||||||
amountCents: number;
|
amountCents: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
experience: "gcashQrPh" | "qris" | "paymentQr";
|
||||||
orderId: string;
|
orderId: string;
|
||||||
qrData: string;
|
qrData: string;
|
||||||
}
|
}
|
||||||
@@ -158,33 +159,39 @@ export function usePaymentLaunchFlow({
|
|||||||
subscriptionType,
|
subscriptionType,
|
||||||
giftCategory,
|
giftCategory,
|
||||||
giftPlanId,
|
giftPlanId,
|
||||||
|
countryCode,
|
||||||
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
|
}: UsePaymentLaunchFlowInput): PaymentLaunchFlow {
|
||||||
const launchedNonceRef = useRef(0);
|
const launchedNonceRef = useRef(0);
|
||||||
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
|
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
|
const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false);
|
||||||
const [hiddenQrisOrderId, setHiddenQrisOrderId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const stripeClientSecret = payment.payParams
|
const stripeClientSecret = payment.payParams
|
||||||
? getStripeClientSecret(payment.payParams)
|
? getStripeClientSecret(payment.payParams)
|
||||||
: null;
|
: null;
|
||||||
const stripeCustomerSessionClientSecret = payment.payParams
|
const stripeCustomerSessionClientSecret = payment.payParams
|
||||||
? getStripeCustomerSessionClientSecret(payment.payParams)
|
? getStripeCustomerSessionClientSecret(payment.payParams)
|
||||||
: null;
|
: null;
|
||||||
|
const selectedPlan = payment.plans.find(
|
||||||
|
(item) => item.planId === payment.selectedPlanId,
|
||||||
|
);
|
||||||
|
const paymentCurrency = payment.payParams
|
||||||
|
? paymentParamString(payment.payParams, "currency") ??
|
||||||
|
selectedPlan?.currency ??
|
||||||
|
null
|
||||||
|
: selectedPlan?.currency ?? null;
|
||||||
const ezpayLaunchTarget =
|
const ezpayLaunchTarget =
|
||||||
payment.payParams && isEzpayPayment(payment.payParams)
|
payment.payParams && isEzpayPayment(payment.payParams)
|
||||||
? resolveEzpayLaunchTarget(payment.payParams)
|
? resolveEzpayLaunchTarget(payment.payParams, {
|
||||||
|
countryCode,
|
||||||
|
currency: paymentCurrency,
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
const ezpayPaymentUrl =
|
const ezpayPaymentUrl =
|
||||||
ezpayLaunchTarget?.kind === "url"
|
ezpayLaunchTarget?.kind === "url"
|
||||||
? ezpayLaunchTarget.paymentUrl
|
? ezpayLaunchTarget.paymentUrl
|
||||||
: null;
|
: null;
|
||||||
const selectedPlan = payment.plans.find(
|
const regionalQrPayment: RegionalQrPaymentDetails | null =
|
||||||
(item) => item.planId === payment.selectedPlanId,
|
|
||||||
);
|
|
||||||
const qrisPayment: QrisPaymentDetails | null =
|
|
||||||
payment.payParams &&
|
payment.payParams &&
|
||||||
payment.currentOrderId &&
|
payment.currentOrderId &&
|
||||||
ezpayLaunchTarget?.kind === "qr"
|
ezpayLaunchTarget?.kind === "qr"
|
||||||
@@ -194,9 +201,9 @@ export function usePaymentLaunchFlow({
|
|||||||
selectedPlan?.amountCents ??
|
selectedPlan?.amountCents ??
|
||||||
0,
|
0,
|
||||||
currency:
|
currency:
|
||||||
paymentParamString(payment.payParams, "currency") ??
|
paymentCurrency ??
|
||||||
selectedPlan?.currency ??
|
(ezpayLaunchTarget.experience === "gcashQrPh" ? "PHP" : "IDR"),
|
||||||
"IDR",
|
experience: ezpayLaunchTarget.experience,
|
||||||
orderId: payment.currentOrderId,
|
orderId: payment.currentOrderId,
|
||||||
qrData: ezpayLaunchTarget.qrData,
|
qrData: ezpayLaunchTarget.qrData,
|
||||||
}
|
}
|
||||||
@@ -215,7 +222,33 @@ export function usePaymentLaunchFlow({
|
|||||||
|
|
||||||
const isEzpay = isEzpayPayment(payment.payParams);
|
const isEzpay = isEzpayPayment(payment.payParams);
|
||||||
if (isEzpay) {
|
if (isEzpay) {
|
||||||
const target = resolveEzpayLaunchTarget(payment.payParams);
|
const target = resolveEzpayLaunchTarget(payment.payParams, {
|
||||||
|
countryCode,
|
||||||
|
currency: paymentCurrency,
|
||||||
|
});
|
||||||
|
const channelType = paymentParamString(
|
||||||
|
payment.payParams,
|
||||||
|
"channelType",
|
||||||
|
"channel_type",
|
||||||
|
);
|
||||||
|
const channelCode = paymentParamString(
|
||||||
|
payment.payParams,
|
||||||
|
"channelCode",
|
||||||
|
"channel_code",
|
||||||
|
);
|
||||||
|
const namedPaymentUrl = getPaymentUrl(payment.payParams);
|
||||||
|
log.debug(`[${logScope}] ezpay launch target resolved`, {
|
||||||
|
countryCode: countryCode?.trim().toUpperCase() ?? null,
|
||||||
|
currency: paymentCurrency?.trim().toUpperCase() ?? null,
|
||||||
|
channelType: channelType?.toUpperCase() ?? null,
|
||||||
|
channelCode,
|
||||||
|
hasCashierUrl: getPaymentUrlHostname(namedPaymentUrl) !== null,
|
||||||
|
hasQrData: target.kind === "qr",
|
||||||
|
paymentUrlHost:
|
||||||
|
target.kind === "url"
|
||||||
|
? getPaymentUrlHostname(target.paymentUrl)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
if (target.kind === "error") {
|
if (target.kind === "error") {
|
||||||
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
|
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
|
||||||
paymentDispatch({
|
paymentDispatch({
|
||||||
@@ -230,11 +263,18 @@ export function usePaymentLaunchFlow({
|
|||||||
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
|
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
|
||||||
paymentDispatch({
|
paymentDispatch({
|
||||||
type: "PaymentLaunchFailed",
|
type: "PaymentLaunchFailed",
|
||||||
errorMessage: "Missing order id before showing QRIS.",
|
errorMessage: "Missing order id before showing payment QR code.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
trackPaymentCheckoutOpened(payment, "qris_embedded");
|
trackPaymentCheckoutOpened(
|
||||||
|
payment,
|
||||||
|
target.experience === "qris"
|
||||||
|
? "qris_embedded"
|
||||||
|
: target.experience === "gcashQrPh"
|
||||||
|
? "gcash_qrph_embedded"
|
||||||
|
: "payment_qr_embedded",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +282,7 @@ export function usePaymentLaunchFlow({
|
|||||||
if (!AppEnvUtil.isProduction()) {
|
if (!AppEnvUtil.isProduction()) {
|
||||||
log.debug(`[${logScope}] ezpay confirmation required`, {
|
log.debug(`[${logScope}] ezpay confirmation required`, {
|
||||||
orderId: payment.currentOrderId,
|
orderId: payment.currentOrderId,
|
||||||
paymentUrl,
|
paymentUrlHost: getPaymentUrlHostname(paymentUrl),
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -268,7 +308,7 @@ export function usePaymentLaunchFlow({
|
|||||||
const paymentUrl = getPaymentUrl(payment.payParams);
|
const paymentUrl = getPaymentUrl(payment.payParams);
|
||||||
if (paymentUrl) {
|
if (paymentUrl) {
|
||||||
try {
|
try {
|
||||||
window.location.href = paymentUrl;
|
window.location.assign(paymentUrl);
|
||||||
trackPaymentCheckoutOpened(payment, paymentUrl);
|
trackPaymentCheckoutOpened(payment, paymentUrl);
|
||||||
} catch {
|
} catch {
|
||||||
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
|
||||||
@@ -289,6 +329,7 @@ export function usePaymentLaunchFlow({
|
|||||||
log,
|
log,
|
||||||
logScope,
|
logScope,
|
||||||
characterSlug,
|
characterSlug,
|
||||||
|
countryCode,
|
||||||
payment.currentOrderId,
|
payment.currentOrderId,
|
||||||
payment,
|
payment,
|
||||||
payment.launchNonce,
|
payment.launchNonce,
|
||||||
@@ -297,6 +338,7 @@ export function usePaymentLaunchFlow({
|
|||||||
payment.plans,
|
payment.plans,
|
||||||
payment.selectedPlanId,
|
payment.selectedPlanId,
|
||||||
paymentDispatch,
|
paymentDispatch,
|
||||||
|
paymentCurrency,
|
||||||
returnTo,
|
returnTo,
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
giftCategory,
|
giftCategory,
|
||||||
@@ -313,22 +355,13 @@ export function usePaymentLaunchFlow({
|
|||||||
payParams: payment.payParams,
|
payParams: payment.payParams,
|
||||||
paymentUrl: ezpayPaymentUrl,
|
paymentUrl: ezpayPaymentUrl,
|
||||||
});
|
});
|
||||||
const shouldShowQrisDialog = Boolean(
|
const shouldShowRegionalQrDialog = Boolean(
|
||||||
qrisPayment &&
|
regionalQrPayment && !payment.isPaid,
|
||||||
qrisPayment.orderId !== hiddenQrisOrderId &&
|
|
||||||
!payment.isPaid,
|
|
||||||
);
|
|
||||||
const hasHiddenQrisPayment = Boolean(
|
|
||||||
qrisPayment &&
|
|
||||||
qrisPayment.orderId === hiddenQrisOrderId &&
|
|
||||||
payment.isPollingOrder &&
|
|
||||||
!payment.isPaid,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function resetLaunchState(): void {
|
function resetLaunchState(): void {
|
||||||
setIsConfirmingEzpay(false);
|
setIsConfirmingEzpay(false);
|
||||||
setHiddenStripeClientSecret(null);
|
setHiddenStripeClientSecret(null);
|
||||||
setHiddenQrisOrderId(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStripeClose(): void {
|
function handleStripeClose(): void {
|
||||||
@@ -375,39 +408,29 @@ export function usePaymentLaunchFlow({
|
|||||||
paymentDispatch({ type: "PaymentReset" });
|
paymentDispatch({ type: "PaymentReset" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleQrisClose(): void {
|
function handleRegionalQrClose(): void {
|
||||||
log.debug(`[${logScope}] qris dialog closed`, {
|
log.debug(`[${logScope}] regional payment QR dialog closed`, {
|
||||||
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
|
orderId: regionalQrPayment?.orderId ?? payment.currentOrderId,
|
||||||
|
experience: regionalQrPayment?.experience ?? null,
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
});
|
});
|
||||||
setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
|
paymentDispatch({ type: "PaymentReset" });
|
||||||
}
|
|
||||||
|
|
||||||
function handleQrisResume(): void {
|
|
||||||
if (!hasHiddenQrisPayment) return;
|
|
||||||
log.debug(`[${logScope}] qris dialog resumed`, {
|
|
||||||
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
|
|
||||||
subscriptionType,
|
|
||||||
});
|
|
||||||
setHiddenQrisOrderId(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ezpayPaymentUrl,
|
ezpayPaymentUrl,
|
||||||
handleEzpayCancel,
|
handleEzpayCancel,
|
||||||
handleEzpayConfirm,
|
handleEzpayConfirm,
|
||||||
handleQrisClose,
|
handleRegionalQrClose,
|
||||||
handleQrisResume,
|
|
||||||
handleStripeClose,
|
handleStripeClose,
|
||||||
handleStripeConfirmed,
|
handleStripeConfirmed,
|
||||||
hasHiddenQrisPayment,
|
|
||||||
isConfirmingEzpay,
|
isConfirmingEzpay,
|
||||||
qrisErrorMessage: payment.errorMessage,
|
regionalQrErrorMessage: payment.errorMessage,
|
||||||
qrisPayment,
|
regionalQrPayment,
|
||||||
qrisStatus: payment.orderStatus,
|
regionalQrStatus: payment.orderStatus,
|
||||||
resetLaunchState,
|
resetLaunchState,
|
||||||
shouldShowEzpayConfirmDialog,
|
shouldShowEzpayConfirmDialog,
|
||||||
shouldShowQrisDialog,
|
shouldShowRegionalQrDialog,
|
||||||
shouldShowStripeDialog,
|
shouldShowStripeDialog,
|
||||||
stripeClientSecret,
|
stripeClientSecret,
|
||||||
stripeCustomerSessionClientSecret,
|
stripeCustomerSessionClientSecret,
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
dispatch: vi.fn(),
|
dispatch: vi.fn(),
|
||||||
handleQrisResume: vi.fn(),
|
|
||||||
hasHiddenQrisPayment: false,
|
|
||||||
resetLaunchState: vi.fn(),
|
resetLaunchState: vi.fn(),
|
||||||
payment: {
|
payment: {
|
||||||
selectedPlanId: "vip_monthly",
|
selectedPlanId: "vip_monthly",
|
||||||
@@ -27,8 +25,6 @@ vi.mock("@/stores/payment/payment-context", () => ({
|
|||||||
|
|
||||||
vi.mock("@/app/_hooks/use-payment-launch-flow", () => ({
|
vi.mock("@/app/_hooks/use-payment-launch-flow", () => ({
|
||||||
usePaymentLaunchFlow: () => ({
|
usePaymentLaunchFlow: () => ({
|
||||||
handleQrisResume: mocks.handleQrisResume,
|
|
||||||
hasHiddenQrisPayment: mocks.hasHiddenQrisPayment,
|
|
||||||
resetLaunchState: mocks.resetLaunchState,
|
resetLaunchState: mocks.resetLaunchState,
|
||||||
launch: {},
|
launch: {},
|
||||||
}),
|
}),
|
||||||
@@ -95,8 +91,6 @@ describe("SubscriptionCheckoutButton renewal confirmation", () => {
|
|||||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
mocks.dispatch.mockClear();
|
mocks.dispatch.mockClear();
|
||||||
mocks.handleQrisResume.mockClear();
|
|
||||||
mocks.hasHiddenQrisPayment = false;
|
|
||||||
mocks.resetLaunchState.mockClear();
|
mocks.resetLaunchState.mockClear();
|
||||||
mocks.payment.selectedPlanId = "vip_monthly";
|
mocks.payment.selectedPlanId = "vip_monthly";
|
||||||
mocks.payment.autoRenew = true;
|
mocks.payment.autoRenew = true;
|
||||||
@@ -189,10 +183,9 @@ describe("SubscriptionCheckoutButton renewal confirmation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reopens a hidden QRIS order without creating another order", () => {
|
it("keeps checkout disabled while a payment order is still being polled", () => {
|
||||||
mocks.payment.payChannel = "ezpay";
|
mocks.payment.payChannel = "ezpay";
|
||||||
mocks.payment.isPollingOrder = true;
|
mocks.payment.isPollingOrder = true;
|
||||||
mocks.hasHiddenQrisPayment = true;
|
|
||||||
|
|
||||||
act(() =>
|
act(() =>
|
||||||
root.render(
|
root.render(
|
||||||
@@ -204,11 +197,8 @@ describe("SubscriptionCheckoutButton renewal confirmation", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const resumeButton = getButton(container, "Resume QRIS payment");
|
const checkoutButton = getButton(container, "Processing payment...");
|
||||||
expect(resumeButton.disabled).toBe(false);
|
expect(checkoutButton.disabled).toBe(true);
|
||||||
act(() => resumeButton.click());
|
|
||||||
|
|
||||||
expect(mocks.handleQrisResume).toHaveBeenCalledOnce();
|
|
||||||
expect(mocks.resetLaunchState).not.toHaveBeenCalled();
|
expect(mocks.resetLaunchState).not.toHaveBeenCalled();
|
||||||
expect(mocks.dispatch).not.toHaveBeenCalled();
|
expect(mocks.dispatch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface SubscriptionCheckoutButtonProps {
|
|||||||
sourceCharacterSlug?: string;
|
sourceCharacterSlug?: string;
|
||||||
renewalPlan?: VipOfferPlanView | null;
|
renewalPlan?: VipOfferPlanView | null;
|
||||||
renewalConsentSubjectId?: string | null;
|
renewalConsentSubjectId?: string | null;
|
||||||
|
countryCode?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubscriptionCheckoutButton({
|
export function SubscriptionCheckoutButton({
|
||||||
@@ -44,6 +45,7 @@ export function SubscriptionCheckoutButton({
|
|||||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||||
renewalPlan = null,
|
renewalPlan = null,
|
||||||
renewalConsentSubjectId = null,
|
renewalConsentSubjectId = null,
|
||||||
|
countryCode = null,
|
||||||
}: SubscriptionCheckoutButtonProps) {
|
}: SubscriptionCheckoutButtonProps) {
|
||||||
const [showRenewalConfirmation, setShowRenewalConfirmation] =
|
const [showRenewalConfirmation, setShowRenewalConfirmation] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -57,15 +59,12 @@ export function SubscriptionCheckoutButton({
|
|||||||
returnTo: returnTo ?? undefined,
|
returnTo: returnTo ?? undefined,
|
||||||
characterSlug: sourceCharacterSlug,
|
characterSlug: sourceCharacterSlug,
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
|
countryCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
|
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
|
||||||
const isLoading =
|
|
||||||
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
|
|
||||||
const readyLabel = "Pay and Top Up";
|
const readyLabel = "Pay and Top Up";
|
||||||
const label = isResumingQris
|
const label = payment.isPollingOrder
|
||||||
? "Resume QRIS payment"
|
|
||||||
: payment.isPollingOrder
|
|
||||||
? "Processing payment..."
|
? "Processing payment..."
|
||||||
: payment.isCreatingOrder
|
: payment.isCreatingOrder
|
||||||
? "Creating order..."
|
? "Creating order..."
|
||||||
@@ -78,10 +77,6 @@ export function SubscriptionCheckoutButton({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (isResumingQris) {
|
|
||||||
paymentLaunch.handleQrisResume();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (disabled || isLoading) return;
|
if (disabled || isLoading) return;
|
||||||
if (
|
if (
|
||||||
payment.payChannel === "stripe" &&
|
payment.payChannel === "stripe" &&
|
||||||
@@ -123,7 +118,7 @@ export function SubscriptionCheckoutButton({
|
|||||||
type="button"
|
type="button"
|
||||||
data-analytics-key="subscription.checkout"
|
data-analytics-key="subscription.checkout"
|
||||||
data-analytics-label="Start subscription checkout"
|
data-analytics-label="Start subscription checkout"
|
||||||
disabled={disabled && !isResumingQris}
|
disabled={disabled}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ export function SubscriptionScreen({
|
|||||||
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
|
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
|
||||||
renewalPlan={selectedVipPlan}
|
renewalPlan={selectedVipPlan}
|
||||||
renewalConsentSubjectId={userState.currentUser?.id || null}
|
renewalConsentSubjectId={userState.currentUser?.id || null}
|
||||||
|
countryCode={countryCode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface TipCheckoutButtonProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onOrder: () => void;
|
onOrder: () => void;
|
||||||
returnPath: string;
|
returnPath: string;
|
||||||
|
countryCode?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TipCheckoutButton({
|
export function TipCheckoutButton({
|
||||||
@@ -28,6 +29,7 @@ export function TipCheckoutButton({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
onOrder,
|
onOrder,
|
||||||
returnPath,
|
returnPath,
|
||||||
|
countryCode = null,
|
||||||
}: TipCheckoutButtonProps) {
|
}: TipCheckoutButtonProps) {
|
||||||
const character = useActiveCharacter();
|
const character = useActiveCharacter();
|
||||||
const payment = usePaymentState();
|
const payment = usePaymentState();
|
||||||
@@ -41,14 +43,11 @@ export function TipCheckoutButton({
|
|||||||
giftCategory,
|
giftCategory,
|
||||||
giftPlanId,
|
giftPlanId,
|
||||||
characterSlug: character.slug,
|
characterSlug: character.slug,
|
||||||
|
countryCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
|
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
|
||||||
const isLoading =
|
const label = payment.isPollingOrder
|
||||||
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
|
|
||||||
const label = isResumingQris
|
|
||||||
? "Resume QRIS payment"
|
|
||||||
: payment.isPollingOrder
|
|
||||||
? "Processing payment..."
|
? "Processing payment..."
|
||||||
: payment.isCreatingOrder
|
: payment.isCreatingOrder
|
||||||
? "Creating order..."
|
? "Creating order..."
|
||||||
@@ -63,8 +62,8 @@ export function TipCheckoutButton({
|
|||||||
data-analytics-key="tip.checkout"
|
data-analytics-key="tip.checkout"
|
||||||
data-analytics-label="Buy coffee tip"
|
data-analytics-label="Buy coffee tip"
|
||||||
className={styles.checkoutButton}
|
className={styles.checkoutButton}
|
||||||
disabled={(disabled && !isResumingQris) || isLoading}
|
disabled={disabled || isLoading}
|
||||||
onClick={isResumingQris ? paymentLaunch.handleQrisResume : onOrder}
|
onClick={onOrder}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ export function TipScreen({
|
|||||||
disabled={!canCreateOrder}
|
disabled={!canCreateOrder}
|
||||||
onOrder={handleOrder}
|
onOrder={handleOrder}
|
||||||
returnPath={returnPath}
|
returnPath={returnPath}
|
||||||
|
countryCode={userState.currentUser?.countryCode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getPaymentUrl,
|
getPaymentUrl,
|
||||||
|
getPaymentUrlHostname,
|
||||||
getStripeCustomerSessionClientSecret,
|
getStripeCustomerSessionClientSecret,
|
||||||
getStripeClientSecret,
|
getStripeClientSecret,
|
||||||
isEzpayPayment,
|
isEzpayPayment,
|
||||||
@@ -66,36 +67,103 @@ describe("payment launch helpers", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves Ezpay URL and QR launch parameters without treating QR data as a URL", () => {
|
it("prioritizes a hosted GCash URL for Philippine checkout", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveEzpayLaunchTarget({
|
resolveEzpayLaunchTarget({
|
||||||
provider: "ezpay",
|
provider: "ezpay",
|
||||||
channelType: "URL",
|
channelType: "URL",
|
||||||
payData: "https://pay.example/qris",
|
payData: "https://pay.example/gcash",
|
||||||
}),
|
}, { countryCode: "PH", currency: "PHP" }),
|
||||||
).toEqual({ kind: "url", paymentUrl: "https://pay.example/qris" });
|
).toEqual({ kind: "url", paymentUrl: "https://pay.example/gcash" });
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveEzpayLaunchTarget({
|
resolveEzpayLaunchTarget({
|
||||||
|
provider: "ezpay",
|
||||||
|
channelType: "QR",
|
||||||
|
payData: "000201010212ph-qr-payload",
|
||||||
|
cashierUrl: "https://pay.example/gcash-hosted",
|
||||||
|
}, { countryCode: "PH" }),
|
||||||
|
).toEqual({
|
||||||
|
kind: "url",
|
||||||
|
paymentUrl: "https://pay.example/gcash-hosted",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a usable QR Ph fallback without calling it QRIS", () => {
|
||||||
|
expect(
|
||||||
|
resolveEzpayLaunchTarget(
|
||||||
|
{
|
||||||
|
provider: "ezpay",
|
||||||
|
channelType: "QR",
|
||||||
|
payData: "000201010212ph-qr-payload",
|
||||||
|
},
|
||||||
|
{ currency: "PHP" },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
kind: "qr",
|
||||||
|
experience: "gcashQrPh",
|
||||||
|
qrData: "000201010212ph-qr-payload",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveEzpayLaunchTarget(
|
||||||
|
{
|
||||||
|
provider: "ezpay",
|
||||||
|
payData: "000201010212ph-qr-payload",
|
||||||
|
},
|
||||||
|
{ countryCode: "PH" },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
kind: "qr",
|
||||||
|
experience: "gcashQrPh",
|
||||||
|
qrData: "000201010212ph-qr-payload",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Indonesian QRIS ahead of an optional hosted URL", () => {
|
||||||
|
expect(
|
||||||
|
resolveEzpayLaunchTarget(
|
||||||
|
{
|
||||||
provider: "ezpay",
|
provider: "ezpay",
|
||||||
channelType: "QR",
|
channelType: "QR",
|
||||||
payData: "00020101021226670016COM.NOBUBANK.WWW",
|
payData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||||
}),
|
cashierUrl: "https://pay.example/indonesia",
|
||||||
|
},
|
||||||
|
{ countryCode: "ID", currency: "IDR" },
|
||||||
|
),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
kind: "qr",
|
kind: "qr",
|
||||||
|
experience: "qris",
|
||||||
qrData: "00020101021226670016COM.NOBUBANK.WWW",
|
qrData: "00020101021226670016COM.NOBUBANK.WWW",
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a regional error only when both URL and QR data are absent", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveEzpayLaunchTarget({
|
resolveEzpayLaunchTarget(
|
||||||
|
{
|
||||||
provider: "ezpay",
|
provider: "ezpay",
|
||||||
channelType: "QR",
|
channelType: "QR",
|
||||||
payData: "",
|
payData: "",
|
||||||
}),
|
},
|
||||||
|
{ countryCode: "PH" },
|
||||||
|
),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
errorMessage: "QRIS payment data is missing. Please try again.",
|
errorMessage:
|
||||||
|
"GCash / QR Ph payment data is missing. Please try again.",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps diagnostics to the payment URL hostname", () => {
|
||||||
|
expect(
|
||||||
|
getPaymentUrlHostname(
|
||||||
|
"https://pay.example/gcash?token=must-not-appear-in-logs",
|
||||||
|
),
|
||||||
|
).toBe("pay.example");
|
||||||
|
expect(getPaymentUrlHostname("not-a-url")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects VA and unknown Ezpay channel types with explicit errors", () => {
|
it("rejects VA and unknown Ezpay channel types with explicit errors", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }),
|
resolveEzpayLaunchTarget({ provider: "ezpay", channelType: "VA" }),
|
||||||
|
|||||||
@@ -13,9 +13,20 @@ const log = new Logger("LibPaymentPaymentLaunch");
|
|||||||
|
|
||||||
export type EzpayLaunchTarget =
|
export type EzpayLaunchTarget =
|
||||||
| { kind: "url"; paymentUrl: string }
|
| { kind: "url"; paymentUrl: string }
|
||||||
| { kind: "qr"; qrData: string }
|
| {
|
||||||
|
kind: "qr";
|
||||||
|
experience: EzpayQrExperience;
|
||||||
|
qrData: string;
|
||||||
|
}
|
||||||
| { kind: "error"; errorMessage: string };
|
| { kind: "error"; errorMessage: string };
|
||||||
|
|
||||||
|
export type EzpayQrExperience = "gcashQrPh" | "qris" | "paymentQr";
|
||||||
|
|
||||||
|
export interface ResolveEzpayLaunchContext {
|
||||||
|
countryCode?: string | null;
|
||||||
|
currency?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function getNonEmptyString(
|
function getNonEmptyString(
|
||||||
payParams: Record<string, unknown>,
|
payParams: Record<string, unknown>,
|
||||||
...keys: string[]
|
...keys: string[]
|
||||||
@@ -41,6 +52,12 @@ function getHttpPaymentUrl(value: string | null): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPaymentUrlHostname(value: string | null): string | null {
|
||||||
|
const paymentUrl = getHttpPaymentUrl(value);
|
||||||
|
if (!paymentUrl) return null;
|
||||||
|
return new URL(paymentUrl).hostname;
|
||||||
|
}
|
||||||
|
|
||||||
export function getPaymentUrl(payParams: Record<string, unknown>): string | null {
|
export function getPaymentUrl(payParams: Record<string, unknown>): string | null {
|
||||||
const keys = [
|
const keys = [
|
||||||
"cashierUrl",
|
"cashierUrl",
|
||||||
@@ -71,6 +88,7 @@ export function isEzpayPayment(payParams: Record<string, unknown>): boolean {
|
|||||||
|
|
||||||
export function resolveEzpayLaunchTarget(
|
export function resolveEzpayLaunchTarget(
|
||||||
payParams: Record<string, unknown>,
|
payParams: Record<string, unknown>,
|
||||||
|
context: ResolveEzpayLaunchContext = {},
|
||||||
): EzpayLaunchTarget {
|
): EzpayLaunchTarget {
|
||||||
const rawChannelType = getNonEmptyString(
|
const rawChannelType = getNonEmptyString(
|
||||||
payParams,
|
payParams,
|
||||||
@@ -79,13 +97,33 @@ export function resolveEzpayLaunchTarget(
|
|||||||
);
|
);
|
||||||
const channelType = rawChannelType?.toUpperCase() ?? null;
|
const channelType = rawChannelType?.toUpperCase() ?? null;
|
||||||
const payData = getNonEmptyString(payParams, "payData", "pay_data");
|
const payData = getNonEmptyString(payParams, "payData", "pay_data");
|
||||||
|
const payDataUrl = getHttpPaymentUrl(payData);
|
||||||
|
const namedPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
|
||||||
|
const paymentUrl = payDataUrl ?? namedPaymentUrl;
|
||||||
|
const qrData = payData && !payDataUrl ? payData : null;
|
||||||
|
const region = resolveEzpayRegion(payParams, context);
|
||||||
|
|
||||||
|
if (
|
||||||
|
region === "ph" &&
|
||||||
|
paymentUrl &&
|
||||||
|
(channelType === "QR" || channelType === "URL" || channelType === null)
|
||||||
|
) {
|
||||||
|
return { kind: "url", paymentUrl };
|
||||||
|
}
|
||||||
|
|
||||||
if (channelType === "QR") {
|
if (channelType === "QR") {
|
||||||
return payData
|
if (qrData) {
|
||||||
? { kind: "qr", qrData: payData }
|
return {
|
||||||
|
kind: "qr",
|
||||||
|
experience: resolveEzpayQrExperience(region),
|
||||||
|
qrData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return paymentUrl
|
||||||
|
? { kind: "url", paymentUrl }
|
||||||
: {
|
: {
|
||||||
kind: "error",
|
kind: "error",
|
||||||
errorMessage: "QRIS payment data is missing. Please try again.",
|
errorMessage: regionalQrMissingMessage(region),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +135,6 @@ export function resolveEzpayLaunchTarget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (channelType === "URL") {
|
if (channelType === "URL") {
|
||||||
const paymentUrl =
|
|
||||||
getHttpPaymentUrl(payData) ?? getHttpPaymentUrl(getPaymentUrl(payParams));
|
|
||||||
return paymentUrl
|
return paymentUrl
|
||||||
? { kind: "url", paymentUrl }
|
? { kind: "url", paymentUrl }
|
||||||
: {
|
: {
|
||||||
@@ -114,16 +150,68 @@ export function resolveEzpayLaunchTarget(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyPaymentUrl = getHttpPaymentUrl(getPaymentUrl(payParams));
|
if (paymentUrl) return { kind: "url", paymentUrl };
|
||||||
return legacyPaymentUrl
|
if (qrData && region !== null) {
|
||||||
? { kind: "url", paymentUrl: legacyPaymentUrl }
|
return {
|
||||||
: {
|
kind: "qr",
|
||||||
|
experience: resolveEzpayQrExperience(region),
|
||||||
|
qrData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
kind: "error",
|
kind: "error",
|
||||||
errorMessage:
|
errorMessage:
|
||||||
"Ezpay payment parameters did not include a supported URL or QRIS code.",
|
"Ezpay payment parameters did not include a supported URL or payment QR code.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveEzpayRegion(
|
||||||
|
payParams: Record<string, unknown>,
|
||||||
|
context: ResolveEzpayLaunchContext,
|
||||||
|
): "ph" | "id" | null {
|
||||||
|
const currency = (
|
||||||
|
context.currency ?? getNonEmptyString(payParams, "currency")
|
||||||
|
)
|
||||||
|
?.trim()
|
||||||
|
.toUpperCase();
|
||||||
|
if (currency === "PHP") return "ph";
|
||||||
|
if (currency === "IDR") return "id";
|
||||||
|
|
||||||
|
const countryCode = (
|
||||||
|
context.countryCode ??
|
||||||
|
getNonEmptyString(
|
||||||
|
payParams,
|
||||||
|
"purchaseCountryCode",
|
||||||
|
"purchase_country_code",
|
||||||
|
"countryCode",
|
||||||
|
"country_code",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
?.trim()
|
||||||
|
.toUpperCase();
|
||||||
|
if (countryCode === "PH") return "ph";
|
||||||
|
if (countryCode === "ID") return "id";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEzpayQrExperience(
|
||||||
|
region: "ph" | "id" | null,
|
||||||
|
): EzpayQrExperience {
|
||||||
|
if (region === "ph") return "gcashQrPh";
|
||||||
|
if (region === "id") return "qris";
|
||||||
|
return "paymentQr";
|
||||||
|
}
|
||||||
|
|
||||||
|
function regionalQrMissingMessage(region: "ph" | "id" | null): string {
|
||||||
|
if (region === "ph") {
|
||||||
|
return "GCash / QR Ph payment data is missing. Please try again.";
|
||||||
|
}
|
||||||
|
if (region === "id") {
|
||||||
|
return "QRIS payment data is missing. Please try again.";
|
||||||
|
}
|
||||||
|
return "Payment QR data is missing. Please try again.";
|
||||||
|
}
|
||||||
|
|
||||||
export function getStripeClientSecret(
|
export function getStripeClientSecret(
|
||||||
payParams: Record<string, unknown>,
|
payParams: Record<string, unknown>,
|
||||||
): string | null {
|
): string | null {
|
||||||
@@ -187,18 +275,19 @@ export async function launchEzpayRedirect({
|
|||||||
onOpened,
|
onOpened,
|
||||||
onFailed,
|
onFailed,
|
||||||
}: LaunchEzpayRedirectInput): Promise<void> {
|
}: LaunchEzpayRedirectInput): Promise<void> {
|
||||||
|
const paymentUrlHost = getPaymentUrlHostname(paymentUrl);
|
||||||
log.debug("[payment-launch] launchEzpayRedirect START", {
|
log.debug("[payment-launch] launchEzpayRedirect START", {
|
||||||
hasOrderId: Boolean(orderId),
|
hasOrderId: Boolean(orderId),
|
||||||
orderId,
|
orderId,
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
paymentUrl,
|
paymentUrlHost,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!orderId) {
|
if (!orderId) {
|
||||||
const errorMessage = "Missing order id before opening Ezpay.";
|
const errorMessage = "Missing order id before opening Ezpay.";
|
||||||
log.error("[payment-launch] pending ezpay order save skipped", {
|
log.error("[payment-launch] pending ezpay order save skipped", {
|
||||||
subscriptionType,
|
subscriptionType,
|
||||||
paymentUrl,
|
paymentUrlHost,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
onFailed(errorMessage);
|
onFailed(errorMessage);
|
||||||
|
|||||||
Reference in New Issue
Block a user