fix(payment): resume QRIS after closing dialog

This commit is contained in:
Codex
2026-07-29 12:26:27 +08:00
parent 47c5f4098f
commit 4bfede7dfd
5 changed files with 130 additions and 18 deletions
@@ -266,6 +266,39 @@ 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 ({
page,
}) => {
const payment = await registerIndonesiaPaymentMocks(page);
await prepareIndonesiaUser(page);
await page.goto("/subscription?type=topup&character=elio");
const orderId = await expectQrisOrder(
page,
/Pay and Top Up/i,
"dol_1000",
/Rp\s*88[.,]900/,
);
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
const resumeButton = page.getByRole("button", {
name: "Resume QRIS payment",
});
await expect(resumeButton).toBeEnabled();
expect(payment.getCreateOrderCount()).toBe(1);
await resumeButton.click();
await expect(dialog).toBeVisible();
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId);
await expect(
page.getByRole("alertdialog", { name: "Payment successful" }),
).toBeVisible({ timeout: 10_000 });
});
test("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 ({
page, page,
}) => { }) => {
@@ -323,6 +356,18 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow",
"tip_coffee_usd_4_99", "tip_coffee_usd_4_99",
/Rp\s*89[.,]299/, /Rp\s*89[.,]299/,
); );
const dialog = page.getByRole("dialog", { name: "Scan to pay with QRIS" });
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
const resumeButton = page.getByRole("button", {
name: "Resume QRIS payment",
});
await expect(resumeButton).toBeEnabled();
await resumeButton.click();
await expect(dialog).toBeVisible();
expect(payment.getCreateOrderCount()).toBe(1);
payment.markPaid(orderId); payment.markPaid(orderId);
await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 }); await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 });
+19
View File
@@ -49,8 +49,10 @@ export interface PaymentLaunchFlow {
handleEzpayCancel: () => void; handleEzpayCancel: () => void;
handleEzpayConfirm: () => void; handleEzpayConfirm: () => void;
handleQrisClose: () => void; handleQrisClose: () => void;
handleQrisResume: () => void;
handleStripeClose: () => void; handleStripeClose: () => void;
handleStripeConfirmed: () => void; handleStripeConfirmed: () => void;
hasHiddenQrisPayment: boolean;
isConfirmingEzpay: boolean; isConfirmingEzpay: boolean;
qrisErrorMessage: string | null; qrisErrorMessage: string | null;
qrisPayment: QrisPaymentDetails | null; qrisPayment: QrisPaymentDetails | null;
@@ -316,6 +318,12 @@ export function usePaymentLaunchFlow({
qrisPayment.orderId !== hiddenQrisOrderId && qrisPayment.orderId !== hiddenQrisOrderId &&
!payment.isPaid, !payment.isPaid,
); );
const hasHiddenQrisPayment = Boolean(
qrisPayment &&
qrisPayment.orderId === hiddenQrisOrderId &&
payment.isPollingOrder &&
!payment.isPaid,
);
function resetLaunchState(): void { function resetLaunchState(): void {
setIsConfirmingEzpay(false); setIsConfirmingEzpay(false);
@@ -375,13 +383,24 @@ export function usePaymentLaunchFlow({
setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId); setHiddenQrisOrderId(qrisPayment?.orderId ?? payment.currentOrderId);
} }
function handleQrisResume(): void {
if (!hasHiddenQrisPayment) return;
log.debug(`[${logScope}] qris dialog resumed`, {
orderId: qrisPayment?.orderId ?? payment.currentOrderId,
subscriptionType,
});
setHiddenQrisOrderId(null);
}
return { return {
ezpayPaymentUrl, ezpayPaymentUrl,
handleEzpayCancel, handleEzpayCancel,
handleEzpayConfirm, handleEzpayConfirm,
handleQrisClose, handleQrisClose,
handleQrisResume,
handleStripeClose, handleStripeClose,
handleStripeConfirmed, handleStripeConfirmed,
hasHiddenQrisPayment,
isConfirmingEzpay, isConfirmingEzpay,
qrisErrorMessage: payment.errorMessage, qrisErrorMessage: payment.errorMessage,
qrisPayment, qrisPayment,
@@ -4,6 +4,8 @@ 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",
@@ -25,6 +27,8 @@ 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: {},
}), }),
@@ -91,10 +95,14 @@ 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;
mocks.payment.payChannel = "stripe"; mocks.payment.payChannel = "stripe";
mocks.payment.isCreatingOrder = false;
mocks.payment.isPollingOrder = false;
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(container); root = createRoot(container);
@@ -180,6 +188,30 @@ describe("SubscriptionCheckoutButton renewal confirmation", () => {
type: "PaymentCreateOrderSubmitted", type: "PaymentCreateOrderSubmitted",
}); });
}); });
it("reopens a hidden QRIS order without creating another order", () => {
mocks.payment.payChannel = "ezpay";
mocks.payment.isPollingOrder = true;
mocks.hasHiddenQrisPayment = true;
act(() =>
root.render(
<SubscriptionCheckoutButton
disabled
subscriptionType="topup"
sourceCharacterSlug="elio"
/>,
),
);
const resumeButton = getButton(container, "Resume QRIS payment");
expect(resumeButton.disabled).toBe(false);
act(() => resumeButton.click());
expect(mocks.handleQrisResume).toHaveBeenCalledOnce();
expect(mocks.resetLaunchState).not.toHaveBeenCalled();
expect(mocks.dispatch).not.toHaveBeenCalled();
});
}); });
function renderButton( function renderButton(
@@ -200,9 +232,13 @@ function renderButton(
} }
function clickButton(root: ParentNode, label: string): void { function clickButton(root: ParentNode, label: string): void {
getButton(root, label).click();
}
function getButton(root: ParentNode, label: string): HTMLButtonElement {
const button = Array.from(root.querySelectorAll("button")).find( const button = Array.from(root.querySelectorAll("button")).find(
(item) => item.textContent?.trim() === label, (item) => item.textContent?.trim() === label,
); );
if (!button) throw new Error(`Expected ${label} button`); if (!button) throw new Error(`Expected ${label} button`);
button.click(); return button;
} }
@@ -59,13 +59,17 @@ export function SubscriptionCheckoutButton({
subscriptionType, subscriptionType,
}); });
const isLoading = payment.isCreatingOrder || payment.isPollingOrder; const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
const isLoading =
payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
const readyLabel = "Pay and Top Up"; const readyLabel = "Pay and Top Up";
const label = payment.isPollingOrder const label = isResumingQris
? "Processing payment..." ? "Resume QRIS payment"
: payment.isCreatingOrder : payment.isPollingOrder
? "Creating order..." ? "Processing payment..."
: readyLabel; : payment.isCreatingOrder
? "Creating order..."
: readyLabel;
const startCheckout = () => { const startCheckout = () => {
if (disabled || isLoading) return; if (disabled || isLoading) return;
@@ -74,6 +78,10 @@ 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" &&
@@ -99,7 +107,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} disabled={disabled && !isResumingQris}
isLoading={isLoading} isLoading={isLoading}
onClick={handleClick} onClick={handleClick}
> >
+14 -10
View File
@@ -43,14 +43,18 @@ export function TipCheckoutButton({
characterSlug: character.slug, characterSlug: character.slug,
}); });
const isLoading = payment.isCreatingOrder || payment.isPollingOrder; const isResumingQris = paymentLaunch.hasHiddenQrisPayment;
const label = payment.isPollingOrder const isLoading =
? "Processing payment..." payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris);
: payment.isCreatingOrder const label = isResumingQris
? "Creating order..." ? "Resume QRIS payment"
: payment.isPaid : payment.isPollingOrder
? "Thanks for the coffee" ? "Processing payment..."
: "Order and Buy"; : payment.isCreatingOrder
? "Creating order..."
: payment.isPaid
? "Thanks for the coffee"
: "Order and Buy";
return ( return (
<> <>
@@ -59,8 +63,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 || isLoading} disabled={(disabled && !isResumingQris) || isLoading}
onClick={onOrder} onClick={isResumingQris ? paymentLaunch.handleQrisResume : onOrder}
> >
{label} {label}
</button> </button>