Tax & Compliance
Tax & Compliance
This page covers the two healthcare-and-tax surfaces of mvs-pay:
- Tax — per-Org sales-tax calculation via TaxJar, wired into Hyperswitch as
a
tax_processorconnector. There is no MVS application code for tax; it is a pure connector-config concern. Hyperswitch calls TaxJar and stampsorder_tax_amounton each payment intent. - IIAS substantiation — IRS Pub 502 / IIAS (Inventory Information Approval
System) auto-substantiation for HSA / FSA card payments. This is MVS code: the
90% rule (
packages/mvs-pay-sdk/src/lib/iias.ts), the auto-substantiation hook on payment create (apps/mvs-pay-extensions/src/routes/payments.ts, § M26), theIiasSubstantiationpersistence model, the CSV export route (apps/mvs-pay-extensions/src/routes/iias-export.ts), and a receipt-PDF service.
These two are independent. TaxJar computes how much sales tax a payment owes. IIAS decides whether an HSA / FSA card was allowed to pay for the basket. A payment can have both, either, or neither.
The IIAS routes covered here are mounted under /v1 behind the gateway middleware
in apps/mvs-pay-extensions/src/app.ts (api.route("/iias-export", iiasExportRoutes)).
A resolved tenant.organizationId is present on every request; see
Authentication & Tenancy for how the internal-secret +
x-mvs-org-id contract resolves the tenant. TaxJar, by contrast, has no MVS route —
it lives entirely inside Hyperswitch.
TaxJar tax integration
Where the wiring lives
TaxJar is not an mvs-pay-extensions route, a SDK namespace, or a UI page. It is a
Hyperswitch connector of connector_type: "tax_processor" attached per-business-profile.
This is the native-Hyperswitch bias in
its purest form: we provision the connector and let Hyperswitch own the tax-calc traffic.
The full operator procedure is the TaxJar runbook (M28);
this section is the conceptual map.
The contract is: once a business profile has a tax_processor_id, every intent created
against that profile — including wallet / express-checkout flows that re-quote tax at
sheet-render time — gets order_tax_amount populated by TaxJar. No app-side code path
is involved.
The connector model
A profile without a tax_processor_id returns order_tax_amount: 0. That is the
intended escape hatch for tax-exempt sub-business-units (e.g. clinical research) — it is
not an error state.
TaxJar returns $0 tax for non-nexus destinations. The nexus address list is configured
inside the TaxJar dashboard (Account → Nexus), not in mvs-pay or Hyperswitch. If a
sandbox payment to a US address comes back with order_tax_amount: 0, the first suspect is
a missing nexus state in TaxJar, not a wiring bug. Connector errors surface as
connector_error in the Hyperswitch API response and are tagged connector: "taxjar" in
Hyperswitch logs.
Product tax codes (PTCs)
TaxJar uses PTCs to override the default rate per line item — e.g. prescription drugs are
exempt in most US states. The catalog table BillableItem carries an optional tax_code
column; when present it is forwarded to Hyperswitch as order_details[].product_tax_code.
Full catalog: https://developers.taxjar.com/api/reference/#categories.
What MVS does not do with tax
mvs-pay does not pull TaxJar reports back into the Settlements UI. Finance reconciles
TaxJar’s own AutoFile / filing reports against the Recon merchant’s settlement files
manually, joining on each row’s order_id (= Hyperswitch payment intent id) against the
Recon merchant’s connector_transaction_id. See the
Merchant Reconciliation runbook. There is no
in-app tax dashboard, no tax-liability table, and no automated filing — this is deferred,
not built.
There is also a salesTax column on transfer_extension (packages/mvs-pay/prisma/schema.prisma),
but it is part of the L2/L3 commercial-card data block (alongside shippingAmount,
discountAmount, customsDutyAmount), not the TaxJar-computed figure. TaxJar’s result
lives on the Hyperswitch intent as order_tax_amount; do not confuse the two.
IIAS substantiation
What IIAS is
IRS Pub 502 plus the IIAS (Inventory Information Approval System) framework let an HSA / FSA card auto-substantiate a payment when at least 90% of the dollar amount is for qualified medical categories. Below that threshold the merchant must provide additional substantiation (copay match, prescription pattern, or manual receipt review).
The single source of truth for the math is packages/mvs-pay-sdk/src/lib/iias.ts. It is
deliberately framework-free so the same function runs in mvs-pay-extensions (on payment
create), in the mvs-pay UI / receipts, and in e2e specs. It is exported from the SDK root
(@mvs/mvs-pay-sdk) as evaluateNinetyPercentRule with types IiasLineItem,
IiasLineItemCategory, NinetyPercentRuleInput, NinetyPercentRuleResult. See
The SDK.
The 90% rule
The four tracked categories are clinic, dental, prescription, vision — mapping 1:1
to the healthcare_<category>_amount columns on transfer_extension. The rule semantics,
straight from the implementation:
The threshold being inclusive matters for test cases sitting exactly on the line: a basket
of 9000 eligible against a 10000 total passes (0.9 >= 0.9). The rule is unit-tested
in packages/mvs-pay-sdk/src/__tests__/iias.test.ts — run
pnpm --filter @mvs/mvs-pay-sdk test iias.test.ts.
Auto-substantiation on payment create
When POST /v1/payments carries any of healthcare_clinic_amount,
healthcare_dental_amount, healthcare_prescription_amount, or
healthcare_vision_amount, the payments route (apps/mvs-pay-extensions/src/routes/payments.ts,
§ M26) builds the line-item array, runs evaluateNinetyPercentRule, and persists one
IiasSubstantiation row.
A subtlety in the route: totalAmount is read from the resolved Hyperswitch payment
(payment.amount), falling back to the request body amount, and 0 if neither is a number.
A totalAmount of 0 would force passed: false by the rule’s own contract.
Only healthcare_*_amount fields trigger the rule. A healthcare payment sent without
those structured amounts gets no IiasSubstantiation row at all — it is silently
unsubstantiated, not flagged manual. If a payment that should have substantiated did not,
the first check is whether the create call actually carried the healthcare amounts.
The IiasSubstantiation model
Defined in packages/mvs-pay/prisma/schema.prisma (@@map("iias_substantiation")), one row
per transferExtension (a @unique 1:1 relation). Cascade-deletes with both its
organization and its transfer extension.
The create path only ever writes substantiationMethod of "iias" or "manual". The
"copay_match" / "rx_pattern" values and the substantiatedAt / substantiatedBy
stamps exist for an operator override flow described in the schema comment — verify the
dashboard wiring before relying on those being populated, as the auto-path does not set them.
Verify a payment substantiated:
The IIAS export route
GET /v1/iias-export?from=YYYY-MM-DD&to=YYYY-MM-DD returns a CSV of every substantiation
row in the tenant’s date window. Plan administrators upload it to their HSA / FSA payer as
proof. Implementation: apps/mvs-pay-extensions/src/routes/iias-export.ts (Phase 5 / M26.5).
CSV schema — one header row, then one row per substantiation:
passed is the literal string true / false; method is the substantiationMethod.
Both transferId and method are RFC-4180 quoted by the route’s csvField helper if they
contain a comma, quote, CR, or LF. The response sets
content-disposition: attachment; filename="iias-export-<from>-to-<to>.csv".
Error responses (all 400 unless noted):
INVALID_RANGE only checks that the string parses to a valid Date — it does not
strictly enforce the YYYY-MM-DD format. A value like 2026-01-01T12:00:00Z would parse
and be accepted. The documented contract is ISO YYYY-MM-DD; callers should send exactly
that, but the route is more permissive than the error message implies.
Full request/response shape is in the API Reference under the iias-export resource.
Per-payment receipt PDF
apps/mvs-pay-extensions/src/services/receipt-pdf.ts exposes
generateIiasReceiptPdf(input) (async), which renders an IRS-Pub-502-compliant HSA / FSA
receipt into a Node Buffer using pdfkit (its only runtime dep — @react-pdf/renderer
was deliberately avoided). The receipt includes patient name, provider name, date of
service, itemized eligible expenses by category, total, and the explicit Pub-502
disclaimer (“Issued for IIAS substantiation per IRS Pub 502”). Inputs are typed via
IiasReceiptInput / IiasReceiptLineItem. The buffer can be streamed from a Hono
response, emailed, or uploaded to object storage.
The operator-facing receipt payload is assembled by the mvs-pay app at
GET /api/transactions/[id]/receipt
(apps/mvs-pay/app/api/transactions/[id]/receipt/route.ts), which combines the Hyperswitch
payment with the local transferExtension line items / healthcare amounts and renders the
UI receipt.
Verification
Rule unit tests pass
pnpm --filter @mvs/mvs-pay-sdk test iias.test.ts — covers the inclusive threshold,
negative/zero filtering, and the totalAmount <= 0 guard.
Auto-substantiation persists
Create a payment with healthcare_*_amount fields; confirm an iias_substantiation row
exists for the transfer extension and substantiationMethod matches the expected
iias / manual.
Export is tenant-scoped
GET /v1/iias-export for org A must never return org B rows, and a PHI-audit row must be
written for every call.
TaxJar (sandbox)
A sandbox payment with line items + a US shipping address returns a non-zero
order_tax_amount. See the TaxJar runbook § 4.
Troubleshooting
Payment not substantiated (no IIAS row)
The create call carried no healthcare_*_amount field. Only those structured amounts
trigger the rule — a healthcare payment without them is silently unsubstantiated (not
flagged manual). Confirm the fields were sent.
ninetyPercentRulePassed is false unexpectedly
Eligible amount is below 90% of totalAmount — usually a non-medical charge diluting the
basket. Inspect substantiationProof.lineItems, and route the residual to manual review.
Also check that totalAmount resolved to a positive number on create; a 0 total forces
passed: false.
Export returns 400 MISSING_RANGE / INVALID_RANGE
from / to missing, or not parseable as a date. Re-issue with ISO YYYY-MM-DD.
Export is empty
No substantiation rows in the window for that org. Widen the window and confirm payments
in that period carried healthcare line items. Remember to is treated as inclusive
end-of-day.
TaxJar order_tax_amount is 0 or null
Check, in order: (1) the profile has a tax_processor_id set; (2) TaxJar has the
destination state configured in its nexus list (TaxJar returns $0 for non-nexus
destinations); (3) Hyperswitch logs for connector: "taxjar" errors. See the
TaxJar runbook § 4.
For incident escalation — including any cleartext-PHI exposure from the export or receipt path — follow the Incident Response runbook.
Related pages
The full M28 operator procedure: provision, attach per profile, sandbox-verify, de-provision.
The M26 on-call runbook for HSA / FSA substantiation and exports.
Where the M26 auto-substantiation hook fires on payment create.
evaluateNinetyPercentRule and the IIAS types are exported from @mvs/mvs-pay-sdk.
IiasSubstantiation and TransferExtension in context.
Why TaxJar is a connector, not MVS code.