7869ec48d8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
182 lines
6.2 KiB
Rust
182 lines
6.2 KiB
Rust
//! 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<String, u32> },
|
|
Exact { ore: BTreeMap<String, i64> },
|
|
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<BTreeMap<String, i64>, 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<Item = &'a String>, 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<String, i64> {
|
|
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())
|
|
);
|
|
}
|
|
}
|
|
}
|