Payments & Refunds
Payments & Refunds
A payment in MVS Pay is a Hyperswitch payment_intent — there is no
MVS-native transaction object. The mvs-pay-extensions service is a thin
orchestration layer that forwards Hyperswitch-native fields to the router,
splits out MVS-specific fields into the transfer_extension sidecar, and (when
the org has surcharging enabled) injects surcharge_details into the intent.
Refunds are first-class objects created on /v1/refunds, and a pre-capture void
is just a cancel.
This page is the operational reference for charging and refunding. For the state machine itself, see Payment Lifecycle; for the full request/response schemas, see the Payments + Refunds API Reference.
The handlers documented here live in
apps/mvs-pay-extensions/src/routes/payments.ts and
apps/mvs-pay-extensions/src/routes/refunds.ts. They are mounted under the
/v1 prefix (api.route("/payments", paymentRoutes) in
apps/mvs-pay-extensions/src/app.ts, with app.route("/v1", api)), so the live
paths are POST /v1/payments, POST /v1/refunds, etc. Application code should
never call these routes directly — always go through @mvs/mvs-pay-sdk. See
The SDK.
The SDK surface
All operations are methods on the payments and refunds namespaces of
MvsPayClient (packages/mvs-pay-sdk/src/namespaces/payments.ts,
refunds.ts). Every write method takes an optional RequestMetadata second
argument carrying idempotencyKey, requestId, and an AbortSignal.
confirm, capture, and cancel are pass-through wrappers over
Hyperswitch — they do not touch the transfer_extension sidecar. Only
payments.create writes the sidecar row and runs the surcharge bridge and IIAS
substantiation. If you need to add MVS extension data to a payment, you must do
it at create time; there is no after-the-fact enrichment path today.
Creating a payment
payments.create does five things in order, all inside one handler so the SLO
latency metric covers the whole round-trip:
- Splits the MVS extension fields out of the request body (everything else is
hyperswitchBody, forwarded verbatim). - Emits a
payment.createPHI audit event. - Runs the surcharge bridge (M27): if the org’s default merchant extension
has surcharging enabled and the caller did not supply
surcharge_details, it injects Hyperswitchsurcharge_detailsinto the intent. - Calls
hyperswitch.payments.create(...), stampingmvs_org_id/mvs_org_sluginto Hyperswitchmetadata. - On success, persists a
transfer_extensionrow and — if any healthcare line item is present — runs the IIAS 90% rule (M26) and persists the outcome.
Capture methods
capture_method on payments.create (defined in CreatePaymentInput,
packages/mvs-pay-sdk/src/types.ts) controls the auth/capture split:
Confirming, capturing, cancelling
The capture and cancel handlers read their body defensively
(c.req.json().catch(() => ({}))), so an empty POST body is valid:
payments.capture(id) captures the full authorized amount, and
payments.cancel(id) cancels without a reason.
ConfirmPaymentInput is typed as Record<string, unknown> — the SDK does not
constrain the confirm body. Whatever you pass is forwarded to Hyperswitch
unchanged, so consult the Hyperswitch payment-confirm schema for valid fields.
MVS extension fields
These fields are not Hyperswitch-native. The create handler destructures
them out of the request body before forwarding the rest to Hyperswitch, then
writes them to the transfer_extension table (model TransferExtension,
@@map("transfer_extension") in packages/mvs-pay/prisma/schema.prisma). The
mapping is snake_case (API/SDK) → camelCase (Prisma column).
All amount fields are integer cents (the transfer_extension columns are
Int?). Do not pass dollars or decimals.
Use the tenant fields for correlation rather than stuffing IDs into the
Hyperswitch object. They are indexed (tenantPatientId, tenantLocationId,
purchaseId, …) and link the payment back to your system without polluting the
Hyperswitch intent. The Hyperswitch payment_id is hyperswitchPaymentIntentId
on the sidecar row (and is @unique).
L2/L3 commercial card data
The six L2/L3 fields (customer_reference_number, invoice_reference_number,
sales_tax, shipping_amount, discount_amount, customs_duty_amount) are
persisted to transfer_extension for reporting and reconciliation. They are
stored, not forwarded — the create handler does not currently push them into
the Hyperswitch intent as connector-level Level 2/3 line-item data. Treat them
as MVS sidecar metadata, not as a guarantee that the connector receives
interchange-optimizing L2/L3 data.
Surcharge bridge (M27)
When tenant.organizationId is set and the caller did not pass
surcharge_details, the handler looks up the org’s default merchant extension
(merchantExtension.findFirst({ where: { organizationId, isDefault: true } }))
and, if surchargesEnabled and a defaultSurchargePercent are present, injects
a Hyperswitch surcharge_details block.
defaultSurchargePercentis stored as a fraction (0.035= 3.5%); Hyperswitch wants percent points, so the handler computesMath.round(pct * 100 * 10_000) / 10_000to dodge the0.035 * 100 = 3.5000000000000004float artefact.defaultSurchargeCapis in cents. Hyperswitch’ssurcharge_details(v1.123.1) has no dedicatedcapfield, so the cap is clamped client-side: ifamount * pctexceeds the cap, the percentage block is replaced with afixedblock at the cap value.- Caller-supplied
surcharge_detailsalways win — operators can override the org default per-payment. - The lookup is best-effort: a DB miss logs a warning and continues
without
surcharge_details. A surcharge failure never blocks payment creation.
The surcharge_details cap workaround is an explicit MVS-side compensation for a
missing upstream field. If you upgrade Hyperswitch and it gains a native cap
field, revisit the clamp logic in payments.ts (the tax_on_surcharge: 0
marker and the fixed-block replacement).
Healthcare line items and IIAS substantiation (M26)
If any of the four healthcare_*_amount fields is set, the handler runs the
IIAS 90% rule (evaluateNinetyPercentRule from
packages/mvs-pay-sdk/src/lib/iias.ts, exported as @mvs/mvs-pay-sdk/iias) and
persists an IiasSubstantiation row (@@map("iias_substantiation")) linked 1:1
to the transfer_extension row.
The rule: sum the valid (positive, finite) healthcare line items; the payment
auto-substantiates when eligible / totalAmount >= 0.9 (inclusive). A
totalAmount <= 0 always fails.
The substantiationProof JSON column stores the lineItems used. Operators may
later override substantiationMethod (also "copay_match" / "rx_pattern") and
stamp substantiatedAt / substantiatedBy from the dashboard.
totalAmount for the rule is taken from the Hyperswitch response amount if
present, else the request amount, else 0. If neither is a number, the rule
sees 0 and fails — so always pass a numeric amount alongside healthcare line
items.
See Tax & Compliance for IIAS export and the broader HSA/FSA reporting story.
Virtual terminal (card-not-present)
The virtual terminal is the keyed, card-not-present sale path. It is a Next.js
route in the dashboard app — apps/mvs-pay/app/api/virtual-terminal/route.ts
(POST /api/virtual-terminal) — not an mvs-pay-extensions route. After the
Hyperswitch pivot, a terminal sale is modeled as a confirmed payment intent: the
route builds a CreatePaymentInput with confirm: true and calls
client.payments.create(...) through the SDK.
Authenticate
authenticateRequest(request, { requiredScope: "transactions:write" }). Supports
both session auth (dashboard) and API key auth (HealthOS integration).
Resolve the merchant
resolveRuntimeMerchantSelection(...) picks the Hyperswitch/Finix merchant from
the explicit merchantId, the location mapping, or the org’s fallback. If none
resolves, it returns 400 "Merchant not configured."
Payment-method selection:
- Saved instrument →
payment_method: paymentInstrumentId. - Fresh keyed card →
payment_method_data: { token }.
The route stamps identifying tags into metadata: merchant_id,
location_id, source: "virtual-terminal", entry_mode: "KEYED",
invoice_reference, and customer_name. Any caller-supplied tags object is
merged in first (normalizedTags).
Virtual-terminal idempotency
If the caller does not supply idempotencyId, the route generates one:
The auto-generated key embeds Date.now() and a random suffix, so it is
unique per request, not stable across retries. If a client retries a failed
virtual-terminal request without passing its own idempotencyId, a new key is
minted and the charge is not deduplicated. For true retry-safety the caller
must supply a stable idempotencyId (e.g. derived from the order). The handler
honors a non-empty trimmed providedIdempotencyId verbatim.
Errors: an MvsPayApiError is returned as
{ error: { code, message, details } } with the upstream statusCode (default
502); anything else is a 500.
Refunds
Refunds are first-class Hyperswitch objects created against a captured payment
intent — there is no Finix-style reversal transfer. The refund handler
(refunds.ts) is a thin pass-through: it emits a refund.create PHI audit
event and forwards the body straight to hyperswitch.refunds.create(body, idempotencyKey).
It does not write transfer_extension.
CreateRefundInput requires payment_id; amount (cents), reason, and
metadata are optional, plus arbitrary [k: string]: unknown pass-through.
Refund resolution arrives asynchronously via the
WEBHOOK_REFUND_SUCCESS / WEBHOOK_REFUND_FAILURE webhooks — the synchronous
refunds.create response is not final.
Void vs refund
To release an authorization before capture, cancel the intent — do not refund it.
Idempotency
payments.create, payments.confirm, payments.capture, and
refunds.create are idempotency-aware. The SDK sends the idempotencyKey as the
idempotency-key request header (packages/mvs-pay-sdk/src/http.ts), alongside
an optional x-request-id from requestId. The handlers read
c.req.header("idempotency-key") and pass it to the Hyperswitch client, where
Hyperswitch performs the actual dedupe.
Always pass a stable key (e.g. checkout_${orderId},
refund_${paymentId}_full) so a retried request resolves to the same
operation. The list/get methods do not take an idempotency key.
Listing and tenant scoping
payments.list accepts customer_id, limit, offset. The handler requires a
tenant (400 TENANT_REQUIRED otherwise) and filters the Hyperswitch result by
metadata.mvs_org_id:
The shared admin key lists payments across EVERY org, so constrain the result to this tenant’s own payments via the
mvs_org_idwe stamp into Hyperswitch metadata on create. Without this an authenticated tenant could enumerate every org’s payments.
This per-tenant scoping is filter-after-fetch, applied only on the
mvs_org_id stamped at create time. Payments created without going through this
handler (no mvs_org_id stamp) will not be attributed to any tenant and will be
filtered out of every tenant’s list. The refund list handler does not apply
the same tenant filter — it returns whatever Hyperswitch returns for the
payment_id query.
Error handling
Both handlers map HyperswitchApiError to a JSON error with the upstream status
code:
- payments →
{ error: { code: "HYPERSWITCH_ERROR", details: <hyperswitch body> } }; non-Hyperswitch failures become{ error: { code: "INTERNAL_ERROR" } }with500. - refunds →
{ error: <hyperswitch body> }withasContentfulStatus(statusCode); non-Hyperswitch errors are re-thrown.
A declined charge surfaces either as status: "failed" on the intent or as a
thrown MvsPayApiError (code: "HYPERSWITCH_ERROR"); the connector decline
reason is under details.
Observability and audit
- PHI audit —
payments.createemits apayment.createevent up front and apayment.createdevent with the resolvedpayment_idandconnector;refunds.createemitsrefund.create.userIdisundefinedon every event because auth is deferred per ADR-010 (internal-secret-only; see ADR-010). PII is redacted at the log layer; only structured fields are recorded. - Metrics (M18.2) —
mvsPayMetrics.paymentCreated(connector, status, org)andmvsPayMetrics.paymentLatency("POST /v1/payments", elapsed). Latency covers the full handler, so DB persistence and the Hyperswitch round-trip are captured in one signal.
Where to go next
The payment-intent state machine: statuses, transitions, and how confirm / capture / cancel / refund move an intent.
Full request/response schemas for every /v1/payments and /v1/refunds
endpoint.
@mvs/mvs-pay-sdk — the only sanctioned client for the payments domain.
Card-present terminal sales (the keyed virtual-terminal path’s sibling).
IIAS substantiation, healthcare line items, and HSA/FSA export.
The transfer_extension and iias_substantiation tables in full.