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

183 lines
6.1 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.

//! Holdback (reserve) policy.
//!
//! A holdback is the slice of a batch's gross amount that the
//! operator withholds from payout to cover risk: open disputes,
//! likely chargebacks, refund pressure. Two layers:
//!
//! - `flat_rate_bps` — the operator's static reserve, expressed
//! in basis points (1 bp = 0.01%). For a 50bp reserve on a
//! $10,000 batch, holdback is $50.
//! - `dispute_adjustment_bps` — an additional reserve that scales
//! with the dispute load on the underlying transactions
//! (operator-supplied — we don't read the dispute store from
//! here, callers compute it and pass it in).
//!
//! Holdback floors at the gross — we never "release" more than
//! the operator earned.
use op_core::Money;
use serde::{Deserialize, Serialize};
use crate::error::Result;
/// Configuration knob for holdback computation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HoldbackPolicy {
/// Static reserve in basis points. `0` means no reserve.
pub flat_rate_bps: u16,
/// Per-batch ceiling in basis points, including the dispute
/// adjustment. `10_000` (= 100%) means no ceiling beyond the
/// gross. Common operator values: `2_000` (20%).
pub max_total_bps: u16,
}
impl Default for HoldbackPolicy {
fn default() -> Self {
Self {
flat_rate_bps: 0,
max_total_bps: 10_000,
}
}
}
impl HoldbackPolicy {
/// A policy that withholds nothing.
#[must_use]
pub const fn none() -> Self {
Self {
flat_rate_bps: 0,
max_total_bps: 10_000,
}
}
/// Build a policy from a flat-rate basis-point reserve.
#[must_use]
pub const fn flat(flat_rate_bps: u16) -> Self {
Self {
flat_rate_bps,
max_total_bps: 10_000,
}
}
/// Builder: cap the total reserve at `max_total_bps` (flat +
/// dispute adjustment).
#[must_use]
pub const fn with_ceiling(mut self, max_total_bps: u16) -> Self {
self.max_total_bps = max_total_bps;
self
}
/// Compute the holdback for a `gross` batch and an operator-
/// supplied `dispute_adjustment_bps`.
///
/// # Errors
/// Propagates [`op_core::Error::Overflow`] if intermediate
/// arithmetic overflows `i64` (only with absurd inputs).
pub fn compute(&self, gross: Money, dispute_adjustment_bps: u16) -> Result<Holdback> {
let combined_bps =
u32::from(self.flat_rate_bps).saturating_add(u32::from(dispute_adjustment_bps));
let effective_bps = combined_bps.min(u32::from(self.max_total_bps));
// gross * effective_bps / 10_000 with checked arithmetic.
// i64::MAX / 10_000 ≈ 9.2e14 minor units. Even a wildly
// extreme batch ($1B in cents = 1e11 minor) * 65_535 fits.
let minor = gross.minor_units;
let scaled = i128::from(minor) * i128::from(effective_bps) / 10_000;
let scaled_minor = i64::try_from(scaled).map_err(|_| op_core::Error::Overflow)?;
let reserve = Money::from_minor(scaled_minor.abs(), gross.currency);
// Clamp to gross.
let reserve = if reserve.minor_units > gross.minor_units.abs() {
Money::from_minor(gross.minor_units.abs(), gross.currency)
} else {
reserve
};
Ok(Holdback {
gross,
reserve,
flat_rate_bps: self.flat_rate_bps,
dispute_adjustment_bps,
})
}
}
/// The result of applying [`HoldbackPolicy`] to a batch.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Holdback {
/// The gross batch amount before reserve.
pub gross: Money,
/// The withheld portion.
pub reserve: Money,
/// The flat rate applied (basis points).
pub flat_rate_bps: u16,
/// The risk adjustment applied (basis points).
pub dispute_adjustment_bps: u16,
}
impl Holdback {
/// Net amount paid out (`gross − reserve`).
///
/// # Errors
/// Propagates [`op_core::Error`] on the subtraction.
pub fn net(&self) -> Result<Money> {
Ok(self.gross.checked_sub(self.reserve)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use op_core::{Currency, Money};
#[test]
fn no_reserve_passes_through() {
let p = HoldbackPolicy::none();
let h = p
.compute(Money::from_minor(1_000_000, Currency::USD), 0)
.unwrap();
assert_eq!(h.reserve, Money::from_minor(0, Currency::USD));
assert_eq!(h.net().unwrap(), h.gross);
}
#[test]
fn flat_50_bps_on_10k() {
// 50bp on $10,000.00 = $50.00 = 5_000 minor.
let p = HoldbackPolicy::flat(50);
let h = p
.compute(Money::from_minor(1_000_000, Currency::USD), 0)
.unwrap();
assert_eq!(h.reserve, Money::from_minor(5_000, Currency::USD));
assert_eq!(h.net().unwrap(), Money::from_minor(995_000, Currency::USD));
}
#[test]
fn dispute_adjustment_adds_to_flat() {
// 100bp flat + 200bp dispute = 300bp on $1,000 = $30.
let p = HoldbackPolicy::flat(100);
let h = p
.compute(Money::from_minor(100_000, Currency::USD), 200)
.unwrap();
assert_eq!(h.reserve, Money::from_minor(3_000, Currency::USD));
}
#[test]
fn ceiling_caps_combined() {
// 100bp flat + 9000bp dispute would be 91% reserve, but
// ceiling is 20%.
let p = HoldbackPolicy::flat(100).with_ceiling(2_000);
let h = p
.compute(Money::from_minor(100_000, Currency::USD), 9_000)
.unwrap();
// 20% of $1,000 = $200 = 20_000 minor.
assert_eq!(h.reserve, Money::from_minor(20_000, Currency::USD));
}
#[test]
fn reserve_clamps_to_gross() {
// Wildly large dispute_adjustment (uncapped) should never
// exceed the gross.
let p = HoldbackPolicy::flat(u16::MAX).with_ceiling(u16::MAX);
let gross = Money::from_minor(100_000, Currency::USD);
let h = p.compute(gross, u16::MAX).unwrap();
assert_eq!(h.reserve, gross);
assert_eq!(h.net().unwrap(), Money::from_minor(0, Currency::USD));
}
}