//! 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 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, ErrorCode}; use crate::openapi::responses::{BadRequest, Conflict, Unprocessable}; use crate::wire::{Allocation, ProposalRequest, ProposalResponse}; #[utoipa::path( post, path = "/settlements/proposal", operation_id = "proposeSettlement", summary = "E-33 Server-side allocation proposal", description = "Computed with `kvitt-domain::propose_allocation` (SPEC §9.4) over the server's non-deleted rows.", request_body(content = ProposalRequest, content_type = "application/json; charset=utf-8"), responses( (status = 200, description = "The proposal.", body = ProposalResponse, content_type = "application/json; charset=utf-8"), (status = 400, response = BadRequest), (status = 409, response = Conflict), (status = 422, response = Unprocessable), ) )] pub async fn propose_settlement( _device: AuthDevice, State(state): State, ApiJson(body): ApiJson, ) -> Result, 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)> = 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 = 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 = 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 = accounts.iter().map(|(id, _, _)| id.clone()).collect(); let balances = kvitt_domain::compute_balances( &rows::user_ids(&users), &account_ids, &expenses.into_values().collect::>(), &settlements.into_values().collect::>(), ); let positions: Vec = 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::>()?, excess_ore: proposal.excess_ore, over_settled_account_ids: proposal .over_settled_account_ids .iter() .map(|id| rows::uuid(id)) .collect::>()?, })) }