Tax & Compliance

TaxJar and IIAS substantiation

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_processor connector. There is no MVS application code for tax; it is a pure connector-config concern. Hyperswitch calls TaxJar and stamps order_tax_amount on 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), the IiasSubstantiation persistence 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

ConceptValueNotes
Connector typetax_processorSame MCA model as a payment processor, different type so HS routes tax-calc traffic to it.
Connector nametaxjar
AuthHeaderKey with TaxJar live tokenStored in Infisical under /apps/mvs-pay/taxjar/<organization_slug>/ (live-api-key, sandbox-api-key).
Attachment scopePer business profileSet via tax_processor_id on the profile, not per-merchant.
payment_methods_enabled[] (empty)Required by the MCA schema; tax processors accept no payment methods.

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.

ItemPTCNotes
Generic drug51010Schedule II–V controlled substance
OTC drug40030Over-the-counter remedies
Medical device40000Durable medical equipment
Clinical service19000Professional services
Wellness service81100Massage, fitness consultations
Prescription (exempt)81100Flags Rx as tax-exempt where applicable

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

1import { evaluateNinetyPercentRule } from "@mvs/mvs-pay-sdk";
2
3const { iiasEligibleAmount, passed } = evaluateNinetyPercentRule({
4 totalAmount, // cents
5 lineItems: [
6 { category: "clinic", amount: 9000 },
7 // ...
8 ],
9});

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:

ConditionBehaviour
iiasEligibleAmountSum of valid line-item amounts (cents).
Negative / non-finite / zero line-item amountDropped before summing (amount <= 0 ignored).
totalAmount <= 0Always returns passed: false (nothing to certify).
ThresholdInclusive: exactly 90% passes (eligible / total >= 0.9).

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.

ColumnTypeSource / meaning
idString (cuid)PK
organizationIdStringTenant; FK to PaymentOrganization, onDelete: Cascade.
transferExtensionIdString @unique1:1 to TransferExtension, onDelete: Cascade.
totalAmountIntPayment amount (cents).
iiasEligibleAmountIntSum of healthcare line items (cents).
ninetyPercentRulePassedBooleanResult of the rule.
substantiationMethodString"iias" when passed, else "manual" on create. Schema comment also lists "copay_match" / "rx_pattern" as valid operator-set values.
substantiationProofJson?JSON of the line items ({ lineItems }).
substantiatedAtDateTime?Operator stamp (override flow).
substantiatedByString?Operator stamp (override flow).

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:

1SELECT "totalAmount", "iiasEligibleAmount",
2 "ninetyPercentRulePassed", "substantiationMethod"
3 FROM "iias_substantiation"
4 WHERE "transferExtensionId" = '<transfer_extension_id>';

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

AspectBehaviour
Tenant scopeFilters where: { organizationId: tenant.organizationId, ... }. No cross-org data leaves the route.
PHI auditphiAudit.logAccess(...) fires before the DB read, because every transferId is patient-linked.
Date filterfrom (inclusive) and to (inclusive end-of-day): to is advanced one day and filtered with lt so rows created later on the to date are not dropped.
SortorderBy: { createdAt: "asc" }.
transferId columnThe transfer extension’s hyperswitchPaymentIntentId, falling back to transferExtensionId if null.

CSV schema — one header row, then one row per substantiation:

date,transferId,totalAmount,iiasEligible,passed,method
2026-01-14,pay_abc123,10000,9500,true,iias
2026-02-03,pay_def456,8000,6000,false,manual

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

$curl -sS \
> -H "x-internal-secret: $S" -H "x-mvs-org-id: $O" \
> "$EXT_URL/v1/iias-export?from=2026-01-01&to=2026-03-31" \
> -o iias-export.csv

Error responses (all 400 unless noted):

CodeStatusCause
TENANT_REQUIRED400No organizationId on the auth context.
MISSING_RANGE400from or to query param absent.
INVALID_RANGE400from / to not parseable as a date (new Date(...) yields NaN).
INTERNAL_ERROR500DB read threw; logged as [iias-export] export failed.

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

1

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.

2

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.

3

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.

4

Receipt PDF renders

apps/mvs-pay-extensions/src/services/receipt-pdf.test.ts is green.

5

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

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.

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.

from / to missing, or not parseable as a date. Re-issue with ISO YYYY-MM-DD.

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.

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.