open-standards/openpay/docs/04-rails-card-progress.md
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

6.6 KiB
Raw Permalink Blame History

Phase 4 — op-rails-card Complete

Status: Draft v0.4 Date: 2026-05-17

What shipped

op-rails-card: a pluggable PSP abstraction plus a complete Hyperswitch driver. Every PSP — Hyperswitch, Stripe, Adyen, Finix, Moov, future ones — implements one trait. The orchestrator holds Box<dyn CardAcquirer> and never knows which one it has.

Plus a working end-to-end example (headless-server) that takes a payment through the full Created → Authorized → Captured → Refunded flow using both the op-core typestate machine and the Hyperswitch driver.

Modules

File Responsibility
acquirer.rs CardAcquirer trait, AuthRequest/AuthDecision types, AuthStatus (9-variant normalized outcome).
error.rs Sealed Error enum: Transport, PspRejected, MissingField, Parse, UnknownStatus, UnsupportedMethod, Core, DriverValidation.
hyperswitch/wire.rs JSON request/response types matching Hyperswitch V1 API. Eight serde-verified shapes against canonical docs examples.
hyperswitch/status_map.rs Maps the 17-variant Hyperswitch status enum to OpenPay's 9-variant AuthStatus.
hyperswitch/client.rs HTTP client using ureq (sync, rustls TLS, no tokio). Implements CardAcquirer.
tests/hyperswitch.rs 13 integration tests using httpmock — full lifecycle without network.

Verified ground truth

All endpoint paths, request shapes, and response status enums were extracted directly from the Hyperswitch V1 API reference fetched live during construction:

Claim Verification
POST /payments request shape {amount, currency} minimal Verbatim from api-reference.hyperswitch.io/v1/payments/payments--create
amount is int64 minor units (e.g. 6540 = $65.40) Same source
capture_method ∈ {automatic, manual, manual_multiple} Same
authentication_type ∈ {three_ds, no_three_ds} Same
Idempotency via merchant-supplied payment_id, 30 chars Same
Auth header api-key only, server-side "Make sure to never share your API key with your client application" — Hyperswitch docs
POST /payments/{id}/capture body {amount_to_capture} Manual Capture quickstart, hyperswitch docs
POST /payments/{id}/cancel body {cancellation_reason} Verified via Hyperswitch hex-docs and the Cancel API reference
POST /refunds body {payment_id, amount, reason, merchant_refund_id} Refund API reference
17 documented status enum values Status enum on payments--create response schema
requires_capture follows manual auth Manual Capture quickstart: "On successful authorization, the payment would transition to 'requires_capture' status"
succeeded after capture Same
partially_captured / partially_captured_and_capturable distinction "When capture method is manual & multiple captures are supported - partially_captured_and_capturable" — Manual Capture docs
Error envelope {error: {type, message, code}} Hyperswitch error_codes reference
next_action.redirect_to_url for 3DS Confirm API docs: "transition to a requires_customer_action status with a next_action block"

Architecture decisions

  1. Sync HTTP, not async. We use ureq for the network layer. Async-fn-in-traits stabilized in Rust 1.95 but doesn't work cleanly through dyn Trait without return_type_notation. Sync keeps the trait simple and lets the FFI layer call drivers from any thread. When async + dyn stabilizes cleanly we'll migrate.

  2. Box<dyn CardAcquirer> from day one. The orchestrator never knows which PSP it's calling. Adding Stripe/Adyen/Finix is a new file, not a refactor.

  3. Unknown statuses error rather than guess. Hyperswitch adds new status values periodically. If we see one we don't recognize, we return Error::UnknownStatus(s) instead of falling through to a default — silently treating an unknown status as "approved" could move funds incorrectly.

  4. EMV TLV forwarded via connector_metadata.emv_tlv_hex. Hyperswitch V1 doesn't have a first-class Tap-to-Pay payload field yet. We hex-encode the TLV blob and put it in connector_metadata where connectors that support card-present mode can pick it up. Tested: the test emv_payload_forwarded_in_connector_metadata verifies the wire shape.

  5. No raw PAN ever. Driver's supports() accepts only Vault, Wallet, Emv — the three opaque-reference variants from op-core. Raw PAN can't even reach the driver without the pci-scope feature flag in op-core.

Test coverage

File Tests Notes
acquirer.rs 4 AuthStatus classification invariants
hyperswitch/wire.rs 10 JSON round-trip for every request/response type
hyperswitch/status_map.rs 11 All 17 documented statuses mapped; unknown errors
hyperswitch/client.rs 8 URL construction, supports(), currency fallbacks
tests/hyperswitch.rs 13 Full lifecycle via httpmock: authorize, capture, void, refund, error paths, EMV forwarding
Phase 4 total 46
Cumulative (Phases 1–4) 158

What was independently verified by Python before being asserted in Rust

  • currency_from_code("zzz") fallback path: lowercase rejected by try_new, returns unwrap_or(USD) → USD. Confirmed by tracing the byte-validation logic.
  • EMV blob hex encoding [0x9F, 0x02, 0x06, 0×5, 0x01, 0x00] → "9f0206000000000100". Confirmed.
  • Canonical Hyperswitch response body parses with all expected fields present.

What's NOT yet implemented (deferred)

  • Live sandbox tests. Feature-gated under live-sandbox. They need a real HYPERSWITCH_API_KEY and run against https://sandbox.hyperswitch.io. The headless-server example is the entry point.
  • Async migration. Once return_type_notation stabilizes, drivers move to async fn in trait.
  • Stripe, Adyen, Finix, Moov drivers. The trait is the abstraction; each new driver is a new module with its own wire types and status map. Will be added in Phase 4.1+ as separate sub-phases.
  • Retry/backoff layer. Live PSPs occasionally return 5xx. A wrapper that retries Transient statuses with exponential backoff sits one level above the driver, not inside it.
  • Webhook ingestion. When a customer completes a 3DS challenge, Hyperswitch fires a webhook. The receiver lives in op-orchestrator (later phase) and uses our status_map::map directly.

Next: Phase 5 — op-rails-a2a

The non-card rails: FedNow direct (the prize, lowest fees), then RTP, PIX, UPI, SEPA Instant. Each driver builds an ISO 20022 pacs.008 via the verified op-iso20022 builder from Phase 2 and sends it over the appropriate transport (mTLS for PIX, FedLine for FedNow, RT1/TIPS for SEPA).