Files
kvitt/server/crates/kvitt-domain/src/allocation.rs
T
jacobh2 06f48ef9e9 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>
2026-10-17 21:30:20 +02:00

174 lines
5.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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())
);
}
}
}