//! Settlement allocation proposal and over-settle warnings (SPEC §9.4, §14.5.2). use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::amount_in_range; /// A non-deleted account with the payer's current net in it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AccountPosition { pub account_id: String, /// Fixed RFC 3339 form, so string order is time order. pub created_at: String, pub archived: bool, pub payer_net_ore: i64, } /// Why no proposal can be made. Checked in declaration order. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AllocationError { AmountOutOfRange, NoAccounts, } impl AllocationError { /// The wire token of SPEC §14.5.3. pub fn token(self) -> &'static str { match self { Self::AmountOutOfRange => "amount_out_of_range", Self::NoAccounts => "no_accounts", } } } impl std::fmt::Display for AllocationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.token()) } } impl std::error::Error for AllocationError {} /// One account's part of a settlement. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Allocation { pub account_id: String, pub amount_ore: i64, } /// The output of [`propose_allocation`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AllocationProposal { /// In the order produced by SPEC §9.4. pub allocations: Vec, /// The amount added in steps 3 or 4 of SPEC §9.4; 0 otherwise. pub excess_ore: i64, /// The account that received `excess_ore`; empty when `excess_ore` is 0. pub over_settled_account_ids: Vec, } /// Proposes how a settlement of `amount_ore` paid by the payer is spread over the accounts (SPEC §9.4). /// /// `accounts` are all non-deleted accounts with the payer's net per account. Owing accounts (net < 0) are paid /// oldest first (`created_at`, then `account_id`); any rest goes to the last paid account. With no owing account the /// whole amount goes to the oldest non-archived account, or the oldest account when all are archived. pub fn propose_allocation( amount_ore: i64, accounts: &[AccountPosition], ) -> Result { if !amount_in_range(amount_ore) { return Err(AllocationError::AmountOutOfRange); } if accounts.is_empty() { return Err(AllocationError::NoAccounts); } let mut owing: Vec<&AccountPosition> = accounts.iter().filter(|a| a.payer_net_ore < 0).collect(); owing.sort_by(|a, b| account_order(a, b)); let mut allocations: Vec = Vec::new(); let mut remaining = amount_ore; for account in &owing { if remaining <= 0 { break; } let amount = remaining.min(account.payer_net_ore.saturating_neg()); allocations.push(Allocation { account_id: account.account_id.clone(), amount_ore: amount, }); remaining -= amount; } if let Some(last) = allocations.last_mut() { if remaining > 0 { last.amount_ore += remaining; return Ok(AllocationProposal { over_settled_account_ids: vec![last.account_id.clone()], allocations, excess_ore: remaining, }); } return Ok(AllocationProposal { allocations, excess_ore: 0, over_settled_account_ids: Vec::new(), }); } // No owing account: the oldest non-archived account, or the oldest account if every account is archived. let oldest = |archived_allowed: bool| { accounts .iter() .filter(|a| archived_allowed || !a.archived) .min_by(|a, b| account_order(a, b)) }; let target = oldest(false) .or_else(|| oldest(true)) .ok_or(AllocationError::NoAccounts)?; Ok(AllocationProposal { allocations: vec![Allocation { account_id: target.account_id.clone(), amount_ore, }], excess_ore: amount_ore, over_settled_account_ids: vec![target.account_id.clone()], }) } /// Ids, in allocation order, whose allocation exceeds `max(0, −payer_net_ore)` (SPEC §9.4 over-settle warning). /// An allocation for an account not in `accounts` counts as net 0. pub fn over_settled_accounts( allocations: &[Allocation], accounts: &[AccountPosition], ) -> Vec { let nets: BTreeMap<&str, i64> = accounts .iter() .map(|a| (a.account_id.as_str(), a.payer_net_ore)) .collect(); allocations .iter() .filter(|a| { let net = nets.get(a.account_id.as_str()).copied().unwrap_or(0); a.amount_ore > net.saturating_neg().max(0) }) .map(|a| a.account_id.clone()) .collect() } fn account_order(a: &AccountPosition, b: &AccountPosition) -> std::cmp::Ordering { (a.created_at.as_str(), a.account_id.as_str()) .cmp(&(b.created_at.as_str(), b.account_id.as_str())) } #[cfg(test)] mod tests { use super::*; #[test] fn serde_form_is_the_wire_token() { for e in [ AllocationError::AmountOutOfRange, AllocationError::NoAccounts, ] { assert_eq!( serde_json::to_value(e).unwrap(), serde_json::json!(e.token()) ); } } }