7869ec48d8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
5.4 KiB
Rust
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>>()?,
|
|
}))
|
|
}
|