open-standards/openpay/crates/op-orchestrator/src/intent.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

221 lines
8 KiB
Rust

//! Payment intent — the orchestrator's input.
//!
//! An intent is what the merchant code submits: "charge X for this
//! item with this method." It is **not** a payment yet — the
//! orchestrator turns it into one (or several rail attempts) and
//! returns an [`OrchestrationOutcome`](crate::OrchestrationOutcome).
use op_core::{Money, PaymentMethod};
use crate::idempotency::IdempotencyKey;
/// Hints to the [`Router`](crate::Router) about how to pick a rail.
///
/// All hints are optional. A router free of hints falls back to the
/// method-driven default (`Vault | Wallet | Emv → Card`,
/// `A2a | Qr → A2A`).
#[derive(Debug, Clone, Default)]
pub struct RoutingHints {
/// ISO-3166-1 alpha-2 country code of the customer, if known.
/// Used by the [`PolicyRouter`](crate::PolicyRouter) to prefer
/// in-country instant rails over cross-border card auths
/// (lower cost, lower latency).
pub customer_country: Option<String>,
/// ISO-3166-1 alpha-2 country code of the merchant.
pub merchant_country: Option<String>,
/// Three-letter MCC equivalent if the merchant has one (we don't
/// model the full ISO 18245 MCC list here — operators set their
/// own taxonomy via this string).
pub merchant_category: Option<String>,
/// Opaque BIN bucket (e.g. `"visa-credit-consumer"`,
/// `"mastercard-debit-business"`). Lets policy routers prefer
/// rails by issuer characteristics without us re-implementing
/// BIN lookup here.
pub bin_bucket: Option<String>,
/// If the customer is enrolled for 3DS, set this so the router
/// can prefer card networks that support 3DS frictionless flow.
pub three_ds_enrolled: bool,
/// If true, the customer has indicated a preference for paying
/// directly from their bank account (e.g. selected "Pay by
/// Bank" at checkout). Causes the router to prefer A2A rails.
pub prefer_a2a: bool,
}
/// A payment intent — the unit of work the orchestrator processes.
///
/// Idempotency-keyed; two intents with the same key are treated as
/// the same request and the cached outcome is returned for the
/// second one.
#[derive(Debug, Clone)]
pub struct PaymentIntent {
/// Caller-supplied idempotency key. Must be unique per logical
/// request; reuse with a different body is rejected as
/// [`Error::IdempotencyMismatch`](crate::Error::IdempotencyMismatch).
pub idempotency_key: IdempotencyKey,
/// Amount to charge.
pub amount: Money,
/// How the customer is paying — vault token, EMV terminal data,
/// A2A bank account reference, etc.
pub method: PaymentMethod,
/// Hints to the router.
pub hints: RoutingHints,
/// Free-form metadata to round-trip back on the outcome.
/// Typically merchant order id, line items, customer id.
pub metadata: Vec<(String, String)>,
}
impl PaymentIntent {
/// Construct a minimal intent. Hints default; metadata is empty.
pub fn new(idempotency_key: IdempotencyKey, amount: Money, method: PaymentMethod) -> Self {
Self {
idempotency_key,
amount,
method,
hints: RoutingHints::default(),
metadata: Vec::new(),
}
}
/// Builder: set routing hints.
#[must_use]
pub fn with_hints(mut self, hints: RoutingHints) -> Self {
self.hints = hints;
self
}
/// Builder: append a metadata pair.
#[must_use]
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.push((key.into(), value.into()));
self
}
/// A deterministic signature of the body fields that must match
/// across retries with the same idempotency key. Hash-like.
/// Used by [`IdempotencyStore`](crate::IdempotencyStore) to
/// detect [`Error::IdempotencyMismatch`](crate::Error::IdempotencyMismatch).
pub fn body_signature(&self) -> String {
// Compact stringification — same idempotency key but
// different amount/method/country/etc. → different signature
// → IdempotencyMismatch. We deliberately do NOT include
// metadata (it's free-form and the merchant may legitimately
// add e.g. a new client-side timestamp on retry).
format!(
"amount={}|currency={}|method={}|cc={}|mc={}",
self.amount.minor_units,
self.amount.currency.code(),
method_signature(&self.method),
self.hints.customer_country.as_deref().unwrap_or(""),
self.hints.merchant_country.as_deref().unwrap_or(""),
)
}
}
fn method_signature(m: &PaymentMethod) -> String {
// op_core::PaymentMethod doesn't impl Display; we synthesize a
// signature using the variant tag plus an opaque-but-stable
// discriminator. Token strings and account-number digests are
// exactly the kind of thing the merchant might mutate by accident
// on retry, so we DO include them.
//
// Note: A2aKey is an enum (Upi/Pix/Iban/UsAch); we project each
// variant to a single string for signature purposes. This is not
// a security boundary — same intent must yield same string, that
// is all.
match m {
PaymentMethod::Vault(v) => format!("Vault:{}", v.as_str()),
PaymentMethod::Wallet(_) => "Wallet:<opaque>".to_owned(),
PaymentMethod::Emv(_) => "Emv:<binary>".to_owned(),
PaymentMethod::A2a(k) => format!("A2a:{}", a2a_key_signature(k)),
PaymentMethod::Qr(s) => format!("Qr:{s}"),
PaymentMethod::Crypto(a) => format!("Crypto:{}:{}", a.chain, a.address),
// RawPan reaching the orchestrator means a vault contract broke.
// We project to a stable, non-revealing string so idempotency
// matching is still deterministic; PCI scope already mandates
// upstream audit logs catch this separately.
PaymentMethod::RawPan(_) => "RawPan:<pci-scoped>".to_owned(),
}
}
fn a2a_key_signature(k: &op_core::A2aKey) -> String {
use op_core::A2aKey::*;
match k {
Upi(h) => format!("upi:{h}"),
Pix(h) => format!("pix:{h}"),
Iban(i) => format!("iban:{i}"),
UsAch { routing, account } => format!("usach:{routing}:{account}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use op_core::{Currency, VaultRef};
fn intent_a() -> PaymentIntent {
PaymentIntent::new(
IdempotencyKey::from("key-1"),
Money::from_minor(1234, Currency::USD),
PaymentMethod::Vault(VaultRef::new("tok_v7_abc")),
)
}
#[test]
fn signature_is_stable_for_identical_intents() {
let a = intent_a();
let b = intent_a();
assert_eq!(a.body_signature(), b.body_signature());
}
#[test]
fn signature_changes_when_amount_changes() {
let a = intent_a();
let mut b = intent_a();
b.amount = Money::from_minor(9999, Currency::USD);
assert_ne!(a.body_signature(), b.body_signature());
}
#[test]
fn signature_changes_when_method_changes() {
let a = intent_a();
let b = PaymentIntent::new(
IdempotencyKey::from("key-1"),
Money::from_minor(1234, Currency::USD),
PaymentMethod::Vault(VaultRef::new("tok_v7_OTHER")),
);
assert_ne!(a.body_signature(), b.body_signature());
}
#[test]
fn signature_does_not_change_with_metadata() {
let a = intent_a();
let b = intent_a().with_metadata("order_id", "ORD-42");
assert_eq!(a.body_signature(), b.body_signature());
}
#[test]
fn builder_pattern_compiles() {
let intent = PaymentIntent::new(
IdempotencyKey::from("k"),
Money::from_minor(100, Currency::USD),
PaymentMethod::Vault(VaultRef::new("tok_v7_x")),
)
.with_hints(RoutingHints {
customer_country: Some("US".into()),
..Default::default()
})
.with_metadata("k1", "v1")
.with_metadata("k2", "v2");
assert_eq!(intent.metadata.len(), 2);
assert_eq!(intent.hints.customer_country.as_deref(), Some("US"));
}
}