Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions apps/dashboard/e2e/maintenance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { expect, type Page, test } from "@playwright/test";
import { MAINTENANCE_DETAILS, mockBackend } from "./support/mockBackend";
import { injectMockWallet } from "./support/mockWallet";
import { seedSession } from "./support/session";

const DESTINATION = "0x1111111111111111111111111111111111111111";

test("An active maintenance window shows the banner and blocks starting an onramp", async ({ page }) => {
const backend = await mockBackend(page, { fiatAccounts: [], maintenanceActive: true, onrampCurrency: "MXN" });
await seedSession(page);
await page.goto("/transfer?mode=onramp");

await expect(page.getByText(MAINTENANCE_DETAILS.title)).toBeVisible({ timeout: 20_000 });
await page.getByLabel("Destination wallet address").fill(DESTINATION);
await page.getByLabel("You pay (MXN)").fill("100");
await expect(page.getByText("You receive", { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(page.getByRole("button", { name: "Continue to payment" })).toBeDisabled();

expect(backend.registerRequests).toEqual([]);
expect(backend.unmatchedRequests).toEqual([]);
expect(backend.unexpectedExternalRequests).toEqual([]);
});

test("Quote errors during an active maintenance window say quotes are paused", async ({ page }) => {
const backend = await mockBackend(page, { maintenanceActive: true });
// The API's maintenance guard rejects quote creation for the whole window.
await page.route("http://localhost:3000/v1/quotes", route =>
route.fulfill({
json: { message: "Vortex services are temporarily unavailable during scheduled maintenance", statusCode: 503 },
status: 503
})
);
await seedSession(page);
const pausedMessage = page.getByText("Quotes are paused for scheduled maintenance. Try again once it ends.");

await page.goto("/transfer?mode=onramp");
await page.getByLabel("Destination wallet address").fill(DESTINATION);
await page.getByLabel("You pay (MXN)").fill("100");
await expect(pausedMessage).toBeVisible({ timeout: 20_000 });

await page.goto("/transfer");
await page.locator("#token-amount").fill("54.054054");
await expect(pausedMessage).toBeVisible({ timeout: 20_000 });

await page.goto("/quote");
await page.getByLabel("You pay").fill("100");
await expect(pausedMessage).toBeVisible({ timeout: 20_000 });

expect(backend.unmatchedRequests).toEqual([]);
expect(backend.unexpectedExternalRequests).toEqual([]);
});

async function openPaymentInstructions(page: Page) {
await page.goto("/transfer?mode=onramp");
await page.getByLabel("Destination wallet address").fill(DESTINATION);
await page.getByLabel("You pay (MXN)").fill("100");
const continueButton = page.getByRole("button", { name: "Continue to payment" });
await expect(continueButton).toBeEnabled({ timeout: 20_000 });
await continueButton.click();
await expect(page.getByRole("button", { name: "I have made the payment" })).toBeEnabled({ timeout: 20_000 });
}

// React Query refetches on visibilitychange, as when the sender returns to the tab. Repeat it: the status request
// fired when the instructions mounted may still be in flight with the old answer.
async function refetchUntilBannerShows(page: Page) {
await expect(async () => {
await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange", { bubbles: true })));
await expect(page.getByText(MAINTENANCE_DETAILS.title)).toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 15_000 });
}

test("A window that opens during payment setup and ends before the ramp expires pauses only the confirmation", async ({
page
}) => {
const backend = await mockBackend(page, { fiatAccounts: [], onrampCurrency: "MXN" });
await seedSession(page);
await openPaymentInstructions(page);

backend.maintenance.active = true;
backend.maintenance.endsAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
await refetchUntilBannerShows(page);

await expect(page.getByText("Confirming is paused until maintenance ends.", { exact: false })).toBeVisible();
await expect(page.getByText("CLABE", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "I have made the payment" })).toBeDisabled();
expect(backend.startRequests).toEqual([]);
expect(backend.unmatchedRequests).toEqual([]);
expect(backend.unexpectedExternalRequests).toEqual([]);
});

test("A window that outlasts the ramp's start deadline hides the payment details", async ({ page }) => {
// The mock's default window runs an hour; the registered ramp expires after 15 minutes.
const backend = await mockBackend(page, { fiatAccounts: [], onrampCurrency: "MXN" });
await seedSession(page);
await openPaymentInstructions(page);

backend.maintenance.active = true;
await refetchUntilBannerShows(page);

await expect(page.getByRole("heading", { name: "Payment paused for maintenance" })).toBeVisible();
await expect(page.getByText("CLABE", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "I have made the payment" })).toHaveCount(0);
expect(backend.startRequests).toEqual([]);
expect(backend.unmatchedRequests).toEqual([]);
expect(backend.unexpectedExternalRequests).toEqual([]);
});

test("An active maintenance window blocks sending an offramp", async ({ page }) => {
const backend = await mockBackend(page, { maintenanceActive: true });
await injectMockWallet(page, { chainIdHex: "0x89" });
await seedSession(page);
await page.goto("/transfer?network=polygon");

const amountInput = page.locator("#token-amount");
await expect(amountInput).toBeVisible({ timeout: 20_000 });
await amountInput.fill("54.054054");
await expect(page.getByText("Available: 1,000 USDC on Polygon", { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(page.getByText(MAINTENANCE_DETAILS.title)).toBeVisible();
await expect(page.getByRole("button", { name: /^Send/ })).toBeDisabled();

expect(backend.registerRequests).toEqual([]);
expect(backend.unmatchedRequests).toEqual([]);
expect(backend.unexpectedExternalRequests).toEqual([]);
});
23 changes: 23 additions & 0 deletions apps/dashboard/e2e/support/mockBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ interface MockBackendOptions {
apiCredentials?: Array<Record<string, unknown>>;
approvedCorridors?: Array<"AR" | "BR" | "CO" | "MX" | "US">;
limits?: Array<Record<string, unknown>>;
// Serve an active window on GET /v1/maintenance/status (default: none). Specs can change `maintenance` later.
maintenanceActive?: boolean;
onboardingState?: OnboardingState;
companyMode?: boolean;
selectionRequired?: boolean;
Expand Down Expand Up @@ -304,6 +306,13 @@ interface MockBackendOptions {
tokenBalances?: TokenBalances | null | ((requestIndex: number, network: BalanceNetwork) => TokenBalances | null);
}

export const MAINTENANCE_DETAILS = {
estimated_time_remaining_seconds: 3600,
message: "Ramps are paused while we upgrade.",
start_datetime: "2026-10-05T08:00:00.000Z",
title: "Scheduled maintenance"
};

// AlfredPayStatus values the machine branches on (packages/shared AlfredPayStatus).
const ALFREDPAY_SUCCESS = "SUCCESS";
const ALFREDPAY_VERIFYING = "VERIFYING";
Expand Down Expand Up @@ -435,6 +444,11 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {})
startRequests: [] as Array<Record<string, unknown>>
};
const auth = { refreshes: 0 };
const maintenance = {
active: options.maintenanceActive ?? false,
// An hour from now outlasts a freshly registered ramp's 15-minute start deadline.
endsAt: new Date(Date.now() + 60 * 60 * 1000).toISOString()
};
let selectedCompany = options.companyMode ?? false;
let hasActiveEntity = options.selectionRequired !== true;
const fiatAccounts = [...(options.fiatAccounts ?? buildFiatAccounts())];
Expand Down Expand Up @@ -595,6 +609,14 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {})
return;
}

if (path === "/v1/maintenance/status" && method === "GET") {
await fulfillJson({
is_maintenance_active: maintenance.active,
maintenance_details: maintenance.active ? { ...MAINTENANCE_DETAILS, end_datetime: maintenance.endsAt } : null
});
return;
}

if (path === "/v1/limits" && method === "POST") {
const body = request.postDataJSON() as { corridors?: Array<"AR" | "BR" | "CO" | "MX" | "US"> };
limitsRequests.push(body);
Expand Down Expand Up @@ -1173,6 +1195,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {})
kyc,
kycFormSubmissions,
limitsRequests,
maintenance,
monerium,
quoteRequests,
registerRequests,
Expand Down
28 changes: 28 additions & 0 deletions apps/dashboard/src/components/layout/MaintenanceBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { TriangleAlert } from "lucide-react";
import { useActiveMaintenance } from "@/hooks/useActiveMaintenance";

export function MaintenanceBanner() {
const maintenance = useActiveMaintenance();

if (!maintenance) return null;

const endsAt = new Date(maintenance.end_datetime).toLocaleString(undefined, {
day: "numeric",
hour: "2-digit",
minute: "2-digit",
month: "short",
timeZoneName: "short"
});

return (
<div className="flex min-w-0 items-start gap-2 bg-warning px-4 py-2 text-sm text-warning-foreground" role="status">
<TriangleAlert className="mt-px size-4 shrink-0" />
<div className="min-w-0">
<p>
<strong>{maintenance.title}</strong> · {maintenance.message}
</p>
<p className="font-medium text-xs">New transfers and payment confirmations are paused until {endsAt}.</p>
</div>
</div>
);
}
8 changes: 7 additions & 1 deletion apps/dashboard/src/components/quote/QuoteExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { CORRIDOR_LIST, CORRIDORS } from "@/domain/corridors";
import { getNetworkOptions, getRampTokenOptions, ONRAMP_CORRIDORS } from "@/domain/onramp";
import { PAYMENT_METHOD_LABEL } from "@/domain/transfer";
import type { CorridorId } from "@/domain/types";
import { MAINTENANCE_QUOTE_ERROR, useActiveMaintenance } from "@/hooks/useActiveMaintenance";
import { useApprovedCorridors } from "@/hooks/useApprovedCorridors";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import {
Expand Down Expand Up @@ -164,6 +165,7 @@ export function QuoteExplorer() {
}
: null;
const { data: quote, error, isFetching } = useQuote(quoteParams);
const maintenance = useActiveMaintenance();

return (
<div className="grid gap-5">
Expand Down Expand Up @@ -205,7 +207,11 @@ export function QuoteExplorer() {
transition={springSnappy}
>
<TriangleAlert className="mt-px size-4 shrink-0" />
<p className="text-pretty">We couldn’t price that right now. Try another amount, token, or currency.</p>
<p className="text-pretty">
{maintenance
? MAINTENANCE_QUOTE_ERROR
: "We couldn’t price that right now. Try another amount, token, or currency."}
</p>
</motion.div>
) : quote ? (
// The result is two distinct chunks, so it cascades in rather than landing as one block. The
Expand Down
10 changes: 8 additions & 2 deletions apps/dashboard/src/components/transfer/OnrampForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { CORRIDORS } from "@/domain/corridors";
import { eurOnrampBlocker, getNetworkOptions, getRampTokenOptions, ONRAMP_CORRIDORS } from "@/domain/onramp";
import { shortenAddress } from "@/domain/transfer";
import type { CorridorId, SenderAccount } from "@/domain/types";
import { MAINTENANCE_QUOTE_ERROR, useActiveMaintenance } from "@/hooks/useActiveMaintenance";
import { useApprovedCorridors } from "@/hooks/useApprovedCorridors";
import { formatCurrencyAmount } from "@/lib/amount";
import { transferActor } from "@/machines/transferActor";
Expand Down Expand Up @@ -110,6 +111,7 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi
}
: null;
const { data: quote, error, isFetching } = useQuote(quoteParams);
const maintenance = useActiveMaintenance();
const eurRamp = account.onboardings.EU?.ramp ?? null;
const eurBlocker = corridorId === "EU" ? eurOnrampBlocker(eurRamp, address) : null;
const transferState = useSelector(transferActor, snapshot => snapshot);
Expand Down Expand Up @@ -327,7 +329,11 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi
{error ? (
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-destructive text-sm">
<TriangleAlert className="mt-px size-4 shrink-0" />
<p>We couldn’t fetch a pay-in quote right now. Try another amount or token.</p>
<p>
{maintenance
? MAINTENANCE_QUOTE_ERROR
: "We couldn’t fetch a pay-in quote right now. Try another amount or token."}
</p>
</div>
) : Number(amount) <= 0 ? (
<p className="rounded-lg border border-dashed p-4 text-center text-muted-foreground text-sm">
Expand All @@ -342,7 +348,7 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi
</span>
</div>
<QuoteSummary isFetching={isFetching} quote={quote} />
<Button disabled={activeTransfer || isFetching || !!eurBlocker} size="lg" type="submit">
<Button disabled={activeTransfer || isFetching || !!eurBlocker || !!maintenance} size="lg" type="submit">
{transferState.matches("Registering")
? "Preparing payment…"
: transferState.matches("SigningUserTxs")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { QRCodeSVG } from "qrcode.react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useActiveMaintenance } from "@/hooks/useActiveMaintenance";
import { resetTransferState, transferActor } from "@/machines/transferActor";

function copy(value: string) {
Expand Down Expand Up @@ -94,10 +95,13 @@ export function OnrampPaymentInstructions({ ramp }: { ramp: RampProcess }) {
const navigate = useNavigate();
const starting = useSelector(transferActor, snapshot => snapshot.matches("Starting"));
const startError = useSelector(transferActor, snapshot => snapshot.context.errorMessage);
const maintenance = useActiveMaintenance();
const [now, setNow] = useState(() => Date.now());
const rows = instructionRows(ramp);
const expiresAt = ramp.expiresAt ? new Date(ramp.expiresAt).getTime() : Number.NaN;
const expired = Number.isFinite(expiresAt) && expiresAt <= now;
// The ramp can only be started before it expires; allow a minute after the window to notice the end and confirm.
const pausedPastExpiry = !!maintenance && Date.parse(maintenance.end_datetime) + 60_000 >= expiresAt;

useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
Expand Down Expand Up @@ -133,14 +137,18 @@ export function OnrampPaymentInstructions({ ramp }: { ramp: RampProcess }) {
navigate({ to: "/transactions" });
}

if (expired) {
if (expired || pausedPastExpiry) {
return (
<div className="grid gap-5">
<div className="flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-destructive">
<TriangleAlert className="mt-px size-5 shrink-0" />
<div className="grid gap-1">
<h2 className="font-semibold">Payment instructions expired</h2>
<p className="text-sm">Do not send money using these details. Get a new quote and fresh payment instructions.</p>
<h2 className="font-semibold">{expired ? "Payment instructions expired" : "Payment paused for maintenance"}</h2>
<p className="text-sm">
{expired
? "Do not send money using these details. Get a new quote and fresh payment instructions."
: "Do not send money using these details: they expire before maintenance ends. Get a new quote once it is over."}
</p>
</div>
</div>
<Button onClick={resetTransferState} size="lg" type="button">
Expand Down Expand Up @@ -187,7 +195,16 @@ export function OnrampPaymentInstructions({ ramp }: { ramp: RampProcess }) {
</div>
)}

<Button disabled={starting} onClick={confirmPayment} size="lg" type="button">
{maintenance && (
<div className="flex items-start gap-2 rounded-lg border border-warning bg-warning/10 p-3 text-sm" role="status">
<TriangleAlert className="mt-px size-4 shrink-0" />
<p>
Confirming is paused until maintenance ends. These details stay valid past that, so you can confirm once it is over.
</p>
</div>
)}

<Button disabled={starting || !!maintenance} onClick={confirmPayment} size="lg" type="button">
<Check /> {starting ? "Starting transfer…" : startError ? "Try again" : "I have made the payment"}
</Button>
<Button disabled={starting} onClick={leavePaymentSetup} type="button" variant="ghost">
Expand Down
6 changes: 4 additions & 2 deletions apps/dashboard/src/components/transfer/TransferForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { recipientLabel } from "@/domain/recipient";
import { RECIPIENT_STATUS_META } from "@/domain/status";
import { PAYMENT_METHOD_LABEL } from "@/domain/transfer";
import type { CorridorId, Recipient, SenderAccount } from "@/domain/types";
import { MAINTENANCE_QUOTE_ERROR, useActiveMaintenance } from "@/hooks/useActiveMaintenance";
import { formatCurrencyAmount } from "@/lib/amount";
import { buildTransferAdditionalData } from "@/machines/registerAdditionalData";
import { transferActor } from "@/machines/transferActor";
Expand Down Expand Up @@ -133,6 +134,7 @@ export function TransferForm({ account, prefill, recipients, preselectRecipientI
}
: null;
const { data: quote, isFetching, error } = useQuote(quoteParams);
const maintenance = useActiveMaintenance();

function submitTransfer(submit: FundingSubmit) {
if (!selected || !isSendable || !quote || !quoteParams || !activeOwnerProfileId || !canStartTransfer || !pixReady) {
Expand Down Expand Up @@ -291,7 +293,7 @@ export function TransferForm({ account, prefill, recipients, preselectRecipientI
) : error ? (
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm">
<TriangleAlert className="mt-px size-4 shrink-0 text-destructive" />
<p className="text-destructive">{friendlyQuoteError(error.message)}</p>
<p className="text-destructive">{maintenance ? MAINTENANCE_QUOTE_ERROR : friendlyQuoteError(error.message)}</p>
</div>
) : !amountReady ? (
<p className="rounded-lg border border-dashed p-4 text-center text-muted-foreground text-sm">
Expand All @@ -311,7 +313,7 @@ export function TransferForm({ account, prefill, recipients, preselectRecipientI
</div>
<QuoteSummary isFetching={isFetching} quote={quote} />
<FundingMethods
disabled={!canStartTransfer || isFetching}
disabled={!canStartTransfer || isFetching || !!maintenance}
onSubmit={submitTransfer}
quote={quote}
submitting={submitting || isFetching}
Expand Down
Loading
Loading