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

224 lines
6.9 KiB
Rust

//! Internal-ledger transfers between connected accounts.
//!
//! No payment-rail involvement; this is purely a ledger entry move. The
//! classic use cases:
//!
//! - Marketplace re-allocating an already-collected payout among
//! sub-merchants after a dispute settlement.
//! - Platform refunding a fee leg back to a sub-merchant.
//! - Reserve releases at the end of a holdback period (see
//! [`crate::payout::PayoutSchedule::reserve_pct`]).
//!
//! The ledger here is a minimal local model — not the full `op-ledger`
//! double-entry machine. Operators wire to `op-ledger` in production;
//! this surface keeps op-connect free-standing for tests and kiosk
//! deployments. The two shapes are 1:1 compatible at the entry level.
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use op_core::{Currency, Money};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::account::AccountId;
use crate::error::{Error, Result};
/// Strongly-typed transfer identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TransferId(pub String);
impl TransferId {
/// Mint a fresh `trsf_<uuidv4>` identifier.
#[must_use]
pub fn new() -> Self {
Self(format!("trsf_{}", Uuid::new_v4().simple()))
}
}
impl Default for TransferId {
fn default() -> Self {
Self::new()
}
}
/// A single ledger entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LedgerEntry {
/// Unique identifier.
pub id: TransferId,
/// Source account (debited).
pub from: AccountId,
/// Destination account (credited).
pub to: AccountId,
/// Amount transferred.
pub amount: Money,
/// Posting time.
pub posted_at: DateTime<Utc>,
}
/// Minimal connected-account ledger.
///
/// Tracks per-account balances and an append-only entry log. Production
/// callers replace this with `op-ledger` (which adds double-entry
/// transactions, pending vs posted, account classes, etc.); this
/// surface stays in op-connect so the crate's tests don't require an
/// op-ledger dependency loop.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ledger {
balances: BTreeMap<AccountId, Money>,
entries: Vec<LedgerEntry>,
}
impl Ledger {
/// Fresh empty ledger.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Seed an account with an opening balance.
pub fn credit_opening(&mut self, acct: &AccountId, amount: Money) {
self.balances
.entry(acct.clone())
.and_modify(|m| {
if let Ok(sum) = m.checked_add(amount) {
*m = sum;
}
})
.or_insert(amount);
}
/// Read balance for an account in the given currency.
///
/// Returns zero (in `currency`) if the account is unknown or if the
/// recorded balance is in a different currency.
#[must_use]
pub fn balance(&self, acct: &AccountId, currency: Currency) -> Money {
self.balances
.get(acct)
.copied()
.filter(|m| m.currency == currency)
.unwrap_or_else(|| Money::zero(currency))
}
/// Number of posted entries.
#[must_use]
pub fn entries_len(&self) -> usize {
self.entries.len()
}
/// Iterate over the entry log.
pub fn entries(&self) -> impl Iterator<Item = &LedgerEntry> {
self.entries.iter()
}
}
/// Move funds from one connected account to another.
///
/// Pure ledger entry — no rail submission. The source account must
/// hold sufficient balance in the requested currency, and both
/// accounts must already exist in the ledger (call
/// [`Ledger::credit_opening`] to seed).
///
/// # Errors
/// - [`Error::AccountNotFound`] if `from` has no balance entry.
/// - [`Error::CurrencyMismatch`] if balances are in a different currency.
/// - [`Error::InvalidSplit`] (re-used for negative-balance protection)
/// if the source would go negative.
pub fn transfer(
from: &AccountId,
to: &AccountId,
amount: Money,
ledger: &mut Ledger,
) -> Result<TransferId> {
if amount.minor_units < 0 {
return Err(Error::InvalidSplit {
reason: "transfer amount must be non-negative".into(),
});
}
let from_balance = ledger
.balances
.get(from)
.copied()
.ok_or_else(|| Error::AccountNotFound(from.0.clone()))?;
if from_balance.currency != amount.currency {
return Err(Error::CurrencyMismatch(format!(
"source account currency {} != transfer currency {}",
from_balance.currency, amount.currency
)));
}
let new_from = from_balance.checked_sub(amount).map_err(|_| Error::Overflow)?;
if new_from.minor_units < 0 {
return Err(Error::InvalidSplit {
reason: format!("insufficient balance in {}", from.0),
});
}
let to_balance = ledger
.balances
.get(to)
.copied()
.unwrap_or_else(|| Money::zero(amount.currency));
if to_balance.currency != amount.currency {
return Err(Error::CurrencyMismatch(format!(
"destination account currency {} != transfer currency {}",
to_balance.currency, amount.currency
)));
}
let new_to = to_balance.checked_add(amount).map_err(|_| Error::Overflow)?;
ledger.balances.insert(from.clone(), new_from);
ledger.balances.insert(to.clone(), new_to);
let entry = LedgerEntry {
id: TransferId::new(),
from: from.clone(),
to: to.clone(),
amount,
posted_at: Utc::now(),
};
let id = entry.id.clone();
ledger.entries.push(entry);
Ok(id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_transfer_moves_balance() {
let a = AccountId("acct_a".into());
let b = AccountId("acct_b".into());
let mut led = Ledger::new();
led.credit_opening(&a, Money::from_minor(10_000, Currency::USD));
led.credit_opening(&b, Money::from_minor(0, Currency::USD));
transfer(&a, &b, Money::from_minor(2_500, Currency::USD), &mut led)
.expect("ok");
assert_eq!(
led.balance(&a, Currency::USD),
Money::from_minor(7_500, Currency::USD)
);
assert_eq!(
led.balance(&b, Currency::USD),
Money::from_minor(2_500, Currency::USD)
);
assert_eq!(led.entries_len(), 1);
}
#[test]
fn insufficient_balance_blocks() {
let a = AccountId("acct_a".into());
let b = AccountId("acct_b".into());
let mut led = Ledger::new();
led.credit_opening(&a, Money::from_minor(100, Currency::USD));
led.credit_opening(&b, Money::from_minor(0, Currency::USD));
let err = transfer(&a, &b, Money::from_minor(200, Currency::USD), &mut led)
.expect_err("insufficient");
assert!(matches!(err, Error::InvalidSplit { .. }));
}
}