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:
2026-10-17 21:25:35 +02:00
parent c277ee37c2
commit 06f48ef9e9
71 changed files with 9187 additions and 0 deletions
@@ -0,0 +1,173 @@
//! 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<Allocation>,
/// 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<String>,
}
/// 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<AllocationProposal, AllocationError> {
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<Allocation> = 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<String> {
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())
);
}
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Balances (SPEC §9.3, §14.5.2).
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// A non-deleted expense as far as balances are concerned.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BalanceExpense {
pub account_id: String,
pub amount_ore: i64,
pub payer_user_id: String,
/// user id → share in öre.
pub shares: BTreeMap<String, i64>,
}
/// A non-deleted settlement as far as balances are concerned.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BalanceSettlement {
pub from_user_id: String,
pub to_user_id: String,
/// account id → öre.
pub allocations: BTreeMap<String, i64>,
}
/// Net per account and user, and the global net per user.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Balances {
/// account id → user id → net öre.
pub per_account: BTreeMap<String, BTreeMap<String, i64>>,
/// user id → net öre over every account.
pub global: BTreeMap<String, i64>,
}
/// Computes `net(u)` per account and globally (SPEC §9.3). Inputs hold only non-deleted rows.
///
/// `per_account` has an entry for every id of `account_ids` (zeros included) and for any other account id the rows
/// reference; each inner map has every id of `user_ids`. Arithmetic saturates instead of overflowing; real data is
/// many orders of magnitude below `i64::MAX`.
pub fn compute_balances(
user_ids: &[String],
account_ids: &[String],
expenses: &[BalanceExpense],
settlements: &[BalanceSettlement],
) -> Balances {
let zeros: BTreeMap<String, i64> = user_ids.iter().map(|u| (u.clone(), 0)).collect();
let mut per_account: BTreeMap<String, BTreeMap<String, i64>> = BTreeMap::new();
for account in account_ids {
per_account
.entry(account.clone())
.or_insert_with(|| zeros.clone());
}
for expense in expenses {
let nets = per_account
.entry(expense.account_id.clone())
.or_insert_with(|| zeros.clone());
add(nets, &expense.payer_user_id, expense.amount_ore);
for (user, share) in &expense.shares {
add(nets, user, share.saturating_neg());
}
}
for settlement in settlements {
for (account, amount) in &settlement.allocations {
let nets = per_account
.entry(account.clone())
.or_insert_with(|| zeros.clone());
add(nets, &settlement.from_user_id, *amount);
add(nets, &settlement.to_user_id, amount.saturating_neg());
}
}
let mut global = zeros;
for nets in per_account.values() {
for (user, net) in nets {
add(&mut global, user, *net);
}
}
Balances {
per_account,
global,
}
}
fn add(nets: &mut BTreeMap<String, i64>, user: &str, delta: i64) {
let net = nets.entry(user.to_owned()).or_insert(0);
*net = net.saturating_add(delta);
}
+39
View File
@@ -0,0 +1,39 @@
//! Kvitt domain rules as pure functions (SPEC §9.1, §9.3, §9.4, §9.5 `next_occurrence_date`, §14.5).
//!
//! No I/O, no clock, no randomness, no locale. Ids are plain strings (the test vectors use short ids like `"a"`);
//! money is `i64` öre and basis points are `u32`. The Kotlin `:domain` module implements the same functions and both
//! must pass every vector in `contracts/test-vectors/` (`tests/vectors.rs`).
// No unwrap/expect outside tests (CLAUDE.md server rule); integration tests are separate crates and may use them.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
mod allocation;
mod balance;
mod recurring;
mod settlement;
mod split;
pub use allocation::{
AccountPosition, Allocation, AllocationError, AllocationProposal, over_settled_accounts,
propose_allocation,
};
pub use balance::{BalanceExpense, BalanceSettlement, Balances, compute_balances};
pub use recurring::{
Period, RecurrenceError, RecurrenceSchedule, next_candidate_period, next_occurrence_date,
};
pub use settlement::{SettlementError, validate_settlement};
pub use split::{SplitError, SplitSpec, compute_split};
/// Smallest valid amount of an expense, settlement or template, in öre (SPEC §9.6).
pub const AMOUNT_MIN_ORE: i64 = 1;
/// Largest valid amount of an expense, settlement or template, in öre: 10 000 000,00 kr (SPEC §9.6).
pub const AMOUNT_MAX_ORE: i64 = 1_000_000_000;
/// 100 % in basis points.
pub const BP_TOTAL: u32 = 10_000;
/// Most allocations one settlement may have (SPEC §9.6).
pub const MAX_ALLOCATIONS: usize = 50;
/// Whether `amount_ore` is within SPEC §9.6 (1 to 1 000 000 000).
pub fn amount_in_range(amount_ore: i64) -> bool {
(AMOUNT_MIN_ORE..=AMOUNT_MAX_ORE).contains(&amount_ore)
}
+305
View File
@@ -0,0 +1,305 @@
//! Recurring periods and the `next_occurrence_date` of a template (SPEC §9.5).
//!
//! Pure calendar arithmetic on `YYYY-MM` periods and `YYYY-MM-DD` dates; no clock and no time zone. The occurrence
//! *instant* (the clamped date at 06:00 Europe/Stockholm) is the server's concern; this module only fixes which
//! period and which calendar date.
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
/// A month, `YYYY-MM`. Orders chronologically.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Period {
year: u16,
month: u8,
}
/// Why a recurrence input is malformed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecurrenceError {
/// A period that is not `YYYY-MM` with month 01 to 12 (years 0001 to 9999).
PeriodInvalid,
/// `day_of_month` outside 1 to 31 (SPEC §9.6).
DayOfMonthOutOfRange,
}
impl fmt::Display for RecurrenceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::PeriodInvalid => "period_invalid",
Self::DayOfMonthOutOfRange => "day_of_month_out_of_range",
})
}
}
impl std::error::Error for RecurrenceError {}
impl Period {
/// A period, if `month` is 1 to 12 and `year` 1 to 9999.
pub fn new(year: u16, month: u8) -> Option<Self> {
((1..=9999).contains(&year) && (1..=12).contains(&month)).then_some(Self { year, month })
}
pub fn year(self) -> u16 {
self.year
}
pub fn month(self) -> u8 {
self.month
}
/// The following month, or `None` after 9999-12.
pub fn next(self) -> Option<Self> {
if self.month == 12 {
Self::new(self.year.checked_add(1)?, 1)
} else {
Self::new(self.year, self.month + 1)
}
}
/// Number of days in this month (Gregorian leap years).
pub fn days(self) -> u8 {
match self.month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ if is_leap(self.year) => 29,
_ => 28,
}
}
/// The occurrence date of `day_of_month` in this month, clamped to its last day (`31` in April is `30`), as
/// `YYYY-MM-DD`.
pub fn clamped_date(self, day_of_month: u8) -> Result<String, RecurrenceError> {
if !(1..=31).contains(&day_of_month) {
return Err(RecurrenceError::DayOfMonthOutOfRange);
}
Ok(format!("{self}-{:02}", day_of_month.min(self.days())))
}
}
impl fmt::Display for Period {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:04}-{:02}", self.year, self.month)
}
}
impl FromStr for Period {
type Err = RecurrenceError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bytes = s.as_bytes();
let digits = |r: std::ops::Range<usize>| bytes[r].iter().all(u8::is_ascii_digit);
if bytes.len() != 7 || bytes[4] != b'-' || !digits(0..4) || !digits(5..7) {
return Err(RecurrenceError::PeriodInvalid);
}
let year: u16 = s[0..4]
.parse()
.map_err(|_| RecurrenceError::PeriodInvalid)?;
let month: u8 = s[5..7]
.parse()
.map_err(|_| RecurrenceError::PeriodInvalid)?;
Self::new(year, month).ok_or(RecurrenceError::PeriodInvalid)
}
}
fn is_leap(year: u16) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
/// The template fields `next_occurrence_date` depends on.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecurrenceSchedule {
pub day_of_month: u8,
pub start_period: String,
pub end_period: Option<String>,
pub last_generated_period: Option<String>,
pub paused: bool,
pub deleted: bool,
}
/// The first period that may still be generated: the month after `last_generated_period`, but not before
/// `start_period`; `None` when that is after `end_period` (SPEC §9.5). Paused and deleted are not considered here.
pub fn next_candidate_period(
start_period: &str,
end_period: Option<&str>,
last_generated_period: Option<&str>,
) -> Result<Option<Period>, RecurrenceError> {
let start: Period = start_period.parse()?;
let end: Option<Period> = end_period.map(str::parse).transpose()?;
let after_last: Option<Period> = match last_generated_period {
Some(last) => match last.parse::<Period>()?.next() {
Some(p) => Some(p),
None => return Ok(None),
},
None => None,
};
let candidate = after_last.map_or(start, |p| p.max(start));
Ok(match end {
Some(end) if candidate > end => None,
_ => Some(candidate),
})
}
/// `next_occurrence_date` of the RecurringTemplate wire form (SPEC §9.5): the clamped date of the first candidate
/// period after `last_generated_period` that is ≥ `start_period` and ≤ `end_period`; `None` when paused, deleted or
/// past `end_period`.
pub fn next_occurrence_date(
schedule: &RecurrenceSchedule,
) -> Result<Option<String>, RecurrenceError> {
if !(1..=31).contains(&schedule.day_of_month) {
return Err(RecurrenceError::DayOfMonthOutOfRange);
}
let candidate = next_candidate_period(
&schedule.start_period,
schedule.end_period.as_deref(),
schedule.last_generated_period.as_deref(),
)?;
if schedule.paused || schedule.deleted {
return Ok(None);
}
candidate
.map(|p| p.clamped_date(schedule.day_of_month))
.transpose()
}
#[cfg(test)]
mod tests {
use super::*;
fn schedule(day: u8, start: &str, end: Option<&str>, last: Option<&str>) -> RecurrenceSchedule {
RecurrenceSchedule {
day_of_month: day,
start_period: start.to_owned(),
end_period: end.map(str::to_owned),
last_generated_period: last.map(str::to_owned),
paused: false,
deleted: false,
}
}
fn next(s: &RecurrenceSchedule) -> Option<String> {
next_occurrence_date(s).unwrap()
}
#[test]
fn never_generated_starts_at_start_period() {
assert_eq!(
next(&schedule(25, "2026-11", None, None)).as_deref(),
Some("2026-11-25")
);
}
#[test]
fn after_last_generated_period() {
assert_eq!(
next(&schedule(1, "2026-01", None, Some("2026-10"))).as_deref(),
Some("2026-11-01")
);
}
#[test]
fn year_boundary() {
assert_eq!(
next(&schedule(15, "2026-01", None, Some("2026-12"))).as_deref(),
Some("2027-01-15")
);
}
#[test]
fn start_period_moved_past_last_generated() {
// Changing start_period never regenerates past periods; a later start wins over last + 1.
assert_eq!(
next(&schedule(10, "2027-03", None, Some("2026-10"))).as_deref(),
Some("2027-03-10")
);
}
#[test]
fn clamped_to_month_end() {
assert_eq!(
next(&schedule(31, "2026-04", None, None)).as_deref(),
Some("2026-04-30")
);
assert_eq!(
next(&schedule(31, "2027-02", None, None)).as_deref(),
Some("2027-02-28")
);
assert_eq!(
next(&schedule(30, "2028-02", None, None)).as_deref(),
Some("2028-02-29")
);
assert_eq!(
next(&schedule(29, "2100-02", None, None)).as_deref(),
Some("2100-02-28")
);
assert_eq!(
next(&schedule(29, "2000-02", None, None)).as_deref(),
Some("2000-02-29")
);
}
#[test]
fn end_period_inclusive_and_past() {
assert_eq!(
next(&schedule(5, "2026-01", Some("2026-11"), Some("2026-10"))).as_deref(),
Some("2026-11-05")
);
assert_eq!(
next(&schedule(5, "2026-01", Some("2026-11"), Some("2026-11"))),
None
);
assert_eq!(next(&schedule(5, "2026-12", Some("2026-11"), None)), None);
}
#[test]
fn paused_or_deleted_is_none() {
let mut s = schedule(5, "2026-01", None, None);
s.paused = true;
assert_eq!(next(&s), None);
s.paused = false;
s.deleted = true;
assert_eq!(next(&s), None);
}
#[test]
fn malformed_input() {
let bad = |s: RecurrenceSchedule, e| assert_eq!(next_occurrence_date(&s), Err(e));
bad(
schedule(0, "2026-01", None, None),
RecurrenceError::DayOfMonthOutOfRange,
);
bad(
schedule(32, "2026-01", None, None),
RecurrenceError::DayOfMonthOutOfRange,
);
bad(
schedule(1, "2026-13", None, None),
RecurrenceError::PeriodInvalid,
);
bad(
schedule(1, "2026-1", None, None),
RecurrenceError::PeriodInvalid,
);
bad(
schedule(1, "2026-01", Some("x"), None),
RecurrenceError::PeriodInvalid,
);
bad(
schedule(1, "2026-01", None, Some("2026-00")),
RecurrenceError::PeriodInvalid,
);
}
#[test]
fn period_round_trip_and_order() {
let p: Period = "2026-10".parse().unwrap();
assert_eq!(p.to_string(), "2026-10");
assert!(p < "2026-11".parse().unwrap());
assert!(p > "2025-12".parse().unwrap());
assert_eq!(p.next().map(|n| n.to_string()).as_deref(), Some("2026-11"));
assert_eq!("9999-12".parse::<Period>().unwrap().next(), None);
}
}
@@ -0,0 +1,108 @@
//! Settlement validity (SPEC §9.4, §14.5.2).
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::{Allocation, MAX_ALLOCATIONS, amount_in_range};
/// Why a settlement is invalid. Checked in declaration order; the first failure is returned.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SettlementError {
AmountOutOfRange,
SameUser,
UserUnknown,
NoAllocations,
TooManyAllocations,
AllocationNotPositive,
DuplicateAccount,
AllocationSumMismatch,
}
impl SettlementError {
/// The wire token of SPEC §14.5.3.
pub fn token(self) -> &'static str {
match self {
Self::AmountOutOfRange => "amount_out_of_range",
Self::SameUser => "same_user",
Self::UserUnknown => "user_unknown",
Self::NoAllocations => "no_allocations",
Self::TooManyAllocations => "too_many_allocations",
Self::AllocationNotPositive => "allocation_not_positive",
Self::DuplicateAccount => "duplicate_account",
Self::AllocationSumMismatch => "allocation_sum_mismatch",
}
}
}
impl std::fmt::Display for SettlementError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.token())
}
}
impl std::error::Error for SettlementError {}
/// Checks a settlement against SPEC §9.4. Reference existence of the accounts is not checked here.
pub fn validate_settlement(
amount_ore: i64,
from_user_id: &str,
to_user_id: &str,
user_ids: &[String],
allocations: &[Allocation],
) -> Result<(), SettlementError> {
if !amount_in_range(amount_ore) {
return Err(SettlementError::AmountOutOfRange);
}
if from_user_id == to_user_id {
return Err(SettlementError::SameUser);
}
let known = |u: &str| user_ids.iter().any(|id| id == u);
if !known(from_user_id) || !known(to_user_id) {
return Err(SettlementError::UserUnknown);
}
if allocations.is_empty() {
return Err(SettlementError::NoAllocations);
}
if allocations.len() > MAX_ALLOCATIONS {
return Err(SettlementError::TooManyAllocations);
}
if allocations.iter().any(|a| a.amount_ore < 1) {
return Err(SettlementError::AllocationNotPositive);
}
let distinct: BTreeSet<&str> = allocations.iter().map(|a| a.account_id.as_str()).collect();
if distinct.len() != allocations.len() {
return Err(SettlementError::DuplicateAccount);
}
let sum: i128 = allocations.iter().map(|a| i128::from(a.amount_ore)).sum();
if sum != i128::from(amount_ore) {
return Err(SettlementError::AllocationSumMismatch);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serde_form_is_the_wire_token() {
let all = [
SettlementError::AmountOutOfRange,
SettlementError::SameUser,
SettlementError::UserUnknown,
SettlementError::NoAllocations,
SettlementError::TooManyAllocations,
SettlementError::AllocationNotPositive,
SettlementError::DuplicateAccount,
SettlementError::AllocationSumMismatch,
];
for e in all {
assert_eq!(
serde_json::to_value(e).unwrap(),
serde_json::json!(e.token())
);
}
}
}
+181
View File
@@ -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())
);
}
}
}