Files
kvitt/server/crates/kvitt-server/src/api/proposal.rs
T

136 lines
5.4 KiB
Rust

//! 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<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>>()?,
}))
}