//! Splits (SPEC §9.1, §14.5.2). use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; use crate::{BP_TOTAL, amount_in_range}; /// How an expense is split between the two household users. /// /// Modes `default` and `ratio` are both [`SplitSpec::Ratio`]; `exact` is [`SplitSpec::Exact`]; `full` is /// [`SplitSpec::Full`]. The serde form is the one of the test vectors (SPEC §14.5.5): /// `{"mode":"ratio","bp":{…}}`, `{"mode":"exact","ore":{…}}`, `{"mode":"full","bearer_user_id":"…"}`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "mode", rename_all = "snake_case")] pub enum SplitSpec { Ratio { bp: BTreeMap }, Exact { ore: BTreeMap }, Full { bearer_user_id: String }, } /// Why a split is invalid. Checked in declaration order; the first failure is returned (SPEC §14.5.2). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SplitError { AmountOutOfRange, UsersInvalid, PayerUnknown, BpOutOfRange, // `rename_all = "snake_case"` alone would give `bp_sum_not10000`; the token of SPEC §14.5.3 is `bp_sum_not_10000`. // The explicit rename is the rule (D-006). #[serde(rename = "bp_sum_not_10000")] BpSumNot10000, ExactNegative, ExactSumMismatch, BearerUnknown, ShareUsersMismatch, } impl SplitError { /// The wire token of SPEC §14.5.3. pub fn token(self) -> &'static str { match self { Self::AmountOutOfRange => "amount_out_of_range", Self::UsersInvalid => "users_invalid", Self::PayerUnknown => "payer_unknown", Self::BpOutOfRange => "bp_out_of_range", Self::BpSumNot10000 => "bp_sum_not_10000", Self::ExactNegative => "exact_negative", Self::ExactSumMismatch => "exact_sum_mismatch", Self::BearerUnknown => "bearer_unknown", Self::ShareUsersMismatch => "share_users_mismatch", } } } impl std::fmt::Display for SplitError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.token()) } } impl std::error::Error for SplitError {} /// Computes each user's share in öre (SPEC §9.1). The result has both user ids. /// /// Errors are checked in [`SplitError`] order: amount, users, payer, then the mode's own checks /// (bp range and sum for `Ratio`, negativity and sum for `Exact`, bearer for `Full`), then the share keys. pub fn compute_split( amount_ore: i64, payer_user_id: &str, user_ids: &[String], spec: &SplitSpec, ) -> Result, SplitError> { if !amount_in_range(amount_ore) { return Err(SplitError::AmountOutOfRange); } let users = two_distinct(user_ids).ok_or(SplitError::UsersInvalid)?; if !users.contains(&payer_user_id) { return Err(SplitError::PayerUnknown); } let user_set: BTreeSet<&str> = users.iter().copied().collect(); match spec { SplitSpec::Ratio { bp } => { if bp.values().any(|&v| v > BP_TOTAL) { return Err(SplitError::BpOutOfRange); } let sum: u64 = bp.values().map(|&v| u64::from(v)).sum(); if sum != u64::from(BP_TOTAL) { return Err(SplitError::BpSumNot10000); } if !same_keys(bp.keys(), &user_set) { return Err(SplitError::ShareUsersMismatch); } Ok(ratio_rule(amount_ore, payer_user_id, users, |u| { bp.get(u).copied().unwrap_or(0) })) } SplitSpec::Exact { ore } => { if ore.values().any(|&v| v < 0) { return Err(SplitError::ExactNegative); } let sum: i128 = ore.values().map(|&v| i128::from(v)).sum(); if sum != i128::from(amount_ore) { return Err(SplitError::ExactSumMismatch); } if !same_keys(ore.keys(), &user_set) { return Err(SplitError::ShareUsersMismatch); } Ok(ore.clone()) } SplitSpec::Full { bearer_user_id } => { if !user_set.contains(bearer_user_id.as_str()) { return Err(SplitError::BearerUnknown); } // `Full` is `Ratio` with the bearer at 10000 bp (SPEC §14.5.2). Ok(ratio_rule(amount_ore, payer_user_id, users, |u| { if u == bearer_user_id { BP_TOTAL } else { 0 } })) } } } /// The two user ids, if `user_ids` has exactly two distinct entries. fn two_distinct(user_ids: &[String]) -> Option<[&str; 2]> { match user_ids { [a, b] if a != b => Some([a.as_str(), b.as_str()]), _ => None, } } fn same_keys<'a>(keys: impl Iterator, users: &BTreeSet<&str>) -> bool { let keys: BTreeSet<&str> = keys.map(String::as_str).collect(); keys == *users } /// The ratio rule of SPEC §9.1: the non-payer's share is rounded down, the payer absorbs the remainder. /// `amount_ore` ≤ 1 000 000 000 and bp ≤ 10000, so the product fits in `i64`. fn ratio_rule( amount_ore: i64, payer: &str, users: [&str; 2], bp_of: impl Fn(&str) -> u32, ) -> BTreeMap { let non_payer = if users[0] == payer { users[1] } else { users[0] }; let share_non_payer = amount_ore * i64::from(bp_of(non_payer)) / i64::from(BP_TOTAL); BTreeMap::from([ (non_payer.to_owned(), share_non_payer), (payer.to_owned(), amount_ore - share_non_payer), ]) } #[cfg(test)] mod tests { use super::*; #[test] fn serde_form_is_the_wire_token() { let all = [ SplitError::AmountOutOfRange, SplitError::UsersInvalid, SplitError::PayerUnknown, SplitError::BpOutOfRange, SplitError::BpSumNot10000, SplitError::ExactNegative, SplitError::ExactSumMismatch, SplitError::BearerUnknown, SplitError::ShareUsersMismatch, ]; for e in all { assert_eq!( serde_json::to_value(e).unwrap(), serde_json::json!(e.token()) ); } } }