M3: server core API (E-3 to E-30, E-33), landing page, pictures, rate limiting, recurring generation, bootstrap and pair-device
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
//! E-33 settlement allocation proposal (SPEC §9.4).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
//! E-33 settlement allocation proposal (SPEC §9.4, AC-11.8): `kvitt_domain::propose_allocation` over the server's
|
||||
//! non-deleted accounts, expenses and settlements, the payer's nets computed with `kvitt_domain::compute_balances`
|
||||
//! and the settlement `exclude_settlement_id` left out.
|
||||
|
||||
use crate::api::not_implemented;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::extract::State;
|
||||
use kvitt_domain::{AccountPosition, AllocationError, BalanceExpense, BalanceSettlement};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::api::{ApiJson, JsonBody, rows, validate};
|
||||
use crate::auth::AuthDevice;
|
||||
use crate::error::AppError;
|
||||
use crate::error::{AppError, ErrorCode};
|
||||
use crate::openapi::responses::{BadRequest, Conflict, Unprocessable};
|
||||
use crate::wire::{ProposalRequest, ProposalResponse};
|
||||
use crate::wire::{Allocation, ProposalRequest, ProposalResponse};
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
@@ -23,6 +28,108 @@ use crate::wire::{ProposalRequest, ProposalResponse};
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn propose_settlement(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
pub async fn propose_settlement(
|
||||
_device: AuthDevice,
|
||||
State(state): State<AppState>,
|
||||
ApiJson(body): ApiJson<ProposalRequest>,
|
||||
) -> Result<JsonBody<ProposalResponse>, AppError> {
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let users = rows::household(&mut conn).await?;
|
||||
if !users.iter().any(|u| u.id == body.payer_user_id) {
|
||||
return Err(AppError::validation("payer_user_id", "user_unknown"));
|
||||
}
|
||||
validate::amount("amount_ore", body.amount_ore)?;
|
||||
let payer = body.payer_user_id.to_string();
|
||||
|
||||
let accounts: Vec<(String, String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, created_at, archived_at FROM accounts WHERE deleted_at IS NULL ORDER BY created_at, id",
|
||||
)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
let expense_rows: Vec<(String, String, i64, String, String, i64)> = sqlx::query_as(
|
||||
"SELECT e.id, e.account_id, e.amount_ore, e.payer_user_id, s.user_id, s.share_ore \
|
||||
FROM expenses e JOIN expense_splits s ON s.expense_id = e.id WHERE e.deleted_at IS NULL ORDER BY e.id",
|
||||
)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
let mut expenses: BTreeMap<String, BalanceExpense> = BTreeMap::new();
|
||||
for (id, account_id, amount_ore, payer_user_id, user_id, share_ore) in expense_rows {
|
||||
expenses
|
||||
.entry(id)
|
||||
.or_insert_with(|| BalanceExpense {
|
||||
account_id,
|
||||
amount_ore,
|
||||
payer_user_id,
|
||||
shares: BTreeMap::new(),
|
||||
})
|
||||
.shares
|
||||
.insert(user_id, share_ore);
|
||||
}
|
||||
let settlement_rows: Vec<(String, String, String, String, i64)> = sqlx::query_as(
|
||||
"SELECT s.id, s.from_user_id, s.to_user_id, a.account_id, a.amount_ore \
|
||||
FROM settlements s JOIN settlement_allocations a ON a.settlement_id = s.id \
|
||||
WHERE s.deleted_at IS NULL AND s.id IS NOT ? ORDER BY s.id",
|
||||
)
|
||||
.bind(body.exclude_settlement_id.map(|id| id.to_string()))
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
let mut settlements: BTreeMap<String, BalanceSettlement> = BTreeMap::new();
|
||||
for (id, from_user_id, to_user_id, account_id, amount_ore) in settlement_rows {
|
||||
settlements
|
||||
.entry(id)
|
||||
.or_insert_with(|| BalanceSettlement {
|
||||
from_user_id,
|
||||
to_user_id,
|
||||
allocations: BTreeMap::new(),
|
||||
})
|
||||
.allocations
|
||||
.insert(account_id, amount_ore);
|
||||
}
|
||||
|
||||
let account_ids: Vec<String> = accounts.iter().map(|(id, _, _)| id.clone()).collect();
|
||||
let balances = kvitt_domain::compute_balances(
|
||||
&rows::user_ids(&users),
|
||||
&account_ids,
|
||||
&expenses.into_values().collect::<Vec<_>>(),
|
||||
&settlements.into_values().collect::<Vec<_>>(),
|
||||
);
|
||||
let positions: Vec<AccountPosition> = accounts
|
||||
.into_iter()
|
||||
.map(|(id, created_at, archived_at)| AccountPosition {
|
||||
payer_net_ore: balances
|
||||
.per_account
|
||||
.get(&id)
|
||||
.and_then(|nets| nets.get(&payer))
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
account_id: id,
|
||||
created_at,
|
||||
archived: archived_at.is_some(),
|
||||
})
|
||||
.collect();
|
||||
let proposal =
|
||||
kvitt_domain::propose_allocation(body.amount_ore, &positions).map_err(|e| match e {
|
||||
AllocationError::NoAccounts => {
|
||||
AppError::new(ErrorCode::NoAccounts, "there are no accounts")
|
||||
}
|
||||
AllocationError::AmountOutOfRange => AppError::validation("amount_ore", e.token()),
|
||||
})?;
|
||||
Ok(JsonBody(ProposalResponse {
|
||||
allocations: proposal
|
||||
.allocations
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
Ok(Allocation {
|
||||
account_id: rows::uuid(&a.account_id)?,
|
||||
amount_ore: a.amount_ore,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, AppError>>()?,
|
||||
excess_ore: proposal.excess_ore,
|
||||
over_settled_account_ids: proposal
|
||||
.over_settled_account_ids
|
||||
.iter()
|
||||
.map(|id| rows::uuid(id))
|
||||
.collect::<Result<_, AppError>>()?,
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user