The Hyperswitch Pivot & PCI Boundary

Why and how

MVS Pay used to be a Finix-direct payment platform. It is now a thin set of MVS extensions wrapped around a self-hosted Hyperswitch orchestration stack. This page explains why that pivot happened, what was deleted, the design principle that governs every feature decision since (ADR-007), and how the PCI scope collapsed onto two Hyperswitch containers. If you are taking over the platform, read this before anything else — almost every other decision in the codebase is downstream of it.

The pivot is complete (status as of 2026-05-28). All milestones M0–M28 have landed plus a gap-audit remediation pass. The narrative here records the original rationale; where the built state differs from the original plan, this page calls it out. Source of truth for the build status is docs/architecture/hyperswitch-pivot.md.

Why we pivoted

The Phase 4–7 work had already productionized MVS Pay as a Finix-direct platform. That was a large, working surface — and it was replaced wholesale. Concretely, the Finix-direct architecture was:

Pre-pivot artifactSizeFate
apps/payfac-gateway45 Finix-backed routesDeleted → rewritten as apps/mvs-pay-extensions
packages/payments~2,700-line Finix clientDeleted
packages/payfac-jobs8 Finix sync workersDeleted → rebuilt as the pay-crons worker
packages/mvs-pay/prisma/schema.prisma43-table Finix-entity schemaCut to ~15 tables + extension/sidecar tables

The replacement: rebuild on top of Hyperswitch, Juspay’s open-source payment orchestration platform. Hyperswitch becomes the source of truth for the payment domain — payment_intent, payment_attempt, refund, dispute, mandate, customer, payment_method, merchant_account, connector configs, and routing rules. Finix is demoted from “the platform” to “one connector among many,” plugged in through Hyperswitch’s merchant_connector_account surface (see The five connectors below).

The motivation

The Finix-direct path meant MVS owned, forever, the full payment-orchestration problem: connector adapters, routing logic, retry rules, dunning, settlement ledgers, dispute evidence handling, cost analytics. Hyperswitch already solves all of that as open-source under Apache-2.0 and is actively maintained. Rebuilding it MVS-side was a permanent maintenance tax competing against upstream — the exact trap ADR-007 was written to prevent.

Pivoting also unlocked features MVS would otherwise have had to build from scratch: Smart Retries, decision-engine multi-armed-bandit (MAB) routing, Cost Observability, Revenue Recovery, and OLAP analytics all ship as native Hyperswitch features we configure rather than implement.

Product reframing: MVS Pay is now a package

The pivot also changed what MVS Pay is. It is no longer just an app — it is a package other MVS apps (healthos, mvs-c2, future apps) consume. Two npm packages ship from the Buildkite Package Registry (mvs-cloud/pay/npm):

PackageLayerAudience
@mvs/mvs-pay-sdkNode, server-sideOther MVS apps. Typed wrapper over the extensions HTTP API. They never touch Hyperswitch directly.
@mvs/mvs-pay-uiReact, browser-sideApps embedding checkout. Wraps Hyperswitch’s HyperLoader Web SDK with MVS theming.

The SDK calls apps/mvs-pay-extensions — a Hono service (renamed from payfac-gateway) that orchestrates Hyperswitch and persists only MVS-specific extension data (Plaid-backed instant payouts, POS device management, residuals, custom billing). The operator UI at apps/mvs-pay consumes the same SDK and dogfoods @mvs/mvs-pay-ui for embedded flows.

For the surface-by-surface breakdown of these packages and apps, see Surfaces → Extensions Service, Surfaces → The SDK, and Surfaces → Checkout UI Components.

What was deleted

The pivot is a deletion story as much as an addition story. Packages removed:

  • packages/payments — the Finix provider / client. Gone entirely. There is no longer any direct Finix HTTP client in the repo; Finix is reached only through Hyperswitch’s connector adapter.
  • packages/payfac-client — collapsed into @mvs/mvs-pay-sdk.
  • apps/payfac-gateway — rewritten as apps/mvs-pay-extensions.
  • packages/payfac-jobs (the 8 Finix sync workers) — replaced by the pay-crons worker, which now ingests Hyperswitch webhooks instead of polling Finix.

And one rename: packages/payments-uipackages/mvs-pay-ui.

These paths are gone from the working treeapps/payfac-gateway, packages/payments, and packages/payfac-client no longer exist. If you find a reference to them, it is stale documentation or a comment, not live code. The last commit where the Finix-direct architecture existed is tagged pre-hyperswitch-pivot; git checkout pre-hyperswitch-pivot restores it if a recovery is ever needed.

Schema: 43 tables → ~15

The Prisma schema in packages/mvs-pay/prisma/schema.prisma was cut from 43 models to roughly 15. 22 models were dropped because Hyperswitch already models the same concept and is now authoritative:

Dropped MVS modelNow lives in Hyperswitch as
MerchantAccountmerchant_account + business_profile
Transferpayment_intent + payment_attempt
Authorizationpayment_attempt (capture_method=manual)
Settlement, SettlementEntrypayouts + ledger
Dispute, DisputeEvidencedispute + file_metadata
Feepayment_attempt.fee_amount + ledger
SubscriptionPlan, Subscriptionmandate + recurring intent cycle
FeeProfile, RiskProfilemerchant pricing + routing rules
OnboardingForm, RemediationRequirementonboarding state
ComplianceFormRecord, VerificationRecord, IdentityRecordverification + identity
PayoutRecordpayouts + payout_attempts
WebhookEventevents
SyncStatus, SettlementQueueEntryHyperswitch’s own scheduler / event outbox

What stays are the ~11–12 MVS-specific models Hyperswitch does not model: PayoutMethod, InstantPayoutRequest, PlaidItem (Plaid ACH instant payouts), Device (POS terminal inventory — Hyperswitch does not manage POS), BillableItem (healthcare line items), ManualPayment (cash/check), Purchase, Notification, AdminAuditLog, and CheckoutSession + CheckoutSessionItem.

For data that is MVS-specific but bound to a Hyperswitch entity, the codebase uses sidecar extension tables keyed by Hyperswitch’s opaque ID strings — no foreign key crosses the DB boundary (Hyperswitch’s DB is a separate Neon project):

merchant_extensions(hyperswitch_merchant_id, surcharge_percent, surcharge_cap, ...)
transfer_extensions(hyperswitch_payment_intent_id, healthcare_line_items, surcharges, tips, level_2_3_data, ...)
settlement_extensions(hyperswitch_payout_id, approval_status, approved_by, platform_notes, ...)
dispute_extensions(hyperswitch_dispute_id, assigned_to_user_id, platform_notes, ...)
remediation_requirement_extensions(hyperswitch_merchant_id, custom_remediation_state, ...)

The pattern: every extension table has a hyperswitch_*_id string column (indexed, no FK) plus MVS-only columns. Reads join through the SDK, never via SQL across DBs. The per-MCA variant of this pattern — MerchantConnectorExtension — is its own ADR (ADR-008).

The eight original phase migrations were squashed to a single baseline at packages/mvs-pay/prisma/migrations/20260101000000_squashed_baseline/. The originals are archived (not live) under packages/mvs-pay/_archive/broken-migrations-pre-squash/. Full schema walkthrough is in Concepts → Data Model.

ADR-007: the native-Hyperswitch bias

The single principle that governs feature work post-pivot:

Default: surface every Hyperswitch feature through the SDK or UI. Override only via a counter-ADR explaining why an MVS-side rebuild is justified.ADR-007

The reasoning is the same one that motivated the pivot at all: MVS picked Hyperswitch specifically for its orchestration features, so rebuilding them MVS-side throws away the reason for choosing it and adds permanent maintenance burden. The rejected alternative (“MVS-first: rebuild what we can for stronger control”) loses on exactly that trade.

What this looks like in practice:

  • Native features are first-class consumers, not reimplementations. Smart Retries, decision-engine MAB routing, Cost Observability, and Revenue Recovery are configured in Hyperswitch and exposed through the SDK/UI — MVS writes no orchestration logic for them. See Features → Routing & Smart Retries, Features → Revenue Recovery, and Features → Cost Observability.
  • MVS-side rebuilds require an explicit ADR. Anything MVS builds itself — custom IIAS substantiation, custom HSA receipts, custom Plaid Link UX — must justify in an ADR why Hyperswitch cannot do it. These exist because they are genuinely MVS-domain-specific (healthcare FSA/HSA, ACH instant payouts), not because rebuilding felt nicer.

When you are about to add a feature, the first question is always: does Hyperswitch already do this? If yes, you wire it through the SDK. If no, you write an ADR.

Why self-hosted at all? (ADR-001)

ADR-001 locks in self-hosting the open-source code on EKS rather than using Juspay’s managed/hosted tier. MVS never uses the managed tier or paid features.

Self-hosted (chosen)Juspay-hosted (rejected)
Routing / GSM / decision-engine controlFullLimited
Smart Retries enablementSelf-serve (no support handshake)Requires Juspay support
Trust boundaryOn MVS infrastructureOff MVS infrastructure
Operational burden (upgrades, DR, scaling)MVS owns itJuspay owns it
PCI scopeCard-vault in MVS environment; MVS owns the AOCLargely on Juspay

The decisive factor was the trust boundary: hosting moves card data and routing config off MVS infrastructure and gates self-serve features behind a support handshake. The cost is operational ownership — MVS owns the upgrade cadence (see Operations → Hyperswitch Upgrade), DR, and scaling — and the PCI Attestation of Compliance for the card-vault.

The target architecture

A few load-bearing facts about this topology:

  • Cards never transit MVS code. The @mvs/mvs-pay-ui HyperLoader tokenizes card data directly from the browser into the Hyperswitch card-vault. The extensions service only ever sees tokenized references and Hyperswitch IDs.
  • Two Neon Postgres projects, deliberately separate. hyperswitch-prod for Hyperswitch’s router DB; mvs-pay-extensions-prod for the MVS extensions DB. The no-cross-FK rule above exists precisely because these are different databases. (Neon-over-RDS is ADR-004.)
  • The SDK→extensions hop is authenticated by an internal shared secret. Full MVS-tenant auth (mvs-auth) is deferred — see ADR-010 and Concepts → Authentication & Tenancy. Do not assume per-user auth exists yet at the extensions boundary.
  • Everything except apps/mvs-pay runs in the EKS payments namespace. The operator UI deploys to Vercel; the extensions service, the pay-crons worker, self-hosted Hyperswitch, and the decision-engine all run in EKS. See Infrastructure → Deployment Topology.

The PCI boundary collapse

This is the headline benefit of the pivot.

1

Before: payfac-gateway was the sole PCI-scoped process

apps/payfac-gateway held raw Finix credentials and brokered every card-data round-trip. The entire gateway process was in PCI scope.

2

After: scope collapses to two Hyperswitch containers

The PCI boundary is now just the Hyperswitch card-vault + router containers. mvs-pay-extensions and the pay-crons worker see only Hyperswitch IDs and tokenized references — they never hold connector credentials, never receive raw card data, and are out of PCI scope.

3

Result: PCI controls apply to a tiny, fixed surface

Tainted node pools, restricted egress, weekly secret rotation, and per-process attestations apply only to the Hyperswitch services — not to any MVS application code. This is a major simplification of the audit surface.

“Out of PCI scope” is a property of the data flow, not a permanent guarantee. It holds only as long as MVS code never touches raw PAN. If you add a flow that makes the extensions service or any MVS app handle raw card data (rather than a HyperLoader token), you pull that surface back into PCI scope and break the AOC. Keep tokenization in the browser, against the vault. Per ADR-001, MVS owns the AOC for the card-vault.

Operational detail — rotation cadence, node-pool isolation, secret paths — lives in Infrastructure → Secrets Management, Operations → Secrets Rotation, and Operations → Security Cadence.

The five connectors behind merchant_connector_account

Hyperswitch’s connector abstraction is what makes Finix “one connector among many.” A connector is wired to a merchant via a merchant_connector_account (MCA) — identified by an mca_xxx… ID — which carries the connector’s credentials, enabled payment methods, and test/prod mode. Five connectors are supported:

Connectorconnector_nameNotes
StripestripeMulti-connector fallback / routing target
AdyenadyenMulti-connector fallback / routing target
CybersourcecybersourceMulti-connector fallback / routing target
BraintreebraintreeMulti-connector fallback / routing target
FinixfinixDay-1 default card + ACH connector. Auth shape: HTTP basic mapped to Hyperswitch auth_type: "BodyKey" (api_key = Finix username, key1 = Finix password)

Finix’s special status is historical, not architectural. It is the day-1 default routing target only because it was the incumbent. In the connector model it is just another row in merchant_connector_account with no privileged position.

How a connector is wired

The hierarchy is merchant_accountbusiness_profilemerchant_connector_account → routing rule. Wiring a connector means POSTing its credentials to Hyperswitch’s admin API (or doing the equivalent in the Control Center UI):

$# Add Finix as a merchant_connector_account on a profile
$curl -sS -X POST "$HYPERSWITCH_BASE_URL/account/$MERCHANT_ID/connectors" \
> -H "Content-Type: application/json" \
> -H "api-key: $HYPERSWITCH_ADMIN_API_KEY" \
> -d '{
> "connector_type": "fiz_operations",
> "connector_name": "finix",
> "profile_id": "'"$PROFILE_ID"'",
> "connector_label": "finix-prod",
> "test_mode": false,
> "connector_account_details": {
> "auth_type": "BodyKey",
> "api_key": "'"$FINIX_USERNAME"'",
> "key1": "'"$FINIX_PASSWORD"'"
> }
> }'
$# → capture the returned merchant_connector_id (mca_xxx…)

Routing then points traffic at one or more MCAs. Day-1 ships single-connector routing (algorithm.type: "single", Finix only). Multi-connector priority/fallback routing across Stripe / Adyen / Cybersource / Braintree, and auth-rate-based dynamic selection, is the decision-engine’s job:

1{
2 "algorithm": {
3 "type": "priority",
4 "data": [
5 { "connector": "finix", "merchant_connector_id": "mca_finix_xxx" },
6 { "connector": "stripe", "merchant_connector_id": "mca_stripe_xxx" },
7 { "connector": "adyen", "merchant_connector_id": "mca_adyen_xxx" },
8 { "connector": "cybersource", "merchant_connector_id": "mca_cybersource_xxx" },
9 { "connector": "braintree", "merchant_connector_id": "mca_braintree_xxx" }
10 ]
11 }
12}

Click to Pay is not a UI component — it is enabled as connectors (ctp_visa, ctp_mastercard), so adding it is a connector-onboarding task, not a frontend one. The full live-API surface for connectors is in the API Reference tab.

What is honest about gaps

Internal documentation should be honest about loose ends. As of this writing:

  • Auth is deferred. The SDK↔extensions boundary uses an internal shared secret; full mvs-auth tenant integration is a follow-on plan (ADR-010). Treat per-user authorization at the extensions service as not-yet-built.
  • A handful of M3 schema “design calls” were resolved by recommendation, and some remain pragmatic choices rather than settled doctrine — notably whether the MVS billing-platform models stay a separate domain (recommended: separate) and whether /payment-links and /onboarding keep MVS-custom surfaces or defer to Hyperswitch. The built schema reflects the “keep MVS-custom where the workflow differs” stance, but expect to revisit these as Hyperswitch’s payment-link and onboarding surfaces mature.
  • Validation notes from the May 2026 Hyperswitch v1.123.1 sweep still apply: Click to Pay V1 (VisaSRCI) is deprecated in favor of the V2 wrapper; the hyperswitch-stack Helm chart is in maintenance (new deploys use hyperswitch-app-1.1.3); and the decision-engine ships as a standalone juspay/decision-engine image with no first-party Helm chart — MVS authors its own under deploy/decision-engine/. See Operations → Decision Engine.

Where to go next