diff --git a/e2e/specs/mock/payment/indonesia-qris.spec.ts b/e2e/specs/mock/payment/indonesia-qris.spec.ts index a25a364e..f2571bbc 100644 --- a/e2e/specs/mock/payment/indonesia-qris.spec.ts +++ b/e2e/specs/mock/payment/indonesia-qris.spec.ts @@ -266,6 +266,39 @@ test("Indonesia credit top-up uses QRIS display cents", async ({ page }) => { ).toBeVisible({ timeout: 10_000 }); }); +test("closing QRIS restores a resumable checkout without creating a duplicate order", async ({ + 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 ({ page, }) => { @@ -323,6 +356,18 @@ test("Indonesia gift checkout keeps IDR price through QRIS and thank-you flow", "tip_coffee_usd_4_99", /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); await expect(page.getByText("Gift received")).toBeVisible({ timeout: 10_000 }); diff --git a/src/app/_hooks/use-payment-launch-flow.ts b/src/app/_hooks/use-payment-launch-flow.ts index fc0837f7..1d645763 100644 --- a/src/app/_hooks/use-payment-launch-flow.ts +++ b/src/app/_hooks/use-payment-launch-flow.ts @@ -49,8 +49,10 @@ export interface PaymentLaunchFlow { handleEzpayCancel: () => void; handleEzpayConfirm: () => void; handleQrisClose: () => void; + handleQrisResume: () => void; handleStripeClose: () => void; handleStripeConfirmed: () => void; + hasHiddenQrisPayment: boolean; isConfirmingEzpay: boolean; qrisErrorMessage: string | null; qrisPayment: QrisPaymentDetails | null; @@ -316,6 +318,12 @@ export function usePaymentLaunchFlow({ qrisPayment.orderId !== hiddenQrisOrderId && !payment.isPaid, ); + const hasHiddenQrisPayment = Boolean( + qrisPayment && + qrisPayment.orderId === hiddenQrisOrderId && + payment.isPollingOrder && + !payment.isPaid, + ); function resetLaunchState(): void { setIsConfirmingEzpay(false); @@ -375,13 +383,24 @@ export function usePaymentLaunchFlow({ 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 { ezpayPaymentUrl, handleEzpayCancel, handleEzpayConfirm, handleQrisClose, + handleQrisResume, handleStripeClose, handleStripeConfirmed, + hasHiddenQrisPayment, isConfirmingEzpay, qrisErrorMessage: payment.errorMessage, qrisPayment, diff --git a/src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx b/src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx index b0a3f64d..1b6229ba 100644 --- a/src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx +++ b/src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx @@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ dispatch: vi.fn(), + handleQrisResume: vi.fn(), + hasHiddenQrisPayment: false, resetLaunchState: vi.fn(), payment: { selectedPlanId: "vip_monthly", @@ -25,6 +27,8 @@ vi.mock("@/stores/payment/payment-context", () => ({ vi.mock("@/app/_hooks/use-payment-launch-flow", () => ({ usePaymentLaunchFlow: () => ({ + handleQrisResume: mocks.handleQrisResume, + hasHiddenQrisPayment: mocks.hasHiddenQrisPayment, resetLaunchState: mocks.resetLaunchState, launch: {}, }), @@ -91,10 +95,14 @@ describe("SubscriptionCheckoutButton renewal confirmation", () => { .IS_REACT_ACT_ENVIRONMENT = true; localStorage.clear(); mocks.dispatch.mockClear(); + mocks.handleQrisResume.mockClear(); + mocks.hasHiddenQrisPayment = false; mocks.resetLaunchState.mockClear(); mocks.payment.selectedPlanId = "vip_monthly"; mocks.payment.autoRenew = true; mocks.payment.payChannel = "stripe"; + mocks.payment.isCreatingOrder = false; + mocks.payment.isPollingOrder = false; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -180,6 +188,30 @@ describe("SubscriptionCheckoutButton renewal confirmation", () => { 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( + , + ), + ); + + 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( @@ -200,9 +232,13 @@ function renderButton( } 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( (item) => item.textContent?.trim() === label, ); if (!button) throw new Error(`Expected ${label} button`); - button.click(); + return button; } diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index a6b2372f..fe4d7df3 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -59,13 +59,17 @@ export function SubscriptionCheckoutButton({ 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 label = payment.isPollingOrder - ? "Processing payment..." - : payment.isCreatingOrder - ? "Creating order..." - : readyLabel; + const label = isResumingQris + ? "Resume QRIS payment" + : payment.isPollingOrder + ? "Processing payment..." + : payment.isCreatingOrder + ? "Creating order..." + : readyLabel; const startCheckout = () => { if (disabled || isLoading) return; @@ -74,6 +78,10 @@ export function SubscriptionCheckoutButton({ }; const handleClick = () => { + if (isResumingQris) { + paymentLaunch.handleQrisResume(); + return; + } if (disabled || isLoading) return; if ( payment.payChannel === "stripe" && @@ -99,7 +107,7 @@ export function SubscriptionCheckoutButton({ type="button" data-analytics-key="subscription.checkout" data-analytics-label="Start subscription checkout" - disabled={disabled} + disabled={disabled && !isResumingQris} isLoading={isLoading} onClick={handleClick} > diff --git a/src/app/tip/tip-checkout-button.tsx b/src/app/tip/tip-checkout-button.tsx index 8aa5e3cb..40dfdf82 100644 --- a/src/app/tip/tip-checkout-button.tsx +++ b/src/app/tip/tip-checkout-button.tsx @@ -43,14 +43,18 @@ export function TipCheckoutButton({ characterSlug: character.slug, }); - const isLoading = payment.isCreatingOrder || payment.isPollingOrder; - const label = payment.isPollingOrder - ? "Processing payment..." - : payment.isCreatingOrder - ? "Creating order..." - : payment.isPaid - ? "Thanks for the coffee" - : "Order and Buy"; + const isResumingQris = paymentLaunch.hasHiddenQrisPayment; + const isLoading = + payment.isCreatingOrder || (payment.isPollingOrder && !isResumingQris); + const label = isResumingQris + ? "Resume QRIS payment" + : payment.isPollingOrder + ? "Processing payment..." + : payment.isCreatingOrder + ? "Creating order..." + : payment.isPaid + ? "Thanks for the coffee" + : "Order and Buy"; return ( <> @@ -59,8 +63,8 @@ export function TipCheckoutButton({ data-analytics-key="tip.checkout" data-analytics-label="Buy coffee tip" className={styles.checkoutButton} - disabled={disabled || isLoading} - onClick={onOrder} + disabled={(disabled && !isResumingQris) || isLoading} + onClick={isResumingQris ? paymentLaunch.handleQrisResume : onOrder} > {label}