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:

#LayerWhat it isPCI scope
1Consumer apps → @mvs/mvs-pay-sdkhealthos, mvs-c2, and apps/mvs-pay itself call payments only through the typed Node SDKOut
2apps/mvs-payNext.js 16 merchant + operator UI / BFF on VercelOut
3@mvs/mvs-pay-uiBrowser checkout components wrapping Hyperswitch’s HyperLoaderOut
4apps/mvs-pay-extensionsHono service that wraps Hyperswitch + persists MVS extension dataOut
5Self-hosted Hyperswitch + decision-engine + pay-crons Temporal worker + Neon DBsThe payment core and async plane on EKSRouter + card-vault only

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:

Consumer apps Browser checkout
(healthos, mvs-c2, (any consumer's checkout page)
apps/mvs-pay UI itself) │
│ ▼
│ ┌─────────────┐
▼ │@mvs/mvs-pay │ ◄─ wraps HyperLoader
┌──────────────────┐ │ -ui │ + MVS theme
│@mvs/mvs-pay-sdk │ └──────┬──────┘
│ (typed Node SDK) │ │
└─────────┬────────┘ ▼
│ Hyperswitch HyperLoader
│ HTTPS + internal-secret (browser-side; tokenises
▼ cards directly to vault)
┌─────────────────────────────────┐ │
│ apps/mvs-pay-extensions │ │
│ (Hono service in payments ns) │ │
│ - Wraps Hyperswitch API │ │
│ - Persists MVS extension data │ │
│ - Audit logging │ │
└──┬─────────────────┬────────────┘ │
│ │ │
▼ ▼ ▼
┌────────────┐ ┌─────────────────────────────────────┐
│ MVS Neon │ │ Hyperswitch (self-hosted) │
│ (extensions│ │ router (8080) · producer · consumer │
│ schema) │ │ drainer · control-center · vault │
│ │ │ ── Postgres (hyperswitch-prod) │
│ PayoutMethod │ ── Redis │
│ PlaidItem │ │ Connectors: stripe, adyen, │
│ Device … │ │ cybersource, braintree, finix │
└────────────┘ └─────────────────────────────────────┘

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-side MvsPayClient HTTP 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 the x-internal-secret, x-org-id / x-org-slug, and optional idempotency-key headers, retries 5xx / network errors with exponential backoff (3 retries), and throws every non-2xx as a typed MvsPayApiError.
  • It ships a ./legacy shim subpath (a 30-day cutover overlap for consumers still on the old client) and a ./mock subpath for consumer CI. Published as a linked release group with @mvs/mvs-pay-ui to the Buildkite Package Registry (mvs-cloud/pay/npm).

The consumers today:

ConsumerHow it uses MVS PayState
apps/mvs-payDogfoods the SDK for all server-side payment opsIn-repo, wired
healthosSDK + UI components + sends clinical billing webhooks → extensionsCutover pending (healthos-cutover runbook)
mvs-c2 (apps/platform-admin)SDK for billing operator viewsBroken today — still imports the deleted @mvs/payfac-client (mvs-c2-cutover runbook)

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 an MvsPayClient (via apps/mvs-pay/lib/mvs-pay-client.tsmvsPayClientFromAuth() 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 is apps/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.ts UNIONs transfer + manual_payment rows, always scoped where: { organizationId }.
  • Auth is Better Auth via the central mvs-auth service, 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 in apps/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 by organizationId.

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:

ServiceRole
router (port 8080)Authorization; holds connector credentials in merchant_connector_account
producer + consumerSchedulers
drainerRedis → DB persistence
control-center (v1.38.4)Operator UI for connector + routing config
card-vault (v0.7.0)Tokenizes / stores card data — PCI-scoped

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 dispatches
  • instantPayoutSettlementWorkflow — settles instant payouts
  • residualEstimationWorkflow / residualChecksWorkflow — residual estimation
  • disputeSlaMonitorWorkflow — dispute SLA monitoring
  • deviceHealthMonitorWorkflow — POS device health
  • successRateReconcileWorkflow — success-rate reconciliation
  • hyperswitchEventReplayWorkflow — 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_account config.

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)

1

Consumer → SDK

A consumer app (healthos / mvs-c2 / apps/mvs-pay) calls mvsPay.payments.create(input, { idempotencyKey }) on @mvs/mvs-pay-sdk.

2

SDK → extensions

The SDK sends HTTPS to apps/mvs-pay-extensions /v1/payments with x-internal-secret, org headers, and the forwarded idempotency-key.

3

Extensions → Hyperswitch → connector

Extensions calls the Hyperswitch router REST API, which routes to a connector (Stripe / Adyen / Cybersource / Braintree / Finix), with per-attempt selection delegated to the decision-engine MAB router.

4

Extensions persists the sidecar

Extensions writes MVS extension data (e.g. transfer_extensions: surcharges, tips, healthcare line items, Level 2/3 data) into its Prisma DB, keyed by the Hyperswitch payment_intent ID. Hyperswitch remains source of truth for the payment itself.

consumer ─sdk.payments.create─▶ extensions /v1/payments ─REST─▶ HS router ─▶ connector
└─▶ Prisma: transfer_extensions (keyed by intent id)

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.

1

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 }.

2

Dispatch

The dispatcher fans UPP-normalized event types (PAYMENT_INTENT_SUCCESS, WEBHOOK_REFUND_SUCCESS, WEBHOOK_DISPUTE_OPENED, MANDATE_ACTIVE, PAYOUT_SUCCESS, …) into the MVS extension tables.

3

DLQ drain

Rows that fail dispatch are re-run asynchronously by the pay-crons webhookDlqDrainerWorkflow.

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)

apps/mvs-pay / SDK ─▶ extensions /plaid · /instant-payouts · /payout-methods
├─▶ Plaid (ACH bank-account linking)
│ models: PlaidItem · PayoutMethod · InstantPayoutRequest
└─▶ webhooks-plaid (logs sanitized to {code,message,status};
no access_token/public_token leak)
settlement of instant payouts ──▶ pay-crons instantPayoutSettlementWorkflow (async)

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

For the orchestrated REST contract itself, see the API Reference tab.