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