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