Data Model
This page documents the dedicated mvs-pay extensions database — the PostgreSQL
schema owned by the @mvs/mvs-pay package (packages/mvs-pay/prisma/schema.prisma)
and reached through the MVS_PAY_DATABASE_URL connection string. It is the only
relational store MVS Pay owns directly; everything payment-canonical lives in
Hyperswitch.
After the Hyperswitch pivot, Hyperswitch is the source of truth for
payment_intent, payment_attempt, refund, dispute, mandate, customer,
payment_method, merchant_account, payouts, connector configs, and routing.
This database holds only the data Hyperswitch does not model, plus thin sidecars
keyed by Hyperswitch IDs. See the schema header in
packages/mvs-pay/prisma/schema.prisma and docs/architecture/hyperswitch-pivot.md
§“Schema strategy”.
The sidecar extension pattern
MVS Pay runs two separate databases for the payment domain:
- Hyperswitch’s own Postgres — canonical payment objects. We do not write to
it directly; we go through the Hyperswitch API (surfaced via
apps/mvs-pay-extensionsand@mvs/mvs-pay-sdk). - The mvs-pay extensions DB (this page) — its own Neon project, separate from the auth and control-plane databases (see ADR-004: Neon, not RDS).
Because they are physically separate databases, no foreign key can cross from an
extension row into a Hyperswitch row. Instead, extension tables store the relevant
Hyperswitch ID as an opaque String column (for example
hyperswitchPaymentIntentId, hyperswitchMerchantConnectorId, hyperswitchPayoutId)
and the application layer joins the two worlds at read time by calling the SDK. This
is the “sidecar” pattern: each MVS row rides alongside a Hyperswitch entity, attached
by ID only.
There is no referential integrity between this DB and Hyperswitch. If a Hyperswitch entity is deleted or an ID is mistyped, the corresponding extension row becomes an orphan — nothing in Postgres will catch it. Reconciliation is an application/operational concern (see the merchant reconciliation runbook), not a database constraint.
ADR-008: MerchantConnectorExtension sidecar table
records the canonical decision for this pattern at the per-MCA level: rather than a
JSON blob hanging off MerchantExtension, per-merchant-connector metadata gets its
own table keyed by hyperswitch_merchant_connector_id so the fields are indexable
and delete cleanly. The same reasoning generalises to every *Extension model.
Multi-tenancy and the client
Every table carries an organizationId (the column is even present, but nullable,
on WebhookDeliveryAttempt, where the org may be unknown until the payload is
parsed). Tenant isolation is row-level, enforced in application code — there is
no connection-level routing. The client factory in packages/mvs-pay/src/client.ts
returns a single shared PrismaClient for all tenants:
The client uses @prisma/adapter-pg (PrismaPg) over MVS_PAY_DATABASE_URL and
throws a structured MissingMvsPayDsnError (code: "MVS_PAY_DSN_MISSING") if the
DSN is unset. The generated client is emitted to
node_modules/@prisma/mvs-pay-client, separate from any other Prisma client in the
monorepo.
Prefer the tenant-scoped helpers in @mvs/mvs-pay/queries (devices, billable-items,
notifications, purchases, audit, pagination) over raw db.* calls — they carry the
organizationId scope for you and reduce the chance of a cross-tenant read.
Model inventory
The schema currently defines 31 models and 14 enums. The root container is
PaymentOrganization, which mirrors the control-plane PlatformOrganization by ID
and holds the org’s hyperswitchMerchantId / hyperswitchProfileId linkage; every
other table cascades from it.
The package README.md still says “30 models”. The live count is 31 — the
PaymentLinkExtension model was added after that README line was written (see the
20260201000000_add_payment_link_extension migration). Trust the schema, not the
README header.
The models group into the following domains.
Merchant & connector extensions
Per-merchant and per-connector MVS configuration, keyed by Hyperswitch
merchant_id / merchant_connector_id.
Transfer, settlement & dispute extensions
Per-payment, per-payout, and per-dispute MVS metadata and workflow.
IIAS substantiation
IiasSubstantiation is one of the few models with a true in-DB foreign key — it has a
unique 1:1 relation to TransferExtension (both live in this database). See the
IIAS substantiation runbook.
Payouts & Plaid
Instant-payout and bank-linking subsystems. Hyperswitch’s payouts module is the settlement primitive; these tables hold the MVS-side request workflow and bank/card metadata.
Sensitive-token handling here is mixed and worth understanding. On both
PayoutMethod and PlaidItem, accessTokenEnc stores an AES-256-GCM ciphertext of
the Plaid access token (encrypted via @mvs/secrets#encryptForOrg, Phase 5 / M24).
PlaidItem.accessTokenSecretName is the older path — a reference to an Infisical
secret at /tenants/{slug}/PLAID_ACCESS_TOKEN_{itemId}; older rows still resolve via
that name while new rows populate accessTokenEnc directly. No raw PANs are ever
stored — only tokenized references. See the
Plaid Link runbook.
Devices
Billing, subscriptions, products & invoices
A custom, Stripe-style billing platform. Hyperswitch’s mandate model is recurring-only; MVS needs full products/prices/invoices for healthcare-platform billing, so this is built MVS-side and linked to Hyperswitch mandates/payment-intents by ID where relevant.
The Revenue Recovery webhook fields on BillingSubscription are updated by
routes/webhooks-revenue-recovery.ts; see
ADR-011: Revenue Recovery.
Webhooks, idempotency & audit (platform plumbing)
Other MVS-native subsystems
Full model list (31)
PaymentOrganization, MerchantExtension, MerchantConnectorExtension,
TransferExtension, IiasSubstantiation, SettlementExtension,
DisputeExtension, PaymentLinkExtension, RemediationExtension, Device,
PayoutMethod, InstantPayoutRequest, PlaidItem, ManualPayment, Purchase,
BillableItem, Notification, OrganizationWebhook, WebhookDelivery,
WebhookDeliveryAttempt, IdempotencyRecord, AdminAuditLog, Product, Price,
BillingCustomer, BillingSubscription, BillingSubscriptionItem,
BillingInvoice, CheckoutSession, CheckoutSessionItem, ProductFeature.
Conventions you will see everywhere
hyperswitch*Idcolumns — opaque foreign references into Hyperswitch, never enforced by Postgres. Usually@uniqueon the owning sidecar.tenant*Idcolumns (tenantPatientId,tenantLocationId,tenantUserId,tenantInvoiceId,tenantPurchaseId) — opaque links back to the consuming app (HealthOS, etc.). Also no FK; they are indexed where queried.- Money is integer cents.
Intcolumns hold cents (e.g.surchargeAmount,total). The one exception isMerchantExtension.defaultSurchargePercent, aDecimal(5,4)(e.g.0.0350= 3.5%). - Soft deletes via
isDeleted/deletedAtonTransferExtension,DisputeExtension,BillableItem, andPlaidItem. Filter onisDeletedin reads; these are indexed. - Enums are prefixed
Payment*orBilling*(e.g.PaymentRemediationStatus,BillingSubscriptionStatus). Note several state/status fields are plainString, not enums —SettlementExtension.approvalStatus,InstantPayoutRequest.status,PaymentLinkExtension.state,Device.status,WebhookDeliveryAttempt.status, andManualPayment.statususe string literals with the allowed values documented only in schema comments. Treat those as application-validated, not DB-validated.
Migrations
Migrations live in packages/mvs-pay/prisma/migrations/ and follow a
forward-only, squashed-baseline policy.
Squashed baseline
20260101000000_squashed_baseline/migration.sql (~1,170 lines) creates the entire
schema as it stood post-pivot — all enums, tables, indexes, and foreign keys in one
file. Pre-pivot migration history was collapsed into this single baseline so new
environments provision cleanly without replaying dropped tables.
Forward-only. Do not edit the squashed baseline or any applied migration in
place — that causes Prisma checksum drift and db:migrate:status will report the
environment as out of sync. Add a new migration for every schema change. The
cross-repo build of the generated client has its own gotchas; see the
cross-repo Prisma build runbook.
Honest gaps & caveats
- Stale README count.
packages/mvs-pay/README.mdsays “30 models”; the schema has 31 (PaymentLinkExtension was added later). Several other guides have quoted “~44 models” — that figure does not match the current schema and should be read as an over-estimate; the authoritative count is 31 models / 14 enums inschema.prisma. - No cross-DB integrity. As above, orphaned sidecars are possible and are not prevented at the database layer.
- String state fields. Workflow states stored as
String(settlement approval, payout request, payment-link state, device status, DLQ status) rely entirely on application validation; a bad write will not be rejected by Postgres. PlaidItem.accessTokenSecretNameis legacy. It coexists with the newer encrypted-column path (accessTokenEnc); a row may resolve through either, which is a migration in progress, not a finished state.AdminAuditLogis not tenant-scoped. It has noorganizationIdcolumn, so tenant filtering of the audit log must be done byentityId/userId, not by org.
Related reading
Why per-MCA metadata is its own table, not a JSON column.
Where this database is hosted and why.
The apps/mvs-pay-extensions service that reads/writes this schema.
The recovery fields on BillingSubscription.
How IiasSubstantiation rows are produced and reviewed.
Reconciling sidecars against Hyperswitch (the orphan-detection job).