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

298 lines
10 KiB
Rust

//! Sub-merchant payout orchestration.
//!
//! Payouts are the outbound leg of the platform model: after a
//! sub-merchant's collected balance crosses the schedule's trigger
//! point (daily / weekly / monthly / on-demand), funds move from the
//! platform's pooled account to the sub-merchant's external bank
//! account.
//!
//! Actual rail submission is delegated to `op-batch` (NACHA / SEPA /
//! BACS) or `op-rails-a2a` (FedNow / RTP) — this module computes
//! schedules and reserves and produces the [`PayoutInstruction`] the
//! rail driver consumes.
//!
//! ## Holdback / rolling reserve
//!
//! Per-account `reserve_pct` (typically 5-15%) is withheld on each
//! payout to cover chargebacks. The reserve releases after a 180-day
//! window (the Visa / Mastercard chargeback dispute deadline). This
//! crate exposes the reserve computation; the actual escrow / release
//! machinery lives one layer up.
use chrono::{DateTime, Datelike, Days, NaiveDate, Utc, Weekday};
use op_core::{Currency, Money};
use serde::{Deserialize, Serialize};
use crate::account::AccountId;
use crate::error::{Error, Result};
/// Cadence at which payouts fire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PayoutMode {
/// Real-time / minutes-grain, as soon as funds are eligible.
Instant,
/// Once per business day.
Daily,
/// Once per week on `day_of_week`.
Weekly,
/// Once per month on `day_of_month`.
Monthly,
/// No automatic schedule — sub-merchant pulls funds manually.
OnDemand,
}
/// Payout schedule attached to a connected account.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PayoutSchedule {
/// Cadence.
pub mode: PayoutMode,
/// Required when `mode == Weekly`.
pub day_of_week: Option<Weekday>,
/// Required when `mode == Monthly`. 1..=28 (avoid month-end edge cases).
pub day_of_month: Option<u8>,
/// Settlement delay in days (e.g. T+2 for card payments).
pub delay_days: u8,
/// True if the sub-merchant is approved for instant payouts where eligible.
pub instant_eligible: bool,
/// Fraction of every payout to withhold as a rolling reserve.
/// Range: `0.0..=1.0` (validated at construction).
pub reserve_pct: f32,
}
impl PayoutSchedule {
/// Construct a schedule and validate it.
///
/// # Errors
/// [`Error::InvalidPayoutSchedule`] on any out-of-range field.
pub fn try_new(
mode: PayoutMode,
day_of_week: Option<Weekday>,
day_of_month: Option<u8>,
delay_days: u8,
instant_eligible: bool,
reserve_pct: f32,
) -> Result<Self> {
if !(0.0..=1.0).contains(&reserve_pct) {
return Err(Error::InvalidPayoutSchedule {
reason: format!("reserve_pct {reserve_pct} not in [0.0, 1.0]"),
});
}
if delay_days > 30 {
return Err(Error::InvalidPayoutSchedule {
reason: format!("delay_days {delay_days} > 30 (no real schedule needs this)"),
});
}
if matches!(mode, PayoutMode::Weekly) && day_of_week.is_none() {
return Err(Error::InvalidPayoutSchedule {
reason: "weekly schedule requires day_of_week".into(),
});
}
if matches!(mode, PayoutMode::Monthly) {
match day_of_month {
Some(d) if (1..=28).contains(&d) => {}
Some(d) => {
return Err(Error::InvalidPayoutSchedule {
reason: format!("day_of_month {d} not in 1..=28"),
});
}
None => {
return Err(Error::InvalidPayoutSchedule {
reason: "monthly schedule requires day_of_month".into(),
});
}
}
}
Ok(Self {
mode,
day_of_week,
day_of_month,
delay_days,
instant_eligible,
reserve_pct,
})
}
/// Compute the next payout date given the date funds became
/// available.
///
/// Adds `delay_days` to `available_on`, then rounds up to the next
/// scheduled cadence point.
///
/// `OnDemand` and `Instant` return `available_on + delay_days`.
#[must_use]
pub fn next_payout_date(&self, available_on: NaiveDate) -> NaiveDate {
let base = available_on
.checked_add_days(Days::new(u64::from(self.delay_days)))
.unwrap_or(available_on);
match self.mode {
PayoutMode::Instant | PayoutMode::OnDemand | PayoutMode::Daily => base,
PayoutMode::Weekly => {
let target = self.day_of_week.unwrap_or(Weekday::Mon);
// Walk forward at most 6 days.
let mut d = base;
for _ in 0..=6 {
if d.weekday() == target {
return d;
}
d = d.checked_add_days(Days::new(1)).unwrap_or(d);
}
d
}
PayoutMode::Monthly => {
let dom = u32::from(self.day_of_month.unwrap_or(1));
// Same-month if `base.day() <= dom`, else next month.
if base.day() <= dom {
base.with_day(dom).unwrap_or(base)
} else {
let (year, month) = if base.month() == 12 {
(base.year() + 1, 1)
} else {
(base.year(), base.month() + 1)
};
NaiveDate::from_ymd_opt(year, month, dom).unwrap_or(base)
}
}
}
}
/// Split a gross payout amount into `(payout, reserve)`.
///
/// Reserve is computed via integer rounding on minor units to keep
/// money exact.
///
/// # Errors
/// [`Error::Overflow`] if the reserve computation overflows.
pub fn apply_reserve(&self, gross: Money) -> Result<(Money, Money)> {
let cur = gross.currency;
// reserve_minor = gross.minor * reserve_pct, rounded down.
let reserve_minor = ((gross.minor_units as f64) * f64::from(self.reserve_pct)).floor() as i64;
let reserve = Money::from_minor(reserve_minor, cur);
let payout = gross.checked_sub(reserve).map_err(|_| Error::Overflow)?;
Ok((payout, reserve))
}
}
/// An instruction to the rail driver (`op-batch` / `op-rails-a2a`) to
/// submit a payout.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PayoutInstruction {
/// Sub-merchant receiving the funds.
pub destination: AccountId,
/// Net amount paid out (after reserve withholding).
pub net_amount: Money,
/// Amount withheld to reserve, if any.
pub reserve_amount: Money,
/// Scheduled disbursement date.
pub scheduled_for: NaiveDate,
/// Creation timestamp.
pub created_at: DateTime<Utc>,
}
impl PayoutInstruction {
/// Build a payout instruction from a schedule + gross amount +
/// availability date.
///
/// # Errors
/// Bubbles validation failures from [`PayoutSchedule::apply_reserve`].
pub fn build(
destination: &AccountId,
gross: Money,
available_on: NaiveDate,
schedule: &PayoutSchedule,
) -> Result<Self> {
if gross.currency != Currency::USD
&& gross.currency != Currency::EUR
&& gross.currency != Currency::GBP
&& gross.currency != Currency::INR
&& gross.currency != Currency::BRL
&& gross.currency != Currency::JPY
&& gross.currency != Currency::CNY
{
// Allow but log — exotic currency, operator should know.
tracing::debug!(currency = %gross.currency, "payout in non-mainline currency");
}
let (net, reserve) = schedule.apply_reserve(gross)?;
Ok(Self {
destination: destination.clone(),
net_amount: net,
reserve_amount: reserve,
scheduled_for: schedule.next_payout_date(available_on),
created_at: Utc::now(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_reserve_pct_rejected() {
let err = PayoutSchedule::try_new(PayoutMode::Daily, None, None, 2, false, 1.5)
.expect_err("out of range");
assert!(matches!(err, Error::InvalidPayoutSchedule { .. }));
}
#[test]
fn weekly_without_dow_rejected() {
let err = PayoutSchedule::try_new(PayoutMode::Weekly, None, None, 2, false, 0.0)
.expect_err("weekly needs dow");
assert!(matches!(err, Error::InvalidPayoutSchedule { .. }));
}
#[test]
fn daily_two_day_delay_lands_t_plus_2() {
let s = PayoutSchedule::try_new(PayoutMode::Daily, None, None, 2, false, 0.0)
.expect("ok");
let available = NaiveDate::from_ymd_opt(2026, 1, 10).expect("date");
let next = s.next_payout_date(available);
assert_eq!(next, NaiveDate::from_ymd_opt(2026, 1, 12).expect("date"));
}
#[test]
fn weekly_rolls_to_target_day() {
// Target = Friday. Start = Wednesday + 0-day delay = Wednesday.
// Next payout should be that Friday.
let s = PayoutSchedule::try_new(
PayoutMode::Weekly,
Some(Weekday::Fri),
None,
0,
false,
0.0,
)
.expect("ok");
// 2026-01-14 is a Wednesday.
let wed = NaiveDate::from_ymd_opt(2026, 1, 14).expect("date");
assert_eq!(wed.weekday(), Weekday::Wed);
let next = s.next_payout_date(wed);
// Friday is 2026-01-16.
assert_eq!(next, NaiveDate::from_ymd_opt(2026, 1, 16).expect("date"));
}
#[test]
fn monthly_rolls_to_next_month_after_dom() {
let s = PayoutSchedule::try_new(
PayoutMode::Monthly,
None,
Some(15),
0,
false,
0.0,
)
.expect("ok");
let nov_20 = NaiveDate::from_ymd_opt(2026, 11, 20).expect("date");
let next = s.next_payout_date(nov_20);
assert_eq!(next, NaiveDate::from_ymd_opt(2026, 12, 15).expect("date"));
}
#[test]
fn reserve_split_is_exact_in_minor_units() {
let s = PayoutSchedule::try_new(PayoutMode::Daily, None, None, 0, false, 0.10)
.expect("ok");
let gross = Money::from_minor(10_000, Currency::USD);
let (net, reserve) = s.apply_reserve(gross).expect("ok");
assert_eq!(reserve, Money::from_minor(1_000, Currency::USD));
assert_eq!(net, Money::from_minor(9_000, Currency::USD));
}
}