open-standards/openpay/crates/op-bnpl/src/intent.rs
Transaction Science 6575ff1eb7 Ingest batch 3: 9 more crates across openpay / smart-byte / eoc
openpay/ (+3 crates)
  op-bnpl/        Affirm + Klarna + Afterpay/Clearpay rails. Full lifecycle
                  typestate (Initiated→Approved→Captured→Settled→Refunded).
                  Per-provider sandbox+production base URLs, regions (NA/EU/OC
                  for Klarna; US/AuNz/Gb/Ca/Eu for Afterpay). HMAC-SHA256
                  webhook verification with constant-time compare. 61 tests.
  op-batch/       NACHA ACH (94-char records, PPD/CCD/WEB/TEL/CTX, R01-R99
                  return codes, Same-Day ACH cutoffs); SEPA pain.001.001.09
                  (CT) + pain.008.001.08 (DD) with SCT-Inst flagging; Fedwire
                  MT103/MT202/CHIPS + pacs.008.001.12 / pacs.009.001.10 +
                  UETR; Bacs Standard 18. Per-rail cutoff scheduler. Exception
                  taxonomy unified across rails. 51 tests.
  op-screening/   OFAC SDN + EU consolidated + UN consolidated + HMT UK
                  consolidated + Australia DFAT + Canada SEMA + Japan MOF.
                  Levenshtein + Jaro-Winkler + Soundex + Metaphone + token
                  Jaccard scoring. Unicode NFKD normalisation + Cyrillic /
                  Arabic transliteration. Ed25519-signed BLAKE3-chained
                  audit log. BSA helpers: CTR + SAR pattern detection
                  (structuring, round-dollar, rapid-in-out). 43 tests.

smart-byte/smart-byte-rs/ (+3 crates)
  smart-byte-bbs/      BBS+ signatures (IETF draft-irtf-cfrg-bbs-signatures-08).
                       BLS12-381 keys, signatures, disclosure proofs with
                       cryptographic unlinkability. bbs-2023 Data Integrity
                       cryptosuite. AnonCreds-shaped link secrets +
                       predicate proofs. SecretKey zeroize-on-drop. 49 tests.
  smart-byte-didcomm/  DIDComm v2 messaging per DIF spec. Pack-encrypted
                       (ECDH-1PU + ECDH-ES via X25519), pack-signed (JWS),
                       pack-plaintext. Aries protocols: issue-credential v3,
                       present-proof v3, basic-message, trust-ping, discover-
                       features, out-of-band, coordinate-mediation, message-
                       pickup. Mediator role with pluggable storage. 23 tests.
  smart-byte-mdl/      ISO/IEC 18013-5 mobile driver's licence + 18013-7
                       online presentment. mDoc + MSO with salted-hash
                       selective disclosure (per item; SHA-256 digest tree).
                       org.iso.18013.5.1 namespace + AAMVA add-on. ES256
                       device-auth + issuer-auth. QR-code device-engagement
                       scaffolding. 28 tests.

eoc/eoc-rs/ (+3 crates)
  eoc-local/      Local-inference backends behind one NeuralBackend:
                  llama.cpp (GGUF, Q4/Q5/Q6/Q8/F16/F32 quantisation),
                  MLX (Apple Silicon, feature-gated), MLC-LLM (TVM, feature-
                  gated marker), ONNX Runtime. GGUF file-format reader.
                  HuggingFace tokenizers. Greedy / top-k / top-p / temperature /
                  Mirostat samplers. Model registry scanning ~/.eoc/models/.
                  Joule attribution via eoc-meter. 39 tests.
  eoc-rerank/     BM25 sparse + HNSW dense + RRF + min-max hybrid fusion.
                  Cohere v3.5 + Voyage v2 + bge-reranker v2-m3 + ms-marco
                  MiniLM + ColBERT v2 rerankers. Two-stage pipeline:
                  retrieve top-K hybrid → cross-encoder rerank → top-N.
                  KV-stage recovery shim into the cascade. 31 tests.
  eoc-eval/       15 canonical eval harnesses: MMLU, MMLU-Pro, GPQA,
                  HumanEval (pass@1 with python3 sandbox), BBH, IFEval (25
                  verifiable constraints), AlpacaEval, AGIEval, HellaSwag,
                  ARC (Easy + Challenge), TruthfulQA (MC1+MC2), BoolQ,
                  GSM8K, MATH (boxed-answer equivalence), Winogrande. Real
                  questions in BuiltinSamples. EvalRunner emits joules-per-
                  correct alongside accuracy. 49 tests.

Quality:
- cargo check across all three workspaces: clean
- cargo clippy --all-targets -- -D warnings on every new crate: clean
- 374 new tests, all passing
- #![forbid(unsafe_code)] across every new crate
- No unwrap/expect outside #[cfg(test)]

Rust 1.95.0 edition 2024 (inherited via root rust-toolchain.toml).
2026-05-24 01:09:35 -04:00

294 lines
9.5 KiB
Rust
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.

//! Checkout intent: cart contents + parties + idempotency.
//!
//! All three providers want roughly the same payload at session
//! creation, with provider-specific field name remapping. Modelling the
//! intent once and translating per-provider keeps the orchestrator
//! provider-agnostic.
use std::collections::BTreeMap;
use op_core::{Currency, Money};
use serde::{Deserialize, Serialize};
/// Caller-supplied idempotency key.
///
/// Stripe/Adyen convention: a UUID v4 (or similar) the merchant
/// generates per logical request. The acquirer caches the *outcome*
/// keyed by this string so retries don't double-charge the consumer.
///
/// `op-bnpl` defines its own newtype rather than depending on
/// `op-orchestrator::IdempotencyKey` to keep this crate's dependency
/// graph clean — the orchestrator imports `op-bnpl`, not the other
/// way around.
#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdempotencyKey(String);
impl IdempotencyKey {
/// Construct from any string.
#[must_use]
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
/// Borrow the underlying string.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<T: Into<String>> From<T> for IdempotencyKey {
fn from(s: T) -> Self {
Self::new(s)
}
}
/// A single line item in the consumer's cart.
///
/// Affirm and Klarna both require line-item granularity (Affirm uses
/// `items`, Klarna uses `order_lines`); Afterpay accepts them as
/// `items[]` too. Modelling once and shape-shifting per-provider is
/// less error-prone than three near-duplicate structs.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LineItem {
/// Human-readable display name.
pub name: String,
/// Optional merchant SKU.
pub sku: Option<String>,
/// Quantity (whole units; BNPL providers don't accept fractional).
pub quantity: u32,
/// Unit price.
pub unit_price: Money,
/// Quantity × unit_price, redundant by design so the caller's
/// rounding is authoritative.
pub total_amount: Money,
}
/// Shipping destination. BNPL providers underwrite partly on shipping
/// address (anti-fraud, jurisdiction).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShippingInfo {
/// Recipient name.
pub name: String,
/// First street line.
pub line1: String,
/// Optional second street line.
pub line2: Option<String>,
/// City / locality.
pub city: String,
/// State / province / region (ISO 3166-2 subdivision code if known).
pub region: String,
/// Postal / ZIP code.
pub postal_code: String,
/// ISO 3166-1 alpha-2 country code.
pub country: String,
}
/// Billing address. May differ from shipping (especially for
/// gift purchases).
pub type BillingInfo = ShippingInfo;
/// Consumer-identifying hint passed to the provider for underwriting.
///
/// Providers verify these against their own KYC and credit records.
/// The merchant supplies what it knows; the provider does not require
/// any single field but more data gives a better approval rate.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConsumerInfo {
/// Email (canonical identifier across all three providers).
pub email: String,
/// E.164 phone number.
pub phone: Option<String>,
/// Given (first) name.
pub given_name: Option<String>,
/// Family (last) name.
pub family_name: Option<String>,
/// Date of birth in ISO 8601 (YYYY-MM-DD). Required by Afterpay's
/// age-gate; optional for Affirm/Klarna.
pub date_of_birth: Option<String>,
}
/// Merchant-controlled redirect endpoints. After the consumer
/// accepts or cancels the provider's flow, the provider redirects
/// the browser back to one of these.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RedirectUrls {
/// Where the provider sends the browser on consumer acceptance.
pub success: String,
/// Where the provider sends the browser on consumer cancel.
pub cancel: String,
/// Where the provider sends the browser on hard failure (declined,
/// timeout). Falls back to `cancel` if absent.
pub failure: Option<String>,
}
/// A BNPL checkout intent.
///
/// Provider-neutral. The provider-specific acquirer consumes this and
/// emits the right JSON shape over the wire.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BnplIntent {
/// Total amount (sum of `line_items` + shipping + taxes if any).
pub amount: Money,
/// Currency. Redundant with `amount.currency` but explicit for
/// readability and the rare cross-currency-checkout case.
pub currency: Currency,
/// Cart contents.
pub line_items: Vec<LineItem>,
/// Shipping destination.
pub shipping: ShippingInfo,
/// Billing address.
pub billing: BillingInfo,
/// Consumer identity hint.
pub consumer: ConsumerInfo,
/// Idempotency key.
pub idempotency_key: IdempotencyKey,
/// Browser redirect endpoints.
pub redirect_urls: RedirectUrls,
/// Free-form metadata. Each provider forwards a different subset
/// as opaque key/value pairs.
pub metadata: BTreeMap<String, String>,
}
impl BnplIntent {
/// Sanity-check the intent: positive amount, non-empty cart, line
/// totals in the same currency.
///
/// # Errors
/// [`crate::Error::InvalidIntent`] on any violation.
pub fn validate(&self) -> crate::Result<()> {
if !self.amount.is_positive() {
return Err(crate::Error::InvalidIntent(
"amount must be positive".into(),
));
}
if self.amount.currency != self.currency {
return Err(crate::Error::InvalidIntent(
"amount.currency != intent.currency".into(),
));
}
if self.line_items.is_empty() {
return Err(crate::Error::InvalidIntent(
"line_items must not be empty".into(),
));
}
for li in &self.line_items {
if li.unit_price.currency != self.currency
|| li.total_amount.currency != self.currency
{
return Err(crate::Error::InvalidIntent(format!(
"line item {:?} currency mismatch",
li.name
)));
}
if li.quantity == 0 {
return Err(crate::Error::InvalidIntent(format!(
"line item {:?} has zero quantity",
li.name
)));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_li(currency: Currency) -> LineItem {
LineItem {
name: "Widget".into(),
sku: Some("W-1".into()),
quantity: 2,
unit_price: Money::from_minor(5_000, currency),
total_amount: Money::from_minor(10_000, currency),
}
}
fn sample_intent() -> BnplIntent {
BnplIntent {
amount: Money::from_minor(10_000, Currency::USD),
currency: Currency::USD,
line_items: vec![sample_li(Currency::USD)],
shipping: ShippingInfo {
name: "Alice".into(),
line1: "1 Market St".into(),
line2: None,
city: "San Francisco".into(),
region: "CA".into(),
postal_code: "94105".into(),
country: "US".into(),
},
billing: ShippingInfo {
name: "Alice".into(),
line1: "1 Market St".into(),
line2: None,
city: "San Francisco".into(),
region: "CA".into(),
postal_code: "94105".into(),
country: "US".into(),
},
consumer: ConsumerInfo {
email: "[email protected]".into(),
phone: Some("+14155551212".into()),
given_name: Some("Alice".into()),
family_name: Some("Smith".into()),
date_of_birth: Some("1990-01-01".into()),
},
idempotency_key: IdempotencyKey::new("idem-1"),
redirect_urls: RedirectUrls {
success: "https://m.example/ok".into(),
cancel: "https://m.example/cancel".into(),
failure: None,
},
metadata: BTreeMap::new(),
}
}
#[test]
fn valid_intent_passes() {
assert!(sample_intent().validate().is_ok());
}
#[test]
fn zero_amount_rejected() {
let mut i = sample_intent();
i.amount = Money::from_minor(0, Currency::USD);
assert!(i.validate().is_err());
}
#[test]
fn empty_cart_rejected() {
let mut i = sample_intent();
i.line_items.clear();
assert!(i.validate().is_err());
}
#[test]
fn line_item_currency_mismatch_rejected() {
let mut i = sample_intent();
i.line_items[0].unit_price = Money::from_minor(5_000, Currency::EUR);
assert!(i.validate().is_err());
}
#[test]
fn zero_quantity_rejected() {
let mut i = sample_intent();
i.line_items[0].quantity = 0;
assert!(i.validate().is_err());
}
#[test]
fn idempotency_key_round_trips() {
let k = IdempotencyKey::from("abc-123");
assert_eq!(k.as_str(), "abc-123");
}
#[test]
fn currency_mismatch_amount_vs_intent_rejected() {
let mut i = sample_intent();
i.amount = Money::from_minor(10_000, Currency::EUR);
assert!(i.validate().is_err());
}
}