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).
294 lines
9.5 KiB
Rust
294 lines
9.5 KiB
Rust
//! 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());
|
||
}
|
||
}
|