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

95 lines
6.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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