Files
kvitt/server/crates/kvitt-domain/src/split.rs
T

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())
);
}
}
}