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())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user