M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile
Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned. kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015). kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to the contract schema, error type for all 25 codes, auth extractor and protocol layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
//! 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`.
|
||||
// TODO(spec): §14.5.1 shows only `rename_all`, which does not produce this token.
|
||||
#[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())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user