Payments & Refunds

Charging and refunding

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.

SDK callHTTPHandler
payments.create(input, meta)POST /v1/paymentspaymentRoutes.post("/")
payments.get(id, meta)GET /v1/payments/:idpaymentRoutes.get("/:id")
payments.confirm(id, input, meta)POST /v1/payments/:id/confirmpaymentRoutes.post("/:id/confirm")
payments.capture(id, input, meta)POST /v1/payments/:id/capturepaymentRoutes.post("/:id/capture")
payments.cancel(id, input, meta)POST /v1/payments/:id/cancelpaymentRoutes.post("/:id/cancel")
payments.list(query, meta)GET /v1/paymentspaymentRoutes.get("/")
refunds.create(input, meta)POST /v1/refundsrefundRoutes.post("/")
refunds.get(id, meta)GET /v1/refunds/:idrefundRoutes.get("/:id")
refunds.list(query, meta)GET /v1/refundsrefundRoutes.get("/")

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:

  1. Splits the MVS extension fields out of the request body (everything else is hyperswitchBody, forwarded verbatim).
  2. Emits a payment.create PHI audit event.
  3. 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 Hyperswitch surcharge_details into the intent.
  4. Calls hyperswitch.payments.create(...), stamping mvs_org_id / mvs_org_slug into Hyperswitch metadata.
  5. On success, persists a transfer_extension row and — if any healthcare line item is present — runs the IIAS 90% rule (M26) and persists the outcome.
1import { MvsPayClient } from "@mvs/mvs-pay-sdk";
2
3const mvsPay = new MvsPayClient({
4 baseUrl: process.env.MVS_PAY_EXTENSIONS_URL!,
5 internalSecret: process.env.MVS_PAY_EXTENSIONS_INTERNAL_SECRET!,
6 orgSlug,
7});
8
9const payment = await mvsPay.payments.create(
10 {
11 amount: 5000, // $50.00 in the minor unit (cents)
12 currency: "USD",
13 capture_method: "automatic",
14 // MVS extension fields — split out and persisted to transfer_extension:
15 tip_amount: 500,
16 tenant_patient_id: "PT123",
17 tenant_location_id: "LOC456",
18 tenant_invoice_id: "INV789",
19 },
20 { idempotencyKey: `checkout_${orderId}` },
21);
22// payment.payment_id, payment.status, payment.client_secret, ...

Capture methods

capture_method on payments.create (defined in CreatePaymentInput, packages/mvs-pay-sdk/src/types.ts) controls the auth/capture split:

ValueBehavior
automatic (default)Authorize and capture in one step; intent goes straight to succeeded on a successful connector response.
manualAuthorize only; intent settles at requires_capture. Call payments.capture(id, { amount_to_capture }) later (full or partial).

Confirming, capturing, cancelling

1// Confirm an intent that requires confirmation (payment_method_data, etc.)
2await mvsPay.payments.confirm(paymentId, { /* payment_method_data */ });
3
4// Capture a manual-capture intent (omit amount_to_capture for full capture)
5await mvsPay.payments.capture(paymentId, { amount_to_capture: 4250 });
6
7// Cancel before capture (a void)
8await mvsPay.payments.cancel(paymentId, { cancellation_reason: "duplicate" });

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

SDK fieldtransfer_extension columnGroupNotes
surcharge_amountsurchargeAmountSurcharge / feesCents
convenience_amountconvenienceAmountSurcharge / feesCents
rent_surcharge_amountrentSurchargeAmountSurcharge / feesCents
tip_amounttipAmountTipCents
healthcare_clinic_amounthealthcareClinicAmountHealthcareCents; feeds IIAS rule
healthcare_dental_amounthealthcareDentalAmountHealthcareCents; feeds IIAS rule
healthcare_prescription_amounthealthcarePrescriptionAmountHealthcareCents; feeds IIAS rule
healthcare_vision_amounthealthcareVisionAmountHealthcareCents; feeds IIAS rule
customer_reference_numbercustomerReferenceNumberL2/L3Commercial card data
invoice_reference_numberinvoiceReferenceNumberL2/L3Commercial card data
sales_taxsalesTaxL2/L3Cents
shipping_amountshippingAmountL2/L3Cents
discount_amountdiscountAmountL2/L3Cents
customs_duty_amountcustomsDutyAmountL2/L3Cents
tenant_patient_idtenantPatientIdTenant contextOpaque, no FK
tenant_location_idtenantLocationIdTenant contextOpaque, no FK
tenant_user_idtenantUserIdTenant contextOpaque, no FK
tenant_invoice_idtenantInvoiceIdTenant contextOpaque, no FK
tenant_purchase_idtenantPurchaseIdTenant contextOpaque, no FK
purchase_idpurchaseIdPurchase linkageFK to Purchase (onDelete: SetNull)

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.

  • defaultSurchargePercent is stored as a fraction (0.035 = 3.5%); Hyperswitch wants percent points, so the handler computes Math.round(pct * 100 * 10_000) / 10_000 to dodge the 0.035 * 100 = 3.5000000000000004 float artefact.
  • defaultSurchargeCap is in cents. Hyperswitch’s surcharge_details (v1.123.1) has no dedicated cap field, so the cap is clamped client-side: if amount * pct exceeds the cap, the percentage block is replaced with a fixed block at the cap value.
  • Caller-supplied surcharge_details always 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.

OutcomesubstantiationMethodninetyPercentRulePassed
Rule passes"iias"true
Rule fails"manual" (flagged for operator review)false

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.

1

Authenticate

authenticateRequest(request, { requiredScope: "transactions:write" }). Supports both session auth (dashboard) and API key auth (HealthOS integration).

2

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

3

Validate

amount must be a number >= 50 (50 cents); otherwise 400 "Amount must be at least $0.50 (50 cents)". Exactly one of token (new keyed card) or paymentInstrumentId (saved instrument) must be present.

4

Create the confirmed intent

Build CreatePaymentInput with confirm: true and call client.payments.create(paymentInput, { idempotencyKey: idempotencyId }).

Payment-method selection:

  • Saved instrumentpayment_method: paymentInstrumentId.
  • Fresh keyed cardpayment_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:

1function createVirtualTerminalIdempotencyId(organizationId: string): string {
2 const normalizedOrganizationId = organizationId.replace(/[^a-zA-Z0-9_\-:]/g, "_");
3 const randomSuffix = Math.random().toString(36).slice(2, 12);
4 return `mvspay_${normalizedOrganizationId}_${Date.now()}_${randomSuffix}`;
5}

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.

1// Full refund
2const refund = await mvsPay.refunds.create(
3 { payment_id: paymentId, amount: 5000, reason: "customer_request" },
4 { idempotencyKey: `refund_${paymentId}_full` },
5);
6
7// Partial refund — multiple allowed up to the captured amount
8await mvsPay.refunds.create(
9 { payment_id: paymentId, amount: 2000 }, // $20.00 of $50.00
10 { idempotencyKey: `refund_${paymentId}_partial_1` },
11);

CreateRefundInput requires payment_id; amount (cents), reason, and metadata are optional, plus arbitrary [k: string]: unknown pass-through.

RuleDetail
Max amountTotal refunds cannot exceed the captured amount
Multiple refundsAllowed up to the captured amount
TimingSubject to the connector’s refund window
StatusDrives the parent intent to partially_refunded / refunded

Refund resolution arrives asynchronously via the WEBHOOK_REFUND_SUCCESS / WEBHOOK_REFUND_FAILURE webhooks — the synchronous refunds.create response is not final.

Void vs refund

AspectCancel / void (payments.cancel)Refund (refunds.create)
WhenBefore capture (requires_capture / authorized)After capture (succeeded)
Result statuscancelledpartially_refunded / refunded
Customer viewAuthorization drops offCredit appears
Best forMistakes, abandoned authsReturns, post-settlement adjustments

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_id we 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" } } with 500.
  • refunds{ error: <hyperswitch body> } with asContentfulStatus(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 auditpayments.create emits a payment.create event up front and a payment.created event with the resolved payment_id and connector; refunds.create emits refund.create. userId is undefined on 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) and mvsPayMetrics.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