open-standards/openpay/examples/kiosk-linux/src/main.rs
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

379 lines
13 KiB
Rust

//! # OpenPay kiosk-linux
//!
//! Reference unattended-checkout terminal that demonstrates the full
//! OpenPay stack working end-to-end on a single Linux box:
//!
//! ```text
//! Customer taps card → EMV terminal kernel → op-emv
//! │
//! ▼
//! Vault tokenization (op-vault)
//! │
//! ▼
//! Fraud scoring (op-fraud HeuristicScorer)
//! │
//! ▼
//! Orchestrator routing (op-orchestrator + PolicyRouter)
//! │
//! ▼
//! Card rail (CardAdapter → mock CardAcquirer)
//! │
//! ▼ on soft failure: cross-rail fallback
//! A2A rail (A2aAdapter → mock A2aAcquirer)
//! │
//! ▼
//! OrchestrationOutcome → kiosk display
//! ```
//!
//! ## Why this binary exists
//!
//! Two reasons:
//!
//! 1. **Architectural proof.** It demonstrates that every layer
//! shipped in Phases 1-10 composes — the orchestrator (Phase 11)
//! is the missing keystone that wires them together. If the
//! composition doesn't compile or doesn't terminate with a
//! sensible outcome, the architectural thesis is wrong.
//!
//! 2. **Operator template.** Apache-2.0 reference code that a vendor
//! shipping unattended checkout (parking meters, vending, EV
//! charging) can fork. The only real changes needed are: plug in
//! real `CardAcquirer` / `A2aAcquirer` drivers, swap
//! `InMemoryIdempotencyStore` for a Redis-backed one, and add the
//! physical kiosk's I/O glue (touchscreen, receipt printer,
//! etc.).
//!
//! ## Mock backends, real composition
//!
//! The two rail drivers are mocks — `MockCardAcquirer` and
//! `MockA2aAcquirer` — that return canned decisions so the kiosk
//! runs offline. They implement the same `CardAcquirer` /
//! `A2aAcquirer` traits as the real Hyperswitch / FedNow drivers,
//! so swapping them for production drivers is a one-line change.
use std::sync::Arc;
use op_core::{A2aKey, Currency, Money, PaymentMethod, VaultRef};
use op_fraud::HeuristicScorer;
use op_orchestrator::{
A2aAdapter, CardAdapter, IdempotencyKey, MerchantBankProfile, Orchestrator, PaymentIntent,
PolicyRouter, TerminalStatus,
};
use op_rails_a2a::{
Result as A2aResult,
acquirer::{
A2aAcquirer, A2aDecision, A2aStatus, CreditTransferReq, ParticipantId, StatusQueryReq,
},
};
use op_rails_card::{
AuthDecision, AuthRequest, CaptureRequest, RefundRequest, Result as CardResult, VoidRequest,
acquirer::{AuthStatus, CardAcquirer},
};
fn main() {
eprintln!("=== OpenPay kiosk-linux reference ===");
eprintln!("Wiring Phases 1-11 into a single end-to-end flow.\n");
let kiosk = Kiosk::new();
// Scenario 1: happy path — card auth approved.
eprintln!("[1] Customer taps card. Expected: card-rail approve.");
let result = kiosk.checkout_card(
"ORDER-001",
1299, // $12.99
"tok_v7_demo_visa_4242",
12,
2030,
);
print_outcome("Scenario 1 (happy card)", &result);
// Scenario 2: primary card PSP times out → fallback to backup
// card PSP (within-rail fallback, the most common production
// pattern).
eprintln!("\n[2] Primary card PSP times out; orchestrator retries on backup PSP.");
let result = kiosk.checkout_card(
"ORDER-002",
4500, // $45.00
"tok_v7_demo_timeout",
12,
2030,
);
print_outcome("Scenario 2 (card PSP fallback)", &result);
// Scenario 3: A2A-native direct (customer scanned QR).
eprintln!("\n[3] Customer paid by bank transfer (UPI handle).");
let result = kiosk.checkout_a2a(
"ORDER-003",
750, // $7.50
A2aKey::Upi("alice@hdfc".into()),
);
print_outcome("Scenario 3 (A2A native)", &result);
// Scenario 4: idempotency replay (e.g. terminal lost network
// between auth and confirmation).
eprintln!("\n[4] Replay of order ORDER-001 (same idempotency key).");
let result = kiosk.checkout_card("ORDER-001", 1299, "tok_v7_demo_visa_4242", 12, 2030);
print_outcome("Scenario 4 (replay)", &result);
eprintln!(
" ↑ note attempt_count is 1, not 2 — the orchestrator returned the cached outcome\n without touching the rail. No double charge."
);
// Scenario 5: hard decline (insufficient funds).
eprintln!("\n[5] Customer's card declines hard (insufficient funds).");
let result = kiosk.checkout_card(
"ORDER-005",
50000, // $500.00
"tok_v7_demo_insufficient",
12,
2030,
);
print_outcome("Scenario 5 (hard decline)", &result);
eprintln!("\n=== Done ===");
}
fn print_outcome(
label: &str,
outcome: &op_orchestrator::Result<op_orchestrator::OrchestrationOutcome>,
) {
match outcome {
Ok(o) => {
let status_str = match o.terminal_status {
TerminalStatus::Approved => "APPROVED",
TerminalStatus::RequiresCustomerAction => "ACTION REQUIRED",
TerminalStatus::Declined => "DECLINED",
};
eprintln!(" {label}: {status_str}");
eprintln!(" attempts: {}", o.attempts.len());
for (i, a) in o.attempts.iter().enumerate() {
eprintln!(
" [{i}] rail={:?} driver={} outcome={:?}",
a.rail, a.driver, a.outcome
);
}
if let Some(rail) = o.rail_used {
eprintln!(" terminal rail: {rail:?}");
}
if let Some(id) = &o.psp_payment_id {
eprintln!(" psp_payment_id: {id}");
}
if let Some(u) = &o.uetr {
eprintln!(" uetr: {u}");
}
}
Err(e) => {
eprintln!(" {label}: ERROR {e}");
}
}
}
// ============================================================
// Kiosk: thin orchestration over the OpenPay stack.
// ============================================================
/// The unattended-checkout terminal. Owns:
///
/// - An in-process [`InMemoryVault`](op_vault::InMemoryVault) for
/// the demo. Production kiosks delegate to a hosted vault.
/// - An [`Orchestrator`] with mock card + A2A adapters registered.
struct Kiosk {
orchestrator: Orchestrator,
}
impl Kiosk {
fn new() -> Self {
let mut orchestrator = Orchestrator::new()
// Use the heuristic scorer with default thresholds. The
// amounts in our scenarios stay well below decline.
.with_scorer(Box::new(HeuristicScorer::new()))
// Two card drivers (primary + backup) and one A2A driver.
// Routing is method-driven: Vault methods try card
// drivers in order; A2A methods go to the A2A driver.
.with_router(Box::new(PolicyRouter::new(
vec!["mock-hyperswitch".into(), "mock-stripe".into()],
vec!["mock-fednow".into()],
)));
// Primary card adapter — handles the happy path. Soft-fails
// on the "timeout" demo token so we exercise the fallback.
orchestrator.register_adapter(Arc::new(CardAdapter::new(
"mock-hyperswitch",
Arc::new(MockCardAcquirer::primary()),
)));
// Backup card adapter — approves everything. Used when the
// primary soft-fails.
orchestrator.register_adapter(Arc::new(CardAdapter::new(
"mock-stripe",
Arc::new(MockCardAcquirer::backup()),
)));
// A2A adapter with merchant-side bank info.
let profile = MerchantBankProfile {
creditor_agent: ParticipantId::Aba("021000021".into()),
creditor_account: "MERCHANT-ACCT-1".into(),
creditor_name: "Acme Coffee LLC".into(),
default_debtor_agent: ParticipantId::Aba("026009593".into()),
default_debtor_name: "Customer".into(),
};
orchestrator.register_adapter(Arc::new(A2aAdapter::new(
"mock-fednow",
Arc::new(MockA2aAcquirer),
profile,
)));
Self { orchestrator }
}
/// Tap-to-pay style: card token already obtained from EMV kernel.
fn checkout_card(
&self,
order_id: &str,
amount_minor: i64,
token: &str,
exp_month: u8,
exp_year: u16,
) -> op_orchestrator::Result<op_orchestrator::OrchestrationOutcome> {
let _ = (exp_month, exp_year); // demo: token already carries exp
let intent = PaymentIntent::new(
IdempotencyKey::new(order_id),
Money::from_minor(amount_minor, Currency::USD),
PaymentMethod::Vault(VaultRef::new(token)),
)
.with_metadata("order_id", order_id);
self.orchestrator.run(&intent)
}
/// A2A-native flow: customer scanned merchant QR or selected
/// "Pay by Bank" at the kiosk.
fn checkout_a2a(
&self,
order_id: &str,
amount_minor: i64,
key: A2aKey,
) -> op_orchestrator::Result<op_orchestrator::OrchestrationOutcome> {
let intent = PaymentIntent::new(
IdempotencyKey::new(order_id),
Money::from_minor(amount_minor, Currency::USD),
PaymentMethod::A2a(key),
)
.with_metadata("order_id", order_id);
self.orchestrator.run(&intent)
}
}
// ============================================================
// Mock rail drivers.
//
// These implement the same traits as the real Hyperswitch / FedNow
// drivers. Swapping them out is a one-line change in `Kiosk::new()`.
// ============================================================
/// Mock card acquirer. The `is_backup` flag flips the timeout
/// behavior — primary soft-fails on the "timeout" demo token; backup
/// always approves.
struct MockCardAcquirer {
is_backup: bool,
}
impl MockCardAcquirer {
fn primary() -> Self {
Self { is_backup: false }
}
fn backup() -> Self {
Self { is_backup: true }
}
}
impl CardAcquirer for MockCardAcquirer {
fn name(&self) -> &'static str {
if self.is_backup {
"mock-stripe"
} else {
"mock-hyperswitch"
}
}
fn supports(&self, method: &PaymentMethod) -> bool {
matches!(
method,
PaymentMethod::Vault(_) | PaymentMethod::Wallet(_) | PaymentMethod::Emv(_)
)
}
fn authorize(&self, req: &AuthRequest) -> CardResult<AuthDecision> {
// Token-driven canned responses, so the demo scenarios are
// deterministic.
let token_str = match &req.method {
PaymentMethod::Vault(v) => v.as_str().to_owned(),
_ => "<non-vault>".to_owned(),
};
let (status, error_code) = if token_str.contains("timeout") && !self.is_backup {
// Only the primary times out; the backup approves.
(AuthStatus::Transient, Some("network_timeout"))
} else if token_str.contains("insufficient") {
(AuthStatus::HardDecline, Some("insufficient_funds"))
} else {
(AuthStatus::Settled, None)
};
Ok(AuthDecision {
psp_payment_id: format!("{}_{}", self.name(), req.idempotency_key),
status,
raw_status: format!("{status:?}").to_lowercase(),
authorized_amount: Some(req.amount),
redirect_url: None,
error_code: error_code.map(|s| s.to_owned()),
error_message: error_code.map(|s| s.to_owned()),
})
}
fn capture(&self, _req: &CaptureRequest) -> CardResult<AuthDecision> {
Err(op_rails_card::Error::DriverValidation(
"mock driver does not support capture (auto-capture only)".into(),
))
}
fn void(&self, _req: &VoidRequest) -> CardResult<AuthDecision> {
Err(op_rails_card::Error::DriverValidation(
"mock driver does not support void".into(),
))
}
fn refund(&self, _req: &RefundRequest) -> CardResult<AuthDecision> {
Err(op_rails_card::Error::DriverValidation(
"mock driver does not support refund".into(),
))
}
}
struct MockA2aAcquirer;
impl A2aAcquirer for MockA2aAcquirer {
fn name(&self) -> &'static str {
"mock-fednow"
}
fn submit_credit_transfer(&self, req: &CreditTransferReq) -> A2aResult<A2aDecision> {
// Always settle for the kiosk demo. A real driver inspects
// the rail's pacs.002 response and maps it via op-iso20022.
Ok(A2aDecision {
status: A2aStatus::Settled,
raw_status: "ACSC".into(),
reason_code: None,
reason_text: None,
uetr: Some(req.uetr.clone()),
rail_txn_id: Some(format!("FED-MOCK-{}", &req.end_to_end_id)),
settled_amount: Some(req.amount),
})
}
fn query_status(&self, _req: &StatusQueryReq) -> A2aResult<A2aDecision> {
Err(op_rails_a2a::Error::DriverValidation(
"mock driver does not support query_status".into(),
))
}
}