fix(payment): portal checkout dialogs to viewport

This commit is contained in:
Codex
2026-07-27 18:50:39 +08:00
parent ca9cac21f1
commit e36f94add3
10 changed files with 610 additions and 235 deletions
@@ -0,0 +1,119 @@
import { expect, test, type Page } from "@playwright/test";
import { stripePaymentDialogStyles } from "@/app/_components/payment/stripe-payment-dialog.styles";
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
const viewports = [
{ width: 390, height: 844 },
{ width: 405, height: 797 },
{ width: 540, height: 900 },
] as const;
test.beforeEach(async ({ page }) => {
await mockCoreApis(page);
});
for (const viewport of viewports) {
test(`payment portal stays viewport-bound at ${viewport.width}x${viewport.height}`, async ({
page,
}) => {
await page.setViewportSize(viewport);
await page.goto("/subscription?type=topup", {
waitUntil: "domcontentloaded",
});
await mountPaymentPanel(page, 180);
await expectViewportScrim(page, viewport);
await expectCenteredShortPanel(page, viewport);
await mountPaymentPanel(page, 1_200);
await expectViewportScrim(page, viewport);
await expectScrollableTallPanel(page, viewport);
});
}
async function mountPaymentPanel(page: Page, contentHeight: number) {
await page.evaluate(
({ dialogClassName, overlayClassName, requestedContentHeight }) => {
document.querySelector('[data-testid="payment-layout-overlay"]')?.remove();
document.querySelector('[data-testid="transformed-checkout-host"]')?.remove();
const transformedCheckoutHost = document.createElement("div");
transformedCheckoutHost.dataset.testid = "transformed-checkout-host";
transformedCheckoutHost.style.transform = "translateX(50%)";
document.body.appendChild(transformedCheckoutHost);
const overlay = document.createElement("div");
overlay.dataset.testid = "payment-layout-overlay";
overlay.className = overlayClassName;
const panel = document.createElement("div");
panel.dataset.testid = "payment-layout-panel";
panel.className = dialogClassName;
const content = document.createElement("div");
content.style.height = `${requestedContentHeight}px`;
content.textContent = "Complete payment";
panel.appendChild(content);
overlay.appendChild(panel);
// Mirrors ModalPortal: the overlay must be a body child, never a child of
// the transformed checkout host that triggered it.
document.body.appendChild(overlay);
},
{
dialogClassName: stripePaymentDialogStyles.dialog,
overlayClassName: stripePaymentDialogStyles.overlay,
requestedContentHeight: contentHeight,
},
);
}
async function expectViewportScrim(
page: Page,
viewport: { width: number; height: number },
) {
const overlay = page.getByTestId("payment-layout-overlay");
await expect(overlay).toBeVisible();
const box = await overlay.boundingBox();
expect(box).not.toBeNull();
expect(box?.x).toBeCloseTo(0, 0);
expect(box?.y).toBeCloseTo(0, 0);
expect(box?.width).toBeCloseTo(viewport.width, 0);
expect(box?.height).toBeCloseTo(viewport.height, 0);
await expect(overlay.evaluate((node) => node.parentElement === document.body))
.resolves.toBe(true);
}
async function expectCenteredShortPanel(
page: Page,
viewport: { width: number; height: number },
) {
const panel = page.getByTestId("payment-layout-panel");
const box = await panel.boundingBox();
expect(box).not.toBeNull();
expect(box?.y ?? 0).toBeGreaterThan(100);
expect(Math.abs((box?.y ?? 0) * 2 + (box?.height ?? 0) - viewport.height))
.toBeLessThanOrEqual(2);
}
async function expectScrollableTallPanel(
page: Page,
viewport: { width: number; height: number },
) {
const panel = page.getByTestId("payment-layout-panel");
const box = await panel.boundingBox();
const scrollMetrics = await panel.evaluate((node) => ({
clientHeight: node.clientHeight,
scrollHeight: node.scrollHeight,
}));
expect(box).not.toBeNull();
expect(box?.y ?? 0).toBeGreaterThanOrEqual(15);
expect(box?.y ?? 100).toBeLessThanOrEqual(25);
expect((box?.y ?? 0) + (box?.height ?? 0))
.toBeLessThanOrEqual(viewport.height - 15);
expect(scrollMetrics.scrollHeight).toBeGreaterThan(scrollMetrics.clientHeight);
}
@@ -0,0 +1,119 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ModalPortal } from "../modal-portal";
describe("ModalPortal", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
container.style.transform = "translateX(50%)";
document.body.appendChild(container);
document.body.style.overflow = "auto";
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.style.overflow = "";
});
it("portals outside transformed ancestors and exposes dialog semantics", () => {
const onClose = vi.fn();
act(() =>
root.render(
<ModalPortal
open
onClose={onClose}
scrimClassName="fixed inset-0"
panelClassName="payment-panel"
scrimOpacity={0.5}
role="alertdialog"
ariaLabelledBy="payment-title"
ariaDescribedBy="payment-description"
>
<h2 id="payment-title">Complete payment</h2>
<p id="payment-description">Choose a payment method.</p>
</ModalPortal>,
),
);
const dialog = document.body.querySelector('[role="alertdialog"]');
expect(dialog).not.toBeNull();
expect(container.contains(dialog)).toBe(false);
expect(dialog?.parentElement?.parentElement).toBe(document.body);
expect(dialog?.getAttribute("aria-labelledby")).toBe("payment-title");
expect(dialog?.getAttribute("aria-describedby")).toBe(
"payment-description",
);
expect(document.body.style.overflow).toBe("hidden");
});
it("restores the previous body overflow after the final modal closes", () => {
const renderModals = (showFirst: boolean, showSecond: boolean) => (
<>
<ModalPortal
open={showFirst}
onClose={() => undefined}
scrimClassName="first-scrim"
panelClassName="first-panel"
scrimOpacity={0.4}
>
First
</ModalPortal>
<ModalPortal
open={showSecond}
onClose={() => undefined}
scrimClassName="second-scrim"
panelClassName="second-panel"
scrimOpacity={0.4}
>
Second
</ModalPortal>
</>
);
act(() => root.render(renderModals(true, true)));
expect(document.body.style.overflow).toBe("hidden");
act(() => root.render(renderModals(false, true)));
expect(document.body.style.overflow).toBe("hidden");
act(() => root.render(renderModals(false, false)));
expect(document.body.style.overflow).toBe("auto");
});
it("keeps persistent payment modals open on scrim click and Escape", () => {
const onClose = vi.fn();
act(() =>
root.render(
<ModalPortal
open
persistent
onClose={onClose}
scrimClassName="fixed inset-0"
panelClassName="payment-panel"
scrimOpacity={0.5}
>
Payment
</ModalPortal>,
),
);
const scrim = document.body.querySelector(".fixed.inset-0");
act(() => scrim?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
act(() =>
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })),
);
expect(onClose).not.toHaveBeenCalled();
});
});
+9 -1
View File
@@ -20,7 +20,10 @@ export interface ModalPortalProps {
scrimOpacity: number;
panelStyle?: CSSProperties;
persistent?: boolean;
role?: "dialog" | "alertdialog";
ariaLabel?: string;
ariaLabelledBy?: string;
ariaDescribedBy?: string;
}
export function ModalPortal({
@@ -32,7 +35,10 @@ export function ModalPortal({
scrimOpacity,
panelStyle,
persistent = false,
role = "dialog",
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
}: ModalPortalProps) {
useEffect(() => {
if (!open || typeof document === "undefined") return;
@@ -63,9 +69,11 @@ export function ModalPortal({
onClick={handleScrimClick}
>
<div
role="dialog"
role={role}
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
className={panelClassName}
style={panelStyle}
onClick={(event) => event.stopPropagation()}
@@ -1,5 +1,6 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PaymentLaunchDialogs } from "../payment-launch-dialogs";
@@ -20,9 +21,27 @@ const hiddenLaunch = {
};
describe("PaymentLaunchDialogs", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
container.style.transform = "translateX(50%)";
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.style.overflow = "";
});
it("renders nothing when neither payment dialog is active", () => {
expect(
renderToStaticMarkup(
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId={null}
externalCheckoutAnalyticsKey="payment.external_checkout"
@@ -30,11 +49,15 @@ describe("PaymentLaunchDialogs", () => {
launch={hiddenLaunch}
/>,
),
).toBe("");
);
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull();
});
it("renders the shared Ezpay confirmation content", () => {
const html = renderToStaticMarkup(
it("portals the shared Ezpay confirmation outside transformed checkout UI", () => {
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-123"
externalCheckoutAnalyticsKey="tip.external_checkout"
@@ -45,18 +68,28 @@ describe("PaymentLaunchDialogs", () => {
shouldShowEzpayConfirmDialog: true,
}}
/>,
),
);
expect(html).toContain('role="alertdialog"');
expect(html).toContain("Continue to payment?");
expect(html).toContain("Your coffee order is ready.");
expect(html).toContain("Order No. order-123");
expect(html).toContain('data-analytics-key="tip.external_checkout"');
expect(html).toContain("Opening...");
const dialog = document.body.querySelector('[role="alertdialog"]');
expect(dialog).not.toBeNull();
expect(container.contains(dialog)).toBe(false);
expect(dialog?.parentElement?.parentElement).toBe(document.body);
expect(dialog?.textContent).toContain("Continue to payment?");
expect(dialog?.textContent).toContain("Your coffee order is ready.");
expect(dialog?.textContent).toContain("Order No. order-123");
expect(
dialog?.querySelector("button[data-analytics-key]")?.getAttribute(
"data-analytics-key",
),
).toBe("tip.external_checkout");
expect(dialog?.textContent).toContain("Opening...");
expect(document.body.style.overflow).toBe("hidden");
});
it("renders an accessible QRIS dialog with display cents and order status", () => {
const html = renderToStaticMarkup(
it("portals an accessible QRIS dialog with display cents and order status", () => {
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-id-qris"
externalCheckoutAnalyticsKey="tip.external_checkout"
@@ -73,19 +106,25 @@ describe("PaymentLaunchDialogs", () => {
shouldShowQrisDialog: true,
}}
/>,
),
);
expect(html).toContain('role="dialog"');
expect(html).toContain("Scan to pay with QRIS");
expect(html).toContain("QRIS payment QR code");
expect(html).toContain("Order No. order-id-qris");
expect(html).toContain("Rp");
expect(html).toContain("50.000");
expect(html).toContain("Waiting for payment");
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).not.toBeNull();
expect(container.contains(dialog)).toBe(false);
expect(dialog?.textContent).toContain("Scan to pay with QRIS");
expect(dialog?.querySelector("svg title")?.textContent).toBe(
"QRIS payment QR code",
);
expect(dialog?.textContent).toContain("Order No. order-id-qris");
expect(dialog?.textContent).toContain("Rp");
expect(dialog?.textContent).toContain("50.000");
expect(dialog?.textContent).toContain("Waiting for payment");
});
it("shows QRIS failure state and handles empty QR data safely", () => {
const html = renderToStaticMarkup(
act(() =>
root.render(
<PaymentLaunchDialogs
currentOrderId="order-id-qris"
externalCheckoutAnalyticsKey="payment.external_checkout"
@@ -103,10 +142,12 @@ describe("PaymentLaunchDialogs", () => {
shouldShowQrisDialog: true,
}}
/>,
),
);
expect(html).toContain("QRIS data is unavailable");
expect(html).toContain("Payment failed or was cancelled.");
expect(html).toContain("Close");
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("QRIS data is unavailable");
expect(dialog?.textContent).toContain("Payment failed or was cancelled.");
expect(dialog?.textContent).toContain("Close");
});
});
@@ -0,0 +1,63 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StripePaymentDialogLoading } from "../lazy-stripe-payment-dialog";
import { StripePaymentDialog } from "../stripe-payment-dialog";
describe("Stripe payment portal states", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
container.style.transform = "translateX(50%)";
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.style.overflow = "";
});
it("portals the unavailable state and keeps its explicit close action", () => {
const onClose = vi.fn();
act(() =>
root.render(
<StripePaymentDialog
clientSecret="client-secret"
onClose={onClose}
/>,
),
);
const dialog = document.body.querySelector('[role="alertdialog"]');
expect(dialog).not.toBeNull();
expect(container.contains(dialog)).toBe(false);
expect(dialog?.textContent).toContain("Payment unavailable");
expect(dialog?.getAttribute("aria-labelledby")).toBeTruthy();
expect(dialog?.getAttribute("aria-describedby")).toBeTruthy();
const okButton = Array.from(dialog?.querySelectorAll("button") ?? []).find(
(button) => button.textContent === "OK",
);
act(() => okButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onClose).toHaveBeenCalledOnce();
});
it("portals the lazy loading state", () => {
act(() => root.render(<StripePaymentDialogLoading />));
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).not.toBeNull();
expect(container.contains(dialog)).toBe(false);
expect(dialog?.textContent).toContain("Preparing secure payment");
expect(dialog?.textContent).toContain("Loading payment methods...");
expect(dialog?.querySelector('[aria-busy="true"]')).not.toBeNull();
});
});
@@ -2,6 +2,8 @@
import dynamic from "next/dynamic";
import { ModalPortal } from "@/app/_components/core/modal-portal";
import type { StripePaymentDialogProps } from "./stripe-payment-dialog";
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
@@ -20,22 +22,28 @@ export function LazyStripePaymentDialog(props: StripePaymentDialogProps) {
return <StripePaymentDialog {...props} />;
}
function StripePaymentDialogLoading() {
export function StripePaymentDialogLoading() {
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby="stripe-payment-loading-title"
<ModalPortal
open
persistent
onClose={() => undefined}
scrimClassName={styles.overlay}
panelClassName={styles.dialog}
scrimOpacity={0.5}
ariaLabelledBy="stripe-payment-loading-title"
ariaDescribedBy="stripe-payment-loading-description"
>
<div className={styles.dialog} aria-busy="true">
<div aria-busy="true">
<div className={styles.header}>
<h2 id="stripe-payment-loading-title" className={styles.title}>
Preparing secure payment
</h2>
<p className={styles.content}>Loading payment methods...</p>
</div>
<p id="stripe-payment-loading-description" className={styles.content}>
Loading payment methods...
</p>
</div>
</div>
</ModalPortal>
);
}
@@ -3,6 +3,7 @@
import { useId, type ReactNode } from "react";
import { QRCodeSVG } from "qrcode.react";
import { ModalPortal } from "@/app/_components/core/modal-portal";
import type { PaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
import { LazyStripePaymentDialog } from "./lazy-stripe-payment-dialog";
@@ -111,13 +112,15 @@ function QrisPaymentDialog({
const statusMessage = qrisStatusMessage(status, errorMessage);
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
<ModalPortal
open
persistent
onClose={onClose}
scrimClassName={styles.overlay}
panelClassName={styles.dialog}
scrimOpacity={0.5}
ariaLabelledBy={titleId}
>
<div className={styles.dialog}>
<div className={`${styles.header} text-center`}>
<h2 id={titleId} className={styles.title}>
Scan to pay with QRIS
@@ -163,8 +166,7 @@ function QrisPaymentDialog({
Close
</button>
</div>
</div>
</div>
</ModalPortal>
);
}
@@ -188,13 +190,16 @@ function EzpayRedirectConfirmDialog({
const titleId = useId();
return (
<div
className={styles.overlay}
<ModalPortal
open
persistent
onClose={onCancel}
scrimClassName={styles.overlay}
panelClassName={styles.dialog}
scrimOpacity={0.5}
role="alertdialog"
aria-modal="true"
aria-labelledby={titleId}
ariaLabelledBy={titleId}
>
<div className={styles.dialog}>
<div className={styles.header}>
<h2 id={titleId} className={styles.title}>
Continue to payment?
@@ -222,7 +227,6 @@ function EzpayRedirectConfirmDialog({
{isConfirming ? "Opening..." : "Continue"}
</button>
</div>
</div>
</div>
</ModalPortal>
);
}
@@ -2,7 +2,7 @@ export const stripePaymentDialogStyles = {
overlay:
"fixed inset-0 z-70 flex items-center justify-center bg-[rgba(0,0,0,0.5)] pb-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-bottom,0))] pl-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-left,0))] pr-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-right,0))] pt-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-top,0))]",
dialog:
"max-h-[calc(var(--app-visible-height,100dvh)-calc(var(--dialog-safe-margin,20px)*2))] w-full max-w-(--dialog-wide-max-width,420px) overflow-y-auto rounded-(--responsive-card-radius,32px) bg-(--color-page-background,#ffffff) px-(--responsive-card-padding,18px) pb-(--responsive-card-padding,18px) pt-(--responsive-card-padding-lg,24px) shadow-[0_18px_40px_rgba(0,0,0,0.16)]",
"max-h-[calc(var(--app-visible-height,100dvh)-calc(var(--dialog-safe-margin,20px)*2))] w-full max-w-(--dialog-wide-max-width,420px) overflow-y-auto overscroll-contain rounded-(--responsive-card-radius,32px) bg-(--color-page-background,#ffffff) px-(--responsive-card-padding,18px) pb-(--responsive-card-padding,18px) pt-(--responsive-card-padding-lg,24px) shadow-[0_18px_40px_rgba(0,0,0,0.16)]",
header: "mb-(--page-section-gap,18px) text-left",
title:
"m-0 mb-[clamp(7px,1.481vw,8px)] text-(length:--responsive-page-title,22px) font-bold leading-[1.2] text-text-foreground",
@@ -4,7 +4,7 @@
*
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
*/
import { useState, type FormEvent } from "react";
import { useId, useState, type FormEvent } from "react";
import {
Elements,
PaymentElement,
@@ -13,6 +13,7 @@ import {
} from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { ModalPortal } from "@/app/_components/core/modal-portal";
import { ExceptionHandler } from "@/core/errors";
import { ROUTES } from "@/router/routes";
import { Logger } from "@/utils/logger";
@@ -38,13 +39,27 @@ export function StripePaymentDialog({
onClose,
onConfirmed,
}: StripePaymentDialogProps) {
const titleId = useId();
const descriptionId = useId();
if (!stripePromise) {
return (
<div className={styles.overlay} role="alertdialog" aria-modal="true">
<div className={styles.dialog}>
<ModalPortal
open
persistent
onClose={onClose}
scrimClassName={styles.overlay}
panelClassName={styles.dialog}
scrimOpacity={0.5}
role="alertdialog"
ariaLabelledBy={titleId}
ariaDescribedBy={descriptionId}
>
<div className={styles.header}>
<h2 className={styles.title}>Payment unavailable</h2>
<p className={styles.content}>
<h2 id={titleId} className={styles.title}>
Payment unavailable
</h2>
<p id={descriptionId} className={styles.content}>
Stripe publishable key is not configured for this build.
</p>
</div>
@@ -57,24 +72,26 @@ export function StripePaymentDialog({
OK
</button>
</div>
</div>
</div>
</ModalPortal>
);
}
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby="stripe-payment-title"
<ModalPortal
open
persistent
onClose={onClose}
scrimClassName={styles.overlay}
panelClassName={styles.dialog}
scrimOpacity={0.5}
ariaLabelledBy={titleId}
ariaDescribedBy={descriptionId}
>
<div className={styles.dialog}>
<div className={styles.header}>
<h2 id="stripe-payment-title" className={styles.title}>
<h2 id={titleId} className={styles.title}>
Complete payment
</h2>
<p className={styles.content}>
<p id={descriptionId} className={styles.content}>
Enter your payment details securely through Stripe.
</p>
</div>
@@ -99,8 +116,7 @@ export function StripePaymentDialog({
onConfirmed={onConfirmed}
/>
</Elements>
</div>
</div>
</ModalPortal>
);
}
@@ -1,7 +1,7 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { StripePaymentDialog } from "@/app/_components/payment/stripe-payment-dialog";
import { stripePaymentDialogStyles } from "@/app/_components/payment/stripe-payment-dialog.styles";
import { SubscriptionCtaButton } from "../subscription-cta-button";
import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog";
@@ -48,19 +48,16 @@ describe("subscription Tailwind components", () => {
expect(html).toContain("OK");
});
it("renders StripePaymentDialog fallback with Tailwind dialog utilities", () => {
const html = renderToStaticMarkup(
<StripePaymentDialog
clientSecret="client-secret"
onClose={() => undefined}
/>,
it("keeps Stripe dialog viewport and panel utilities", () => {
expect(stripePaymentDialogStyles.overlay).toContain("fixed inset-0");
expect(stripePaymentDialogStyles.dialog).toContain(
"max-w-(--dialog-wide-max-width,420px)",
);
expect(stripePaymentDialogStyles.dialog).toContain("overflow-y-auto");
expect(stripePaymentDialogStyles.dialog).toContain("overscroll-contain");
expect(stripePaymentDialogStyles.primary).toContain(
"bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0)",
);
expect(html).toContain('role="alertdialog"');
expect(html).toContain("fixed inset-0");
expect(html).toContain("max-w-(--dialog-wide-max-width,420px)");
expect(html).toContain("Payment unavailable");
expect(html).toContain("bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0)");
});
it("renders most popular badges for VIP and coin plans", () => {