fix(payment): resume QRIS after closing dialog
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
<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(
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user