Data Model

The extension database

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:

  1. 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-extensions and @mvs/mvs-pay-sdk).
  2. 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:

1// packages/mvs-pay/src/client.ts
2const db = getMvsPayDb(organizationId);
3// The argument is a typed reminder, NOT a connection selector.
4// You MUST still pass `where: { organizationId }` on every query.
5const devices = await db.device.findMany({ where: { organizationId } });

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.

ModelKeyed byPurpose
PaymentOrganizationown ID (= control-plane org ID)Root tenant container; holds Hyperswitch merchant/profile linkage.
MerchantExtensionhyperswitchMerchantIdPer-merchant MVS config: surcharge/convenience-fee flags, routing algorithm id, smart-retry mirrors, hybrid-MoR opt-in.
MerchantConnectorExtensionhyperswitchMerchantConnectorIdPer-MCA sidecar: operator label, last health check, mirrored connectorName / authType / testMode / enabled (per ADR-008).

Transfer, settlement & dispute extensions

Per-payment, per-payout, and per-dispute MVS metadata and workflow.

ModelKeyed byPurpose
TransferExtensionhyperswitchPaymentIntentIdPer-payment MVS metadata: surcharge/tip/convenience amounts, healthcare line items, L2/L3 commercial-card data, tenant cross-refs, settlement linkage. Soft-deletable.
SettlementExtensionhyperswitchPayoutIdPer-settlement platform approval workflow (PENDING/APPROVED/REJECTED), review deadline, platform notes.
DisputeExtensionhyperswitchDisputeIdPer-dispute platform workflow: alert priority/ack, operator assignment, auto-accept deadline, notes. Soft-deletable.
PaymentLinkExtensionhyperswitchPaymentIdPer-payment-link MVS display + lifecycle fields (nickname, amountType, state machine, expiry, tipping, tags) plus the hosted linkUrl.
RemediationExtensionhyperswitchMerchantId + remediationEventIdRicher MVS merchant-remediation workflow on top of Hyperswitch onboarding state (outcome codes, priority, resolution notes).

IIAS substantiation

ModelKeyed byPurpose
IiasSubstantiationtransferExtensionId (1:1)IRS Pub 502 substantiation outcome per payment (HSA/FSA). Records the 90%-rule result and substantiation method (iias / copay_match / rx_pattern / manual).

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.

ModelKeyed byPurpose
PayoutMethodhyperswitchPaymentMethodIdDestination for instant payouts: push-to-card card metadata or a Plaid-verified ACH bank account.
InstantPayoutRequestown ID (+ optional hyperswitchPayoutId)MVS instant-payout request lifecycle (PENDING → REQUESTED → COMPLETED/FAILED), linked to a PayoutMethod and optionally a SettlementExtension.
PlaidItem(organizationId, itemId)Plaid linked-bank metadata (institution/account, verification + webhook status).

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

ModelKeyed byPurpose
DeviceconnectorDeviceId (+ (organizationId, connectorDeviceId) unique)POS / terminal hardware (e.g., a Finix device via Hyperswitch’s Finix connector). Hyperswitch does not model physical terminals. Holds firmware/connection status, signature-prompt rules, standalone-sale flags.

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.

ModelKeyed byPurpose
Productown ID (+ (organizationId, slug) unique)Sellable product.
Priceown IDA price for a product; one-time or recurring. Optional hyperswitchMandateTemplateId links recurring prices to a Hyperswitch mandate.
ProductFeatureown ID (+ (productId, featureKey) unique)Feature flags/entitlements on a product (boolean / metered / static).
BillingCustomerown ID (+ hyperswitchCustomerId / openMeterCustomerId unique)Billing customer; links to Hyperswitch customer and OpenMeter.
BillingSubscriptionown ID (+ hyperswitchMandateId unique)Subscription lifecycle, including Hyperswitch Revenue Recovery state (recoveryStatus, recoveryAttemptCount) per ADR-011.
BillingSubscriptionItemown IDLine item (price + quantity) on a subscription.
BillingInvoiceown IDInvoice with subtotal/tax/total, line items, optional hyperswitchPaymentIntentId once paid.
CheckoutSessionown ID (+ hyperswitchPaymentIntentId unique)Hosted/HyperLoader checkout session (PAYMENT / SUBSCRIPTION / SETUP) with client secret.
CheckoutSessionItemown IDLine item (price + quantity) on a checkout session.

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)

ModelKeyed byPurpose
OrganizationWebhookown IDOrg-scoped outbound webhook endpoint (URL, subscribed events, HMAC secret, failure count).
WebhookDeliveryown IDOne delivery attempt of an OrganizationWebhook (request/response capture, retry state).
WebhookDeliveryAttempthyperswitchEventId (unique)Phase 3 DLQ for inbound Hyperswitch webhooks that failed to dispatch; drained by the Temporal webhookDlqDrainerWorkflow (pending → processed/failed after 10 attempts).
IdempotencyRecord(organizationId, route, bodyHash) uniquePhase 3 idempotency cache for write-bearing HTTP requests carrying an idempotency-key; 24h TTL swept by idempotencySweepWorkflow.
AdminAuditLogown IDOperator audit trail (action, entityType/entityId, old/new value, IP/UA). Richer than Hyperswitch’s events table; not org-scoped (no organizationId).

Other MVS-native subsystems

ModelKeyed byPurpose
ManualPaymentown IDCash/check entry outside Hyperswitch’s domain (amount, method, line items, void state).
Purchaseown IDPatient purchase order (subscription or one-time); linked to TransferExtension rows.
BillableItemown IDHealthcare-specific catalog item (price, category, copay flag). Soft-deletable.
Notificationown IDOperator-dashboard alert with deep-link entityId / entityType, priority, read/dismiss state.

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*Id columns — opaque foreign references into Hyperswitch, never enforced by Postgres. Usually @unique on the owning sidecar.
  • tenant*Id columns (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. Int columns hold cents (e.g. surchargeAmount, total). The one exception is MerchantExtension.defaultSurchargePercent, a Decimal(5,4) (e.g. 0.0350 = 3.5%).
  • Soft deletes via isDeleted / deletedAt on TransferExtension, DisputeExtension, BillableItem, and PlaidItem. Filter on isDeleted in reads; these are indexed.
  • Enums are prefixed Payment* or Billing* (e.g. PaymentRemediationStatus, BillingSubscriptionStatus). Note several state/status fields are plain String, not enumsSettlementExtension.approvalStatus, InstantPayoutRequest.status, PaymentLinkExtension.state, Device.status, WebhookDeliveryAttempt.status, and ManualPayment.status use 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.

1

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.

2

Incremental migrations

Subsequent changes are appended as new timestamped directories. 20260201000000_add_payment_link_extension/ is the only one so far — it adds the payment_link_extension table, its indexes, and the cascade FK to payment_organization.

3

Apply

Production applies the committed history with pnpm db:migrate:deploy. pnpm db:push is dev-only (no migration history) and must not be run against shared/production databases.

1# packages/mvs-pay/prisma/migrations/migration_lock.toml
2provider = "postgresql"

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.md says “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 in schema.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.accessTokenSecretName is 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.
  • AdminAuditLog is not tenant-scoped. It has no organizationId column, so tenant filtering of the audit log must be done by entityId / userId, not by org.