Architecture
The five-layer system
MVS Pay is a payment-orchestration monorepo for MVS Cloud. It was pivoted from a
Finix-direct architecture onto self-hosted Hyperswitch (Juspay’s open-source
orchestration platform, OSS v1.123.1, Apache-2.0). All planned work — Phases 0
through 5, milestones M0–M28 — is built and merged to main, followed by two
rounds of gap-audit remediation. The application code is largely complete and
the repo is green; what is not done is live infrastructure provisioning,
cross-repo consumer cutovers, and a handful of honest 501 surfaces. Those gaps
are flagged throughout this page and detailed in
Deployment Readiness.
The system is best understood as five layers, top to bottom:
The defining architectural theme is maximum native-Hyperswitch bias (ADR-007): Smart Retries, decision-engine routing, Cost Observability, and Revenue Recovery are all delegated to Hyperswitch. MVS keeps only what Hyperswitch does not model — Plaid instant payouts, POS devices, residuals, custom billing, and subscription state — in ~15 Prisma models plus sidecar extension tables keyed by opaque Hyperswitch IDs.
The single most important invariant: apps/mvs-pay-extensions is the only
thing that calls Hyperswitch. Consumer apps talk to the SDK; the SDK talks to
extensions; extensions talks to Hyperswitch. Browser card data is the one
exception — it goes straight to the Hyperswitch vault via HyperLoader, never
through MVS code.
The system diagram
The red-tinted nodes (Hyperswitch core + connectors) are the PCI-scoped surface. Everything else — both SDKs, the UI, extensions, the Temporal worker — is out of scope because it only ever sees Hyperswitch IDs and tokenized references. See the PCI boundary below.
The same picture as an ASCII sketch, matching the canonical diagram in
docs/architecture/hyperswitch-pivot.md:
Layer 1 — Consumer apps and @mvs/mvs-pay-sdk
Other MVS apps consume payments as a package, never by talking to Hyperswitch directly. The published, typed Node SDK is the single entry point.
@mvs/mvs-pay-sdk— a server-sideMvsPayClientHTTP client. Consumers import it and call namespaced methods (payments,refunds,customers,disputes,mandates,payouts/instantPayouts/payoutMethods,devices,merchants,routing/smartRetries,billableItems,notifications,costObservability,revenueRecovery). It transparently injects thex-internal-secret,x-org-id/x-org-slug, and optionalidempotency-keyheaders, retries5xx/ network errors with exponential backoff (3 retries), and throws every non-2xx as a typedMvsPayApiError.- It ships a
./legacyshim subpath (a 30-day cutover overlap for consumers still on the old client) and a./mocksubpath for consumer CI. Published as a linked release group with@mvs/mvs-pay-uito the Buildkite Package Registry (mvs-cloud/pay/npm).
The consumers today:
mvs-c2’s apps/platform-admin still imports the deleted @mvs/payfac-client
and will not build until it cuts over to @mvs/mvs-pay-sdk. This is a known
cross-repo gap, not a defect in this repo. See the
mvs-c2 cutover runbook.
For the full namespace catalog, error envelope, and rate limits, see The SDK.
Layer 2 — apps/mvs-pay, the Next.js UI / BFF
apps/mvs-pay is a Next.js 16 (App Router, React 19) merchant + operator
dashboard plus a public per-tenant checkout portal. It is the UI and
backend-for-frontend layer over the pivot. Deployed to Vercel at
pay.mvscloud.com today; the planned production host is www.mvspay.io (see
Domain & DNS).
How it talks to the rest of the system:
- Server-side route handlers under
app/api/*construct anMvsPayClient(viaapps/mvs-pay/lib/mvs-pay-client.ts—mvsPayClientFromAuth()builds an org-scoped client,mvsPayApiError()maps SDK errors to an HTTP envelope) and proxy orchestration calls to extensions. Example: the virtual-terminal sale path isapps/mvs-pay/app/api/virtual-terminal/route.ts. - Direct reads of the dedicated MVS Postgres go through Prisma
(
apps/mvs-pay/lib/payments-db.ts) for sidecar/extension data — e.g.app/api/transactions/route.tsUNIONstransfer+manual_paymentrows, always scopedwhere: { organizationId }. - Auth is Better Auth via the central
mvs-authservice, with two session paths: a central same-domain cookie and a local JWT cookie minted by a cross-domain token-exchange callback (app/api/auth/callback/route.ts).
Cross-repo stub trap. apps/mvs-pay/next.config.ts aliases ~30 cross-repo
@mvs/* packages (@mvs/auth, @mvs/control-plane, @mvs/intercom,
@mvs/temporal, @mvs/database, …) to a no-op Proxy at
apps/mvs-pay/lib/_cross-repo-stub.ts for in-repo builds. The production deploy
build context must inject the real packages into node_modules before
next build, or auth/control-plane/Intercom silently return undefined — a
broken-but-green deploy. Verify in the running image, not in CI. Detailed in
Deployment Readiness.
The UI’s surface (transactions, payouts, disputes, customers, devices, payment
links, billing, connectors, routing, revenue recovery, virtual terminal,
onboarding, settings, and the public {slug} checkout portal) is documented in
Merchant & Operator UI.
Layer 3 — @mvs/mvs-pay-ui, browser checkout via HyperLoader
@mvs/mvs-pay-ui is a published, web-only (ADR-009) React component library
that wraps Hyperswitch’s HyperLoader Web SDK with MVS theming. Exported
components include:
<MvsPayCheckout>— the full embedded checkout<MvsPayPaymentMethodForm>— a payment-method capture form<MvsPayCheckoutLink>— a hosted-link launcher<MvsPayInstantPayoutPicker>— Plaid-backed payout-destination picker<MvsPaySavedCardsWidget>— saved payment-method widget
The loader lives in packages/mvs-pay-ui/src/hyper-loader.ts. loadHyperLoader
is idempotent and pulls Hyperswitch’s HyperLoader.js (default
https://beta.hyperswitch.io/v1/HyperLoader.js) using
NEXT_PUBLIC_HYPERSWITCH_PUBLISHABLE_KEY. Apple Pay and Google Pay are handled
through HyperLoader; Click to Pay V2 is enabled purely by adding ctp_visa /
ctp_mastercard connectors — no separate UI component.
This layer is what makes the PCI boundary collapse work: the browser tokenizes
the raw card directly into the Hyperswitch card-vault, so the PAN never
transits apps/mvs-pay or apps/mvs-pay-extensions. See
Checkout UI Components.
Layer 4 — apps/mvs-pay-extensions, the Hono orchestration service
The internal, service-to-service orchestration layer (Hono 4.12.23, dev port
:3005) running on EKS in the payments namespace. It is the only component
that calls Hyperswitch.
Responsibilities:
- Wraps Hyperswitch’s REST API. SDK namespaces map onto
/v1/*routes:payments,refunds,customers,disputes,mandates,payouts/instant-payouts/payout-methods,devices,merchants,routing/routing-dynamic/smart-retries,revenue-recovery,cost-observability,payment-links,billable-items,notifications,iias-export,payment-method-sessions. Route source lives inapps/mvs-pay-extensions/src/routes/. - Persists MVS-specific extension data in its own Neon Postgres via Prisma — healthcare line items, surcharges, tips, Level 2/3 data, POS device inventory, Plaid instant payouts, residuals.
- Ingests webhooks at three signature-verified routes (
webhooks-hyperswitch,webhooks-plaid,webhooks-revenue-recovery) — see the webhook flow. - Carries rate-limit, idempotency, and audit-log (
phiAudit.logAccess) middleware. Every DB query is tenant-scoped byorganizationId.
It holds the MVS_PAY_EXTENSIONS_INTERNAL_SECRET (service auth) and the
HYPERSWITCH_WEBHOOK_SECRET / payment_response_hash_key (webhook HMAC
verification). It holds no raw card data and no connector credentials.
Auth is deferred (ADR-010) — the one real security gap. Extensions trusts a
single shared MVS_PAY_EXTENSIONS_INTERNAL_SECRET. Per-user mvs-auth JWT
validation is a separate plan; until it lands, an upstream consumer compromise
has full payments blast radius, and phiAudit.logAccess records
organizationId but a null userId. Mitigated by rate-limiting, WAF, and
quarterly secret rotation. See
ADR-010.
Why a separate Hono service rather than Next.js API routes (ADR-006): it gives a clean PCI / non-PCI boundary, lets cross-repo consumers call it directly, and unlocks EKS operational features (mTLS, network policies) that Vercel functions lack. Why Hono rather than tRPC (ADR-003): tRPC’s coupled types don’t cross repo boundaries; a stable HTTP contract fronted by the typed SDK gives equivalent safety. Full detail in Extensions Service.
Honest 501 / 503 surfaces. Several routes are not yet implemented and
return explicit, honest errors rather than faking success: terminal device
commands (activate / action / idle-image), hosted checkout-sessions, the
customer billing portal-session, and onboarding remediation / resubmit return
501. Config-gated integrations (Plaid, payment-link email, and the gateway
itself) return 503 until their secrets exist — app.ts /readyz returns
503 {reason: "hyperswitch_unconfigured"} until Hyperswitch is configured.
These are deliberate, not bugs.
Layer 5 — The payment core and async plane
Hyperswitch (self-hosted)
The payment orchestration platform and the source of truth for
payment_intent, payment_attempt, refund, dispute, mandate, customer,
payment_method, merchant_account, connector configs, and routing rules.
Self-hosted on the mvs-prod EKS cluster, payments namespace, via the
hyperswitch-app Helm chart. It comprises:
Backed by its own Neon Postgres (hyperswitch-prod) and Redis. Fronts five
connectors via merchant_connector_account: Stripe (HeaderKey), Adyen
(SignatureKey), Cybersource (SignatureKey), Braintree (BodyKey), and
Finix (BodyKey) — Finix is now one connector among many, no longer the
platform. Connectors ship empty and are configured post-deploy via Control
Center; raw credentials live only here.
decision-engine
juspay/decision-engine v1.4.16 — Hyperswitch’s multi-armed-bandit (MAB) /
Dynamo intelligent routing engine. Deployed on the payments namespace via a
custom in-repo Helm chart (deploy/decision-engine/) authored in Phase 4,
because no first-party chart exists upstream. It drives per-attempt connector
selection. See the decision-engine runbook.
pay-crons Temporal worker
packages/temporal-workflows (@mvs/pay-temporal-workflows) builds the
pay-crons worker, deployed on the payments namespace. It runs the
async/batch plane and is out of PCI scope. The registered workflows
(src/workflows/, scheduled via src/schedules/pay-crons.ts):
webhookDlqDrainerWorkflow— re-runs failed webhook dispatchesinstantPayoutSettlementWorkflow— settles instant payoutsresidualEstimationWorkflow/residualChecksWorkflow— residual estimationdisputeSlaMonitorWorkflow— dispute SLA monitoringdeviceHealthMonitorWorkflow— POS device healthsuccessRateReconcileWorkflow— success-rate reconciliationhyperswitchEventReplayWorkflow— event replay
See Temporal Worker.
Data: packages/mvs-pay
The dedicated Prisma client wrapper + schema for the MVS extensions DB
(MVS_PAY_DATABASE_URL, Neon mvs-pay-extensions-prod; generated client
@prisma/mvs-pay-client). It holds ~15 MVS-specific models plus sidecar
extension tables keyed by opaque Hyperswitch ID strings — no FK across DBs;
reads join via the SDK, not SQL.
The schema was squashed to a single authoritative baseline migration
(packages/mvs-pay/prisma/migrations/20260101000000_squashed_baseline/: 30
tables, 14 enums, 86 indexes, 40 FKs) after a gap audit found the original
8-migration history non-deployable. A later
20260201000000_add_payment_link_extension migration extends it. Migrations are
forward-only; the pre-squash originals are archived and explicitly
non-deployable. See Data Model and ADR-004.
Why Neon (ADR-004): branchable dev/staging + built-in PITR, two projects
(hyperswitch-prod router DB, mvs-pay-extensions-prod MVS data), with
PgBouncer pooling required for Vercel cold-start connection storms.
The PCI boundary
Pre-pivot, apps/payfac-gateway was the sole PCI-scoped process, holding raw
Finix credentials and brokering every card-data round-trip.
Post-pivot, the boundary collapses to just the Hyperswitch router +
card-vault. Everything else — apps/mvs-pay, apps/mvs-pay-extensions, the
pay-crons worker, and both published packages — is out of scope, because:
- Card data is captured client-side via HyperLoader straight into the vault.
- MVS code only ever sees Hyperswitch IDs + tokenized references, never raw PANs.
- Raw connector credentials live only in Hyperswitch’s
merchant_connector_accountconfig.
This is a major simplification: tainted node pools, restricted egress, weekly secret rotation, and per-process attestations apply only to the Hyperswitch services, not to MVS application code. The PCI DSS L1 SAQ-D attestation and per-connector BAAs are tracked in the security cadence runbook.
End-to-end data flows
The flows below are the load-bearing paths through the system. They are drawn
from the verified architecture map and docs/HANDOFF.md §2.
Charge / payment authorization (server-initiated)
Consumer → SDK
A consumer app (healthos / mvs-c2 / apps/mvs-pay) calls
mvsPay.payments.create(input, { idempotencyKey }) on @mvs/mvs-pay-sdk.
SDK → extensions
The SDK sends HTTPS to apps/mvs-pay-extensions /v1/payments with
x-internal-secret, org headers, and the forwarded idempotency-key.
Browser tokenization (the PCI-isolating flow)
The consumer checkout page renders <MvsPayCheckout>; HyperLoader tokenizes the
card directly into the vault using the publishable key; subsequent
confirm/capture goes back through the normal SDK → extensions → router path with
only IDs and tokenized references.
Webhook ingest (the async state machine)
Hyperswitch delivers payment-outcome webhooks to POST /webhooks/hyperswitch on
extensions (src/routes/webhooks-hyperswitch.ts). These are not
internal-secret authenticated; the raw body is HMAC-verified
(x-webhook-signature-512, with a x-webhook-signature-256 fallback) using the
merchant’s payment_response_hash_key.
Verify + persist
The signature is verified, then the event is persisted as an idempotent
WebhookDeliveryAttempt row keyed on Hyperswitch’s event_id — so redeliveries
collapse onto one drain target. An already-processed row acknowledges with
{ ok: true, replay: true }.
Two adjacent signature-verified ingress routes exist: POST /webhooks/revenue-recovery and POST /webhooks/plaid. Full event catalog in
the Developer Guide /
Payments & Refunds.
Instant payout / ACH (Plaid)
The UI / SDK drive Plaid bank-account linking through the extensions /plaid,
/instant-payouts, and /payout-methods routes. Plaid webhooks land at
webhooks-plaid.ts (error logs are sanitized to {code, message, status} —
no token leakage). Actual settlement is performed asynchronously by the
pay-crons worker. See Payouts & Instant Payouts
and the Plaid Link runbook.
Revenue recovery / dunning (ADR-011, a two-way seam)
Per ADR-011, MVS hands dunning/retry orchestration to Hyperswitch Revenue
Recovery (a per-feature instance of the native-bias principle). MVS configures
the retry plan and registers a webhook URL via the SDK; Hyperswitch owns retry
timing, BIN-aware curves, and per-attempt connector selection. Outcomes return
to webhooks-revenue-recovery.ts (HMAC-verified) and single-writer update
BillingSubscription recovery fields; the subscription state machine
(ACTIVE → PAST_DUE → UNPAID) stays MVS-side. The whole feature is behind a
flag so it can be rolled back while keeping the SDK contract. See
Revenue Recovery and
ADR-011.
Smart Retries is a distinct, lower-level mechanism: native decline recovery
driven by per-profile is_auto_retries_enabled + max_auto_retries_enabled
flags and per-connector rules seeded in Hyperswitch’s gateway_status_map
table. Retries execute natively inside Hyperswitch (self-serve OSS, no Juspay
handshake). See Routing & Smart Retries.
Where to go next
Why we replaced the Finix-direct platform and what changed.
How the monorepo is laid out, package by package.
The Hono service in detail — routes, middleware, webhooks.
The 11 ADRs that locked these choices.
Where each component runs and what’s provisioned.
The honest gap list before going live on www.mvspay.io.
For the orchestrated REST contract itself, see the API Reference tab.