fix(payment): resume QRIS after closing dialog
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
This commit is contained in:
@@ -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 });
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user