Skip to content
1 change: 1 addition & 0 deletions apps/dashboard/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ dist
.tanstack
dev-boot.log
*.log
**/.env
12 changes: 12 additions & 0 deletions apps/dashboard/e2e/auth-gate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ test("a seeded session renders the app shell instead of redirecting", async ({ p
expect(backend.unexpectedExternalRequests).toEqual([]);
});

test("an expired access token refreshes before the dashboard renders", async ({ page }) => {
const backend = await mockBackend(page, { verifyTokenValid: false });
const expiredToken = `header.${Buffer.from(JSON.stringify({ exp: 1 })).toString("base64url")}.signature`;
await seedSession(page, expiredToken);

await page.goto("/overview");

await expect(page.getByRole("heading", { name: "Onboarding" })).toBeVisible({ timeout: 20_000 });
await expect(page).toHaveURL(/\/overview/);
expect(backend.auth.refreshes).toBe(1);
});

test("a started onboarding account renders without crashing the overview", async ({ page }) => {
await mockBackend(page, { onboardingState: "started" });
await seedSession(page);
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/e2e/onboarding-alfredpay-mxn.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ test("Alfredpay MX KYC: continuing in the background, the card reacts to approva

backend.kyc.approved = true;
// No interaction, no reload — the 15s onboarding-status refetch flips the card to approved.
await expect(page.getByRole("button", { name: "View payout accounts" })).toBeVisible({ timeout: 25_000 });
await expect(page.getByRole("button", { name: "View pay-out accounts" })).toBeVisible({ timeout: 25_000 });
});

test("Alfredpay MX KYC: closing on the details form and reloading keeps the card resumable", async ({ page }) => {
Expand Down
10 changes: 5 additions & 5 deletions apps/dashboard/e2e/onramp-journeys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@ const CASES = [
{ currency: "ARS", expected: "CVU" }
] as const;

test("transfer modes are route-backed and Cross-border is complete", async ({ page }) => {
test("transfer modes are route-backed and Fiat-to-Fiat is complete", async ({ page }) => {
const backend = await mockBackend(page);
await seedSession(page);
await page.goto("/transfer");

await page.getByRole("tab", { name: "Cross-border" }).click();
await page.getByRole("tab", { name: "Fiat-to-Fiat" }).click();
await expect(page).toHaveURL(/mode=cross-border/);
await expect(page.getByText("Cross-border transfers are coming soon")).toBeVisible();
await expect(page.getByText("Fiat-to-Fiat transfers are coming soon")).toBeVisible();

await page.reload();
await expect(page.getByRole("tab", { name: "Cross-border" })).toHaveAttribute("data-state", "active");
await expect(page.getByRole("tab", { name: "Fiat-to-Fiat" })).toHaveAttribute("data-state", "active");
expect(backend.unmatchedRequests).toEqual([]);
expect(backend.unexpectedExternalRequests).toEqual([]);
});
Expand Down Expand Up @@ -231,7 +231,7 @@ for (const journey of CASES) {
await page.getByRole("button", { name: "I have made the payment" }).click();
await expect.poll(() => backend.startRequests.length).toBe(1);
await expect(page).toHaveURL(/\/transactions/);
await expect(page.getByText("Onramp", { exact: true })).toBeVisible();
await expect(page.getByText("Pay-in", { exact: true })).toBeVisible();
expect(backend.unmatchedRequests).toEqual([]);
expect(backend.unexpectedExternalRequests).toEqual([]);
});
Expand Down
24 changes: 12 additions & 12 deletions apps/dashboard/e2e/payout-accounts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,23 @@ test("an approved AlfredPay corridor adds a self payout account", async ({ page
await page.goto("/overview");

const card = page.getByTestId("corridor-card-MX");
await expect(card.getByRole("button", { name: "Add payout account" })).toBeVisible({ timeout: 20_000 });
await expect(card.getByText(/to enable reception of money through offramps/)).toBeVisible();
await expect(card.getByRole("button", { name: "Add pay-out account" })).toBeVisible({ timeout: 20_000 });
await expect(card.getByText(/to enable reception of money through pay-outs/)).toBeVisible();

const progress = card.getByRole("progressbar", { name: "Mexico onboarding progress" });
await expect(progress).toHaveAttribute("aria-valuenow", "90");
await expect(progress.locator('[data-slot="progress-indicator"]')).toHaveClass(/bg-primary/);

await card.getByRole("button", { name: "Add payout account" }).click();
await card.getByRole("button", { name: "Add pay-out account" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByText(/enables reception of money through offramps/)).toBeVisible();
await expect(dialog.getByText(/enables reception of money through pay-outs/)).toBeVisible();
await dialog.getByLabel("CLABE").fill("646180157000000004");
await dialog.getByLabel("Account holder name").fill("Vortex E2E CLABE");
await dialog.getByRole("button", { name: "Save payout account" }).click();
await dialog.getByRole("button", { name: "Save pay-out account" }).click();

await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(card.getByRole("button", { name: "View payout accounts" })).toBeVisible();
await expect(card.getByRole("button", { name: "View pay-out accounts" })).toBeVisible();
await expect(progress).toHaveAttribute("aria-valuenow", "100");
await expect(progress.locator('[data-slot="progress-indicator"]')).toHaveClass(/bg-success/);
expect(backend.fiatAccountRequests).toEqual([
Expand Down Expand Up @@ -55,21 +55,21 @@ test("a provider-rejected payout account surfaces field and form errors instead
await page.goto("/overview");

const card = page.getByTestId("corridor-card-MX");
await card.getByRole("button", { name: "Add payout account" }).click();
await card.getByRole("button", { name: "Add pay-out account" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByLabel("CLABE").fill("646180157000000004");
await dialog.getByLabel("Account holder name").fill("Vortex E2E CLABE");
await dialog.getByRole("button", { name: "Save payout account" }).click();
await dialog.getByRole("button", { name: "Save pay-out account" }).click();

await expect(dialog.getByText("This account number was rejected by the payout provider.")).toBeVisible();
await expect(
dialog.getByText("The payout provider rejected this account number. Double-check it and try again.")
).toBeVisible();
// The form stays open for correction and nothing was added.
await expect(dialog.getByRole("button", { name: "Save payout account" })).toBeEnabled();
await expect(dialog.getByRole("button", { name: "Save pay-out account" })).toBeEnabled();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(card.getByRole("button", { name: "Add payout account" })).toBeVisible();
await expect(card.getByRole("button", { name: "Add pay-out account" })).toBeVisible();
expect(backend.fiatAccountRequests).toHaveLength(1);
expect(backend.unmatchedRequests).toEqual([]);
expect(backend.unexpectedExternalRequests).toEqual([]);
Expand All @@ -91,7 +91,7 @@ test("an approved AlfredPay corridor removes a payout account after confirmation
await page.goto("/overview");

const card = page.getByTestId("corridor-card-MX");
await card.getByRole("button", { name: "View payout accounts" }).click();
await card.getByRole("button", { name: "View pay-out accounts" }).click();
const dialog = page.getByRole("dialog");
const remove = dialog.getByRole("button", { name: "Remove account ending in 0004" });
await remove.click();
Expand All @@ -104,7 +104,7 @@ test("an approved AlfredPay corridor removes a payout account after confirmation
await expect(dialog.getByText("Vortex E2E CLABE", { exact: false })).toBeHidden();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(card.getByRole("button", { name: "Add payout account" })).toBeVisible();
await expect(card.getByRole("button", { name: "Add pay-out account" })).toBeVisible();
expect(backend.fiatAccountDeleteRequests).toEqual([{ country: "MX", fiatAccountId: "fiat-account-to-delete" }]);
expect(backend.unmatchedRequests).toEqual([]);
expect(backend.unexpectedExternalRequests).toEqual([]);
Expand Down
6 changes: 5 additions & 1 deletion apps/dashboard/e2e/quote-explorer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ test("EUR is quotable on BUY and an unapproved corridor routes to onboarding", a
await expect.poll(() => backend.quoteRequests.length, { timeout: 20_000 }).toBeGreaterThan(0);
expect(backend.quoteRequests.at(-1)).toMatchObject({ inputCurrency: "EUR", paymentMethod: "sepa", rampType: "BUY" });

const onboardingCta = page.getByRole("link", { name: "Get approved for Europe" });
const onboardingCta = page.getByRole("link", { name: "Start onboarding for Europe" });
await expect(onboardingCta).toBeVisible({ timeout: 20_000 });
await expect(onboardingCta).toHaveAttribute("href", "/overview?onboarding=EU");
await onboardingCta.click();

await expect(page).toHaveURL(/\/overview\?onboarding=EU$/);
await expect(page.getByRole("dialog").getByText("KYC is currently disabled in Europe.")).toBeVisible();

expect(backend.unmatchedRequests).toEqual([]);
expect(backend.unexpectedExternalRequests).toEqual([]);
Expand Down
5 changes: 5 additions & 0 deletions apps/dashboard/e2e/support/mockBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ interface MockBackendOptions {
selectionRequired?: boolean;
// Full response for POST /v1/auth/verify-otp. Default: a successful session.
verifyOtp?: (requestBody: Record<string, unknown>) => { status: number; body: unknown };
verifyTokenValid?: boolean;
// How many GET /v1/ramp/:id polls report an in-progress ramp before flipping to COMPLETE.
pendingStatusPolls?: number;
// Drives the Alfredpay MX KYC endpoints and, unless reflectOnboarding is false, makes
Expand Down Expand Up @@ -475,6 +476,10 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {})
await fulfillJson(result.body, result.status);
return;
}
if (path === "/v1/auth/verify" && method === "POST") {
await fulfillJson({ user_id: E2E_USER_ID, valid: options.verifyTokenValid !== false });
return;
}
if (path === "/v1/auth/refresh" && method === "POST") {
auth.refreshes += 1;
await fulfillJson({ access_token: "e2e-access-token", refresh_token: "e2e-refresh-token", success: true });
Expand Down
13 changes: 6 additions & 7 deletions apps/dashboard/e2e/support/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,17 @@ export const SESSION_KEYS = {
} as const;

/**
* Boots the app already authenticated: useAuthStore reads the session from localStorage at
* module init, so it has to be there before any app script runs. The access token is not a
* JWT on purpose — AuthService.isAuthenticated() treats an undecodable expiry as valid.
* Boots the app with a stored session before any app script runs. The default access token is
* not a JWT on purpose so tests can opt into specific expiry behavior when needed.
*/
export async function seedSession(page: Page) {
export async function seedSession(page: Page, accessToken = "e2e-access-token") {
await page.addInitScript(
({ email, keys, userId }) => {
localStorage.setItem(keys.accessToken, "e2e-access-token");
({ accessToken: token, email, keys, userId }) => {
localStorage.setItem(keys.accessToken, token);
localStorage.setItem(keys.refreshToken, "e2e-refresh-token");
localStorage.setItem(keys.userId, userId);
localStorage.setItem(keys.userEmail, email);
},
{ email: E2E_USER_EMAIL, keys: SESSION_KEYS, userId: E2E_USER_ID }
{ accessToken, email: E2E_USER_EMAIL, keys: SESSION_KEYS, userId: E2E_USER_ID }
);
}
10 changes: 5 additions & 5 deletions apps/dashboard/e2e/transactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,16 @@ test("transaction destinations and failure help are direction-aware", async ({ p
await seedSession(page);
await page.goto("/transactions");

const onrampRow = page.getByRole("row").filter({ hasText: "Onramp" });
const onrampRow = page.getByRole("row").filter({ hasText: "Pay-in" });
await expect(onrampRow).toContainText("0x1111…1111");
await onrampRow.getByRole("button", { name: "Get help" }).click();
await expect(page.getByText("We'll email you about this failed onramp.", { exact: true })).toBeVisible();
await expect(page.getByText("We'll email you about this failed pay-in.", { exact: true })).toBeVisible();

const offrampRow = page.getByRole("row").filter({ hasText: "Offramp" });
await expect(offrampRow).toContainText("Payout account");
const offrampRow = page.getByRole("row").filter({ hasText: "Pay-out" });
await expect(offrampRow).toContainText("Pay-out account");
await expect(offrampRow).not.toContainText("0x2222…2222");
await offrampRow.getByRole("button", { name: "Get help" }).click();
await expect(page.getByText("We'll email you about this failed payout.", { exact: true })).toBeVisible();
await expect(page.getByText("We'll email you about this failed pay-out.", { exact: true })).toBeVisible();

expect(backend.unmatchedRequests).toEqual([]);
expect(backend.unexpectedExternalRequests).toEqual([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,19 @@ export function PayoutAccountsSection({ accounts, corridorId, error, isLoading,
}

if (isLoading) {
return <p className="w-full text-center text-muted-foreground text-sm">Loading payout accounts…</p>;
return <p className="w-full text-center text-muted-foreground text-sm">Loading pay-out accounts…</p>;
}
if (isApiError(error) && error.status === 404) {
return (
<p className="w-full text-center text-muted-foreground text-sm">
Payout setup not available yet — finish verification first.
Pay-out setup not available yet — finish verification first.
</p>
);
}
if (error) {
return (
<div className="grid w-full gap-2 text-center">
<p className="text-muted-foreground text-sm">Couldn't load payout accounts.</p>
<p className="text-muted-foreground text-sm">Couldn't load pay-out accounts.</p>
<Button onClick={refetch} size="sm" type="button" variant="outline">
Retry
</Button>
Expand All @@ -49,16 +49,16 @@ export function PayoutAccountsSection({ accounts, corridorId, error, isLoading,
{savedAccounts.length === 0 ? (
<>
<p className="text-muted-foreground text-xs">
Add a payout account to enable reception of money through offramps. Onramps and third-party payments work without
Add a pay-out account to enable reception of money through pay-outs. Pay-ins and third-party payments work without
one.
</p>
<Button onClick={() => show("form")} type="button">
Add payout account
Add pay-out account
</Button>
</>
) : (
<Button onClick={() => show("list")} type="button" variant="outline">
View payout accounts
View pay-out accounts
</Button>
)}
<FiatAccountDialog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ export function FiatAccountDialog({ accounts, corridorId, onOpenChange, onViewCh
try {
await deleteFiatAccount.mutateAsync(fiatAccountId);
setConfirmingDeleteId(null);
toast.success("Payout account removed");
toast.success("Pay-out account removed");
} catch (error) {
toast.error("Could not remove payout account", { description: error instanceof Error ? error.message : undefined });
toast.error("Could not remove pay-out account", { description: error instanceof Error ? error.message : undefined });
}
}

Expand All @@ -70,17 +70,17 @@ export function FiatAccountDialog({ accounts, corridorId, onOpenChange, onViewCh
await addFiatAccount.mutateAsync(toAddFiatAccountRequest(corridorId, values));
form.reset(fiatAccountDefaultValues(corridorId));
onViewChange("list");
toast.success("Payout account added");
toast.success("Pay-out account added");
} catch (error) {
if (isApiError(error)) {
for (const fieldError of error.data.fields ?? []) {
if (config.fields.some(field => field.name === fieldError.field)) {
form.setError(fieldError.field, { message: fieldError.message });
}
}
form.setError("root.server", { message: error.message || "Could not add payout account" });
form.setError("root.server", { message: error.message || "Could not add pay-out account" });
} else {
form.setError("root.server", { message: "Could not add payout account" });
form.setError("root.server", { message: "Could not add pay-out account" });
}
}
}
Expand All @@ -91,9 +91,9 @@ export function FiatAccountDialog({ accounts, corridorId, onOpenChange, onViewCh
{view === "list" ? (
<>
<DialogHeader>
<DialogTitle>Payout accounts</DialogTitle>
<DialogTitle>Pay-out accounts</DialogTitle>
<DialogDescription>
Accounts available for receiving money through {config.methodLabel}. Payout accounts are not needed for onramps
Accounts available for receiving money through {config.methodLabel}. Payout accounts are not needed for pay-ins
or payments to third-party recipients.
</DialogDescription>
</DialogHeader>
Expand All @@ -105,7 +105,7 @@ export function FiatAccountDialog({ accounts, corridorId, onOpenChange, onViewCh
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">
{account.metadata?.accountHolderName || account.accountName || "Payout account"} · ••••
{account.metadata?.accountHolderName || account.accountName || "Pay-out account"} · ••••
{account.accountNumber.slice(-4)}
</p>
<p className="text-muted-foreground text-xs">{config.methodLabel}</p>
Expand Down Expand Up @@ -138,9 +138,9 @@ export function FiatAccountDialog({ accounts, corridorId, onOpenChange, onViewCh
) : (
<>
<DialogHeader>
<DialogTitle>Add payout account</DialogTitle>
<DialogTitle>Add pay-out account</DialogTitle>
<DialogDescription>
This account enables reception of money through offramps. Onramps and payments to third-party recipients work
This account enables reception of money through pay-outs. Pay-ins and payments to third-party recipients work
without one.
</DialogDescription>
</DialogHeader>
Expand Down Expand Up @@ -204,7 +204,7 @@ export function FiatAccountDialog({ accounts, corridorId, onOpenChange, onViewCh
</Button>
)}
<Button disabled={addFiatAccount.isPending} type="submit">
{addFiatAccount.isPending ? "Saving…" : "Save payout account"}
{addFiatAccount.isPending ? "Saving…" : "Save pay-out account"}
</Button>
</DialogFooter>
</form>
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/src/components/quote/QuoteExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ function QuoteCta({ amount, corridorId, isApproved, isBuy, network, token }: Quo
<Button asChild size="lg">
<Link search={{ onboarding: corridorId }} to="/overview">
<ShieldCheck />
Get approved for {CORRIDORS[corridorId].name}
Start onboarding for {CORRIDORS[corridorId].name}
<ArrowRight />
</Link>
</Button>
Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/src/components/recipients/RecipientDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export function RecipientDialog({
<DialogTitle>Add a recipient</DialogTitle>
<DialogDescription>
Choose who you're paying and name the link. We'll generate an invite link you send them yourself — they complete
KYC/KYB and add their payout details to receive transfers.
KYC/KYB and add their pay-out details to receive transfers.
</DialogDescription>
</DialogHeader>
<Form {...form}>
Expand Down Expand Up @@ -289,7 +289,7 @@ function InviteShare({ created, onDone }: { created: CreatedInvite; onDone: () =
<DialogHeader>
<DialogTitle>Invite link ready</DialogTitle>
<DialogDescription>
Send this {created.corridorName} link to your recipient. They'll complete KYC/KYB and add their payout details — then
Send this {created.corridorName} link to your recipient. They'll complete KYC/KYB and add their pay-out details — then
you can pay them.
</DialogDescription>
</DialogHeader>
Expand Down
Loading
Loading