06f48ef9e9
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>
174 lines
5.5 KiB
Rust
174 lines
5.5 KiB
Rust
//! 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())
|
||
);
|
||
}
|
||
}
|
||
}
|