Merchant & Operator UI
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
Two host modes, one app
proxy.ts (the Next.js middleware) inspects the request Host header and
splits traffic into two completely different experiences:
- 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. - Tenant subdomains — the public checkout portal. Any other hostname is
treated as
{slug}.mvspay.ioand 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 atmvspay.io. - Central auth cookie —
__Secure-healthos.session_token/healthos.session_token, used for same-domain auth atpay.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.
Sidebar surfaces
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).
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.
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 anApiContext.mvsPaySystemClient()— a memoized, non-org-scoped client for system routes.mvsPayApiError(err, fallback)— maps a thrownMvsPayApiErrorto the app’sapiErrorenvelope (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.
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:
x-api-keyheader — for external integrations (e.g. HealthOS). Validates the key, checksrequiredScope, and optionallyrequireMerchant.- 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:
- Looks up the tenant by
slugin the control plane database (prisma.platformOrganization.findUniquefrom@mvs/control-plane), selectingname,finixMerchantId,finixEnvironment,logoUrl, andbrandColor. - Calls
notFound()(404) if the org has nofinixMerchantId. - Renders
MvsPayTenantPaymentShell(@mvs/ui) branded with the tenant’s logo/color, wrapping thePaymentFlowcomponent (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:
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:
Notable Vercel config:
- Global security headers (HSTS,
X-Content-Type-Options,X-Frame-Options: SAMEORIGIN,Referrer-Policy, aPermissions-Policythat allowspayment=(self)). These complement the app-level CSP fromcreateSecurityHeadersConfig(cspPresets.mvsPay)innext.config.ts. /api/*responses are forcedno-storeat the edge.- Function durations: webhook routes get
maxDuration: 60s, all other API routes30s. - 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.
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.
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.
Additional honest constraints to keep in mind:
- Operator stats and balance are computed client-side.
/api/statsand/api/balancereduce SDKpayments.list/payouts.listwindows 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-sidecapture_methodfilter yet, so/api/authorizationspaginates then filters forcapture_method=manualin memory. - Onboarding gate. The
(dashboard)layout redirects to/onboardingwhensession.onboarding.statusisnot_startedorpending; the full KYC flow behind it is partly the deferred Platform-Org work above.
Where to look next
The Hono service every proxy handler calls.
@mvs/mvs-pay-sdk — the typed client and MvsPayApiError.
@mvs/mvs-pay-ui HyperLoader components the portal renders.
Sessions, cross-domain cookies, and org scoping.
Where mvs-pay sits in the wider deploy.
The extensions /v1/* surface this UI proxies.