open-standards/openpay/crates/op-connect/src/split.rs
Transaction Science 3fe109d58f ingest batch 4: 9 crates · table-stakes completers across all three workspaces
OpenPay (3 crates, ~13k LOC, 175 tests)
  op-connect           sub-merchant onboarding + KYB + split-payments + payouts + 1099-K
  op-3ds2              3DS2.3 SCA processor (AReq/ARes/CReq/CRes/RReq/RRes + exemptions)
  op-tax               US/EU/UK/AU/CA/IN tax with Avalara/Stripe/CCH/Vertex/TaxJar adapters

Smart Byte (3 crates, ~7k LOC, 57 tests)
  smart-byte-libp2p    libp2p transport (kad/gossipsub/mdns/autonat/relay/dcutr/identify/ping/req_resp)
  smart-byte-crdt      LWW/GCounter/PnCounter/OrSet/LwwMap/RgaList/2P-Set + HLC + vector clock + Automerge/Yjs interop
  smart-byte-keri-witness  KERI key-rotation cycle + witness layer + watchers + recovery + delegation

EOC (3 crates, ~8k LOC, 108 tests)
  eoc-tools            function-calling across Anthropic/OpenAI/Google/Mistral/Cohere + parallel exec + sandbox
  eoc-multimodal       vision (CLIP/SigLIP/ColPali) + audio (Whisper/MMS) + video sampling + modality router
  eoc-spec-decode      vanilla + SpS-with-temp + lookahead speculative decoding + EAGLE/Medusa stubs

All three workspaces compile cleanly. Forbidden-unsafe, no unwrap outside tests.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 04:00:04 -04:00

255 lines
8.3 KiB
Rust

//! Split-payments engine.
//!
//! A single inbound payment can be split into many outbound legs. The
//! canonical case for a marketplace: a $100 ticket sale splits into
//! $95 to the event organiser (sub-merchant) and $5 to the marketplace
//! (platform fee). Three-way splits add an affiliate / third-party
//! recipient leg.
//!
//! ## Invariants
//!
//! 1. `sum(legs.amount) == source.amount`.
//! 2. Every leg has the same currency as the source. Cross-currency
//! splits require an FX leg upstream (out of scope for this module;
//! see `op-fx`).
//! 3. No leg may have a negative amount.
//! 4. At most one leg per `destination` (operators wanting two pays
//! to the same destination should aggregate first).
//!
//! Fee legs are tagged (`is_fee: true`) so downstream reporting
//! (1099-K, ledger entries, analytics) can separate platform revenue
//! from pass-through.
use std::collections::BTreeSet;
use op_core::{Currency, Money};
use serde::{Deserialize, Serialize};
use crate::account::AccountId;
use crate::error::{Error, Result};
/// Identifier for the source payment being split.
///
/// Local newtype — production callers pull this from `op-core::payment`
/// or their own payment-id namespace.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PaymentId(pub String);
impl PaymentId {
/// Borrow the inner string.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// A single outbound leg of a payment split.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SplitLeg {
/// Connected account receiving this leg.
pub destination: AccountId,
/// Amount being routed to `destination`.
pub amount: Money,
/// Currency (denormalised from `amount.currency` for clearer JSON;
/// validated to match at build time).
pub currency: Currency,
/// Free-text description (appears on the destination's ledger entry).
pub description: String,
/// True if this leg is platform revenue rather than pass-through.
pub is_fee: bool,
/// Optional grouping key for reporting (e.g. campaign id, batch id).
pub transfer_group: Option<String>,
}
impl SplitLeg {
/// Construct a leg, validating currency-coherence between
/// `amount.currency` and the standalone `currency` field.
///
/// # Errors
/// [`Error::CurrencyMismatch`] if the two disagree.
pub fn try_new(
destination: AccountId,
amount: Money,
description: impl Into<String>,
is_fee: bool,
transfer_group: Option<String>,
) -> Result<Self> {
let currency = amount.currency;
Ok(Self {
destination,
amount,
currency,
description: description.into(),
is_fee,
transfer_group,
})
}
}
/// A complete split-payment plan.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentSplit {
/// The inbound payment whose proceeds are being distributed.
pub source_payment_id: PaymentId,
/// Outbound legs.
pub splits: Vec<SplitLeg>,
}
impl PaymentSplit {
/// Validate the split against the source amount.
///
/// # Errors
/// - [`Error::InvalidSplit`] if `legs` is empty, contains negatives,
/// contains duplicate destinations, or doesn't sum to `source_amount`.
/// - [`Error::CurrencyMismatch`] if any leg's currency disagrees with
/// `source_amount.currency`.
/// - [`Error::Overflow`] if the leg amounts overflow on summation.
pub fn validate(&self, source_amount: Money) -> Result<()> {
if self.splits.is_empty() {
return Err(Error::InvalidSplit {
reason: "no legs".into(),
});
}
// Negative legs?
for (i, leg) in self.splits.iter().enumerate() {
if leg.amount.minor_units < 0 {
return Err(Error::InvalidSplit {
reason: format!("leg {i} has negative amount {}", leg.amount.minor_units),
});
}
if leg.currency != source_amount.currency {
return Err(Error::CurrencyMismatch(format!(
"leg {i} currency {} != source currency {}",
leg.currency, source_amount.currency
)));
}
if leg.amount.currency != leg.currency {
return Err(Error::CurrencyMismatch(format!(
"leg {i} self-inconsistent: amount.currency={} but leg.currency={}",
leg.amount.currency, leg.currency
)));
}
}
// Duplicate destinations?
let mut seen: BTreeSet<&str> = BTreeSet::new();
for leg in &self.splits {
if !seen.insert(leg.destination.as_str()) {
return Err(Error::InvalidSplit {
reason: format!(
"duplicate destination {} in split",
leg.destination.as_str()
),
});
}
}
// Sum.
let mut total = Money::zero(source_amount.currency);
for leg in &self.splits {
total = total
.checked_add(leg.amount)
.map_err(|_| Error::Overflow)?;
}
if total != source_amount {
return Err(Error::InvalidSplit {
reason: format!(
"split legs sum to {} but source is {}",
total, source_amount
),
});
}
Ok(())
}
/// Sum of fee legs in `currency`.
///
/// # Errors
/// [`Error::Overflow`] if the sum overflows.
pub fn fee_total(&self, currency: Currency) -> Result<Money> {
let mut total = Money::zero(currency);
for leg in &self.splits {
if leg.is_fee && leg.currency == currency {
total = total
.checked_add(leg.amount)
.map_err(|_| Error::Overflow)?;
}
}
Ok(total)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn acct(s: &str) -> AccountId {
AccountId(s.into())
}
fn usd(minor: i64) -> Money {
Money::from_minor(minor, Currency::USD)
}
#[test]
fn even_split_validates() {
let split = PaymentSplit {
source_payment_id: PaymentId("pay_1".into()),
splits: vec![
SplitLeg::try_new(acct("acct_merchant"), usd(9500), "merchant payout", false, None)
.expect("ok"),
SplitLeg::try_new(acct("acct_platform"), usd(500), "platform fee", true, None)
.expect("ok"),
],
};
split.validate(usd(10_000)).expect("validates");
assert_eq!(split.fee_total(Currency::USD).expect("ok"), usd(500));
}
#[test]
fn oversum_fails() {
let split = PaymentSplit {
source_payment_id: PaymentId("pay_2".into()),
splits: vec![
SplitLeg::try_new(acct("acct_merchant"), usd(9500), "m", false, None)
.expect("ok"),
SplitLeg::try_new(acct("acct_platform"), usd(600), "p", true, None)
.expect("ok"),
],
};
let err = split.validate(usd(10_000)).expect_err("oversum");
assert!(matches!(err, Error::InvalidSplit { .. }));
}
#[test]
fn negative_leg_fails() {
let split = PaymentSplit {
source_payment_id: PaymentId("pay_3".into()),
splits: vec![SplitLeg::try_new(
acct("acct_x"),
usd(-100),
"bad",
false,
None,
)
.expect("ok")],
};
let err = split.validate(usd(-100)).expect_err("negative");
assert!(matches!(err, Error::InvalidSplit { .. }));
}
#[test]
fn duplicate_destinations_fail() {
let split = PaymentSplit {
source_payment_id: PaymentId("pay_4".into()),
splits: vec![
SplitLeg::try_new(acct("acct_m"), usd(5000), "a", false, None).expect("ok"),
SplitLeg::try_new(acct("acct_m"), usd(5000), "b", false, None).expect("ok"),
],
};
let err = split.validate(usd(10_000)).expect_err("dupe");
assert!(matches!(err, Error::InvalidSplit { .. }));
}
}