Merchant & Operator UI

apps/mvs-pay

apps/mvs-pay is the Next.js 16 (App Router) dashboard that merchants and MVS operators use to run the payment platform, plus the public per-tenant checkout portal served from {slug}.mvspay.io. It is the only browser-facing surface in the system. It holds no connector credentials and no raw card data — those live in Hyperswitch — and it talks to payments exclusively through the @mvs/mvs-pay-sdk, which proxies to the Extensions Service.

The dashboard never calls Hyperswitch directly. Every server route handler either (a) constructs an MvsPayClient and proxies to mvs-pay-extensions, or (b) reads the dedicated mvs-pay Postgres database directly through Prisma. Both paths are described below.

At a glance

PropertyValue
FrameworkNext.js 16.2.6, React 19.2.6, App Router
Dev port3002 (next dev --port 3002 --turbo)
Buildnext build --webpack (not Turbopack — see build notes)
Outputstandalone (outputFileTracingRoot = monorepo root)
State managementReact Query for server state; client components fetch /api/*
UI library@mvs/ui (shared shadcn-studio blocks), @mvs/mvs-pay-ui (HyperLoader checkout)
Deploy targetVercel (region iad1), also runnable as Docker / Node service
AuthCentral auth session cookie (dashboard) or x-api-key (integrations like HealthOS)

Two host modes, one app

proxy.ts (the Next.js middleware) inspects the request Host header and splits traffic into two completely different experiences:

  1. Root domains — the merchant + operator dashboard (pay.mvscloud.com, pay-dev/-staging, mvspay.io, www.mvspay.io, localhost:3002, mvspay.mvsdev.io). These render the (dashboard) route group behind authentication.
  2. Tenant subdomains — the public checkout portal. Any other hostname is treated as {slug}.mvspay.io and rewritten to /_tenants/{slug}/... while the browser URL stays on the tenant subdomain.

The middleware matcher excludes api, healthz, readyz, _next/static, _next/image, and favicon.ico, so health probes and API routes are never gated by the auth/rewrite logic.

Dashboard authentication

proxy.ts looks for a session cookie under two naming families (defined in @mvs/config):

  • Local mvspay.io cookie__Secure-mvspay.session_token (prod) / mvspay.session_token (dev), used for cross-domain auth where the dashboard is reached at mvspay.io.
  • Central auth cookie__Secure-healthos.session_token / healthos.session_token, used for same-domain auth at pay.mvscloud.com.

The protectedPaths list — /overview, /transactions, /settlements, /disputes, /customers, /devices, /settings, /onboarding — triggers a redirect to the environment’s login URL when no session cookie is present. External domains (anything not under mvscloud.com) are sent to the cross-domain token URL instead of the plain login URL so the session can be minted on the right domain. Authenticated requests get an x-pathname response header that server components read (the (dashboard) layout uses it to detect the onboarding page).

The middleware cookie check is a coarse gate: it only checks presence of a cookie, not validity. Real session verification happens server-side in the (dashboard) layout via getMvsPaySession() (which also enforces the onboarding gate) and in every /api/* handler via authenticateRequest. See Authentication & Tenancy.

The sidebar is generated by getMvsPayNavSections() in apps/mvs-pay/lib/nav-config.ts. The grouping and labels there are the source of truth; note that the on-disk route folders are broader than what the sidebar links to (some surfaces are reached contextually rather than from the rail).

GroupSidebar labelRoute (href)Page folderPrimary data path
PaymentsOverview/overview(dashboard)/overviewClient fetches /api/stats, /api/transfers, /api/devices, /api/payment-links, /api/customers, /api/stats/analytics
PaymentsVirtual Terminal/virtual-terminal(dashboard)/virtual-terminalSDK: payments.create({ confirm: true })
PaymentsPayment Links/payment-links(dashboard)/payment-linksSDK: paymentLinks.*
PaymentsTransactions/transactions(dashboard)/transactionsPrisma (raw UNION of card transfers + manual payments)
PaymentsAuthorizations/authorizations(dashboard)/authorizationsSDK: payments.list filtered to capture_method=manual
PaymentsSubscriptions/subscriptions(dashboard)/subscriptionsPrisma BillingSubscription (+ Hyperswitch mandate binding)
FinancialPayouts/settlements(dashboard)/settlementsSDK: payouts.list + Prisma settlementExtension join
FinancialDisputes/disputes(dashboard)/disputesSDK: disputes.list + Prisma disputeExtension join
ManagementCustomers/customers(dashboard)/customersPrisma BillingCustomer
ManagementDevices/devices(dashboard)/devicesSDK: devices.*
BillingCustomers / Products / Subscriptions/billing/*(dashboard)/billingPrisma (MVS billing models)
SettingsMerchant Accounts/settings/merchants(dashboard)/settings/merchantsSDK: merchants.*
SettingsRisk Settings/settings/risk(dashboard)/settings/risk/api/settings/risk
SettingsGeneral/settings(dashboard)/settingsMixed

Connectors, Routing, Revenue Recovery, and Cost Observability ship as full pages under (dashboard)/ (connectors/, routing/, revenue-recovery/, cost-observability/) but are not items in getMvsPayNavSections(). They are reached contextually — e.g. the Connectors page is opened from a merchant account and drives the /api/merchants/[id]/connectors/* routes; there is no top-level /api/connectors handler. Treat the sidebar config, not the folder list, as the canonical “what’s on the rail” answer; if you add a surface to the rail, edit nav-config.ts.

The full settings tree on disk also includes account, api-keys, integrations, mor, payouts, security (with two-factor), surcharging, and webhooks. BREADCRUMB_LABELS in the same file maps URL segments to display names for the breadcrumb component.

Feature deep-dives

The mechanics of each surface live in the Features section:

Route handlers: SDK proxy vs direct Prisma

Every /api/* handler starts the same way — it authenticates the request, then chooses one of two data paths. There is no single rule per resource; many handlers do both (proxy to the SDK for the canonical Hyperswitch object, then join an org-scoped MVS “extension” sidecar from Prisma).

Pattern A — SDK proxy to mvs-pay-extensions

Handlers build a per-request, org-scoped client and forward the call. The SDK injects the x-internal-secret, x-org-id / x-org-slug, and optional idempotency-key headers.

1// apps/mvs-pay/app/api/disputes/route.ts (abridged)
2const auth = await authenticateRequest(request, { requiredScope: "disputes:read" });
3if (!auth.success) return apiError(auth.error, auth.status);
4
5const { organizationId, orgSlug } = auth.context;
6const client = mvsPayClient({ organizationId, organizationSlug: orgSlug });
7const listResp = await client.disputes.list({ limit, offset });

The client factory lives in apps/mvs-pay/lib/mvs-pay-client.ts:

  • mvsPayClient({ organizationId, organizationSlug }) — per-request org-scoped client (cheap to construct: field assignment only).
  • mvsPayClientFromAuth(ctx) — same thing straight from an ApiContext.
  • mvsPaySystemClient() — a memoized, non-org-scoped client for system routes.
  • mvsPayApiError(err, fallback) — maps a thrown MvsPayApiError to the app’s apiError envelope (defaulting to HTTP 502 when the upstream status is absent), so each catch block is one line.

Surfaces that primarily proxy: Virtual Terminal, Payment Links, Authorizations, Devices, Disputes (+ sidecar join), Payouts/Settlements (+ sidecar join), Routing, Revenue Recovery, Cost Observability, Merchant Accounts, Balance and operator Stats (which reduce SDK list responses client-side because Hyperswitch’s list endpoints are not server-side aggregates).

Pattern B — direct Prisma read against the mvs-pay DB

Handlers for MVS-owned models read the dedicated mvs-pay Postgres directly via getMvsPayDb(organizationId) (Prisma client @prisma/mvs-pay-client). The database uses a single shared schema scoped by organizationId.

1// apps/mvs-pay/app/api/transactions/route.ts (abridged)
2const { organizationId } = auth.context;
3const db = getMvsPayDb(organizationId);
4// raw SQL UNION of card transfers + manual payments for correct pagination

Surfaces that primarily read Prisma: Transactions (raw UNION of card transfers and manual_payment), Customers (BillingCustomer), Subscriptions and the Billing surfaces (MVS billing models). The Transactions handler also defensively probes to_regclass('manual_payment') and skips the manual join if the table is missing.

The “extension sidecar” pattern is everywhere: a list call hits Hyperswitch via the SDK, then each row is enriched with an org-scoped MVS row (disputeExtension, settlementExtension, transfer extension, …) fetched from Prisma and re-checked against organizationId before being returned. See Data Model and ADR-008 · MerchantConnector extension.

Request authentication

apps/mvs-pay/lib/api-auth.ts exposes authenticateRequest(request, { requiredScope, requireMerchant }), which returns an ApiContext (org id/slug, finix merchant id, environment) or a typed AuthError. It supports two methods, tried in this order:

  1. x-api-key header — for external integrations (e.g. HealthOS). Validates the key, checks requiredScope, and optionally requireMerchant.
  2. Session cookie — for dashboard users, via getMvsPaySession().

Server components / page-level loaders use getPaymentsContext() / requirePaymentsContext() from apps/mvs-pay/lib/payments-db.ts, which resolve the session, the org id (preferring platformOrg.id), and a getMvsPayDb() handle in one call.

The public checkout portal — {slug}.mvspay.io

Tenant subdomains are rewritten to app/_tenants/[slug]/page.tsx. That page:

  1. Looks up the tenant by slug in the control plane database (prisma.platformOrganization.findUnique from @mvs/control-plane), selecting name, finixMerchantId, finixEnvironment, logoUrl, and brandColor.
  2. Calls notFound() (404) if the org has no finixMerchantId.
  3. Renders MvsPayTenantPaymentShell (@mvs/ui) branded with the tenant’s logo/color, wrapping the PaymentFlow component (apps/mvs-pay/components/payment-flow.tsx), which drives the HyperLoader checkout from @mvs/mvs-pay-ui.

The portal has its own loading.tsx and error.tsx boundaries.

The tenant lookup still selects finixMerchantId / finixEnvironment and the page comment notes the control-plane lookup “should be cached or fetched from the Edge” but currently is not. The finix* naming is a pre-pivot artifact — Finix is now just one connector Hyperswitch can route to (see The Hyperswitch Pivot). The field name is the tenant’s gateway merchant id, not an active Finix integration.

Apple Pay domain association

app/.well-known/apple-developer-merchantid-domain-association/route.ts serves the Apple Pay verification token required on every domain that renders Apple Pay. The token is generated by Hyperswitch when a merchant uploads Apple Pay certs, pasted into Infisical, and exposed at runtime as APPLE_PAY_DOMAIN_ASSOCIATION_PROD (or _DEV). A ?domain= query param looks up per-domain overrides (APPLE_PAY_DOMAIN_ASSOCIATION_<UPPER_SNAKE_SLUG>). Returns 404 when unconfigured. See the Wallet Validation runbook.

Health endpoints

The app exposes lightweight probes that the middleware deliberately skips:

PathBehavior
GET /healthzStatic liveness — returns { status: "ok" }, cache-control: no-store. HEAD returns 200.
GET /readyzReadiness — delegates to the /api/health handler. HEAD mirrors its status.
GET /api/healthReturns { status: "healthy", timestamp, app: "mvs-pay", version }. Used by E2E tests and monitoring.

vercel.json also adds a non-permanent redirect from /health/api/health. All three handlers force runtime = "nodejs" and dynamic = "force-dynamic".

Build and deploy

The app deploys to Vercel (region iad1). vercel.json pins the toolchain and build wiring:

1{
2 "framework": "nextjs",
3 "installCommand": "corepack enable && corepack prepare pnpm@11.1.2 --activate && pnpm install --frozen-lockfile",
4 "buildCommand": "pnpm turbo run build --filter='./apps/mvs-pay'",
5 "regions": ["iad1"],
6 "git": { "deploymentEnabled": { "main": true } }
7}

Notable Vercel config:

  • Global security headers (HSTS, X-Content-Type-Options, X-Frame-Options: SAMEORIGIN, Referrer-Policy, a Permissions-Policy that allows payment=(self)). These complement the app-level CSP from createSecurityHeadersConfig(cspPresets.mvsPay) in next.config.ts.
  • /api/* responses are forced no-store at the edge.
  • Function durations: webhook routes get maxDuration: 60s, all other API routes 30s.
  • Auto-deploy is enabled for main.

The build uses webpack, not Turbopack (next build --webpack) because the webpack config does load-bearing work: a Prisma monorepo plugin on the server build, IgnorePlugin rules to keep the server-only secrets chain (@mvs/secrets, Infisical, AWS SDK credential providers) out of the client bundle, and the cross-repo stub aliasing described next. serverExternalPackages keeps Prisma adapters, the Neon driver, and the Infisical/AWS SDK chain as Node require() (CJS) to dodge ESM named-export interop failures.

Cross-repo stub caveat — read before deploying

next.config.ts aliases 32 @mvs/* module specifiers (CROSS_REPO_STUB_MODULES) to a single build-time no-op stub at apps/mvs-pay/lib/_cross-repo-stub.ts. These packages — @mvs/api, @mvs/auth*, @mvs/auth-config, @mvs/control-plane*, @mvs/database*, @mvs/intercom*, @mvs/openmeter, @mvs/temporal*, @mvs/testing* — live in a different repository and are not vendored into mvs-pay. The stub exists only so pnpm build succeeds locally when those packages are absent from node_modules.

This means a local build is not representative of production. The real deploy build context must provide the genuine @mvs/* packages so the aliases resolve to real implementations; if the stub ships to production, anything that depends on these modules (central auth, the control-plane tenant lookup that powers the public checkout portal, Intercom, Temporal) is silently a no-op.

1

Keep the stub list aligned

CROSS_REPO_STUB_MODULES in next.config.ts must stay in sync with tooling/typescript/cross-repo-stubs.d.ts. Adding a new cross-repo import means updating both.

2

Provide real packages in the deploy build

The deploy build must overlay the actual cross-repo @mvs/* packages before pnpm turbo run build so the webpack aliases resolve to real code rather than the no-op stub.

3

Verify the control-plane path post-deploy

Because @mvs/control-plane is stubbed, smoke-test a real {slug}.mvspay.io checkout portal after deploy — a stubbed control-plane client returns nothing and the portal 404s.

See the Cross-repo Prisma Build runbook and ADR-006 · Extensions as a separate app for the boundary rationale.

Honest gaps and stubs

These are intentional 501 Not Implemented (or deferred) responses in the current app — not bugs. They return honest errors rather than calling the decommissioned pre-pivot payfac gateway.

RouteStatusReason
POST /api/billing/checkout-sessions501Hosted-checkout backend doesn’t exist post-pivot; extensions mounts no checkout-session route and the SDK has no checkout namespace.
POST /api/billing/customers/[id]/portal-session501Same hosted-portal backend gap.
POST /api/onboarding/resubmit501Merchant KYC re-verification (full Platform-Org KYC via Hyperswitch) is ADR-deferred — no Hyperswitch equivalent.
POST /api/onboarding/remediation501Same KYC-remediation deferral.

Additional honest constraints to keep in mind:

  • Operator stats and balance are computed client-side. /api/stats and /api/balance reduce SDK payments.list / payouts.list windows because Hyperswitch exposes no server-side aggregate or balance endpoint. The stats route is route-cached (revalidate = 60) to bound SDK call volume.
  • Authorizations filter client-side. payments.list() has no server-side capture_method filter yet, so /api/authorizations paginates then filters for capture_method=manual in memory.
  • Onboarding gate. The (dashboard) layout redirects to /onboarding when session.onboarding.status is not_started or pending; the full KYC flow behind it is partly the deferred Platform-Org work above.

Where to look next