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

321 lines
11 KiB
Rust

//! Accounts: E-18 to E-22 (FR-6).
//!
//! Names are unique among non-deleted accounts, compared case-insensitively after trimming through `name_key`
//! (Unicode lowercase, AC-6.2). A ratio change affects only expenses created afterwards (AC-6.3): expenses store
//! their own shares. Delete is a soft delete, refused while anything references the account (AC-6.5).
use axum::extract::State;
use axum::http::StatusCode;
use sqlx::SqliteConnection;
use uuid::Uuid;
use crate::AppState;
use crate::api::{ApiJson, IdPath, JsonBody, rows, validate};
use crate::auth::AuthDevice;
use crate::db;
use crate::error::{AppError, ErrorCode};
use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable};
use crate::wire::{Account, CreateAccountRequest, PatchAccountRequest, Share, User, UuidSchema};
/// `name_key`: the trimmed name, Unicode-lowercased (SPEC §14.1).
pub fn name_key(trimmed_name: &str) -> String {
trimmed_name.to_lowercase()
}
fn validate_shares(shares: &[Share], users: &[User]) -> Result<Vec<(Uuid, i64)>, AppError> {
let entries: Vec<(Uuid, i64)> = shares.iter().map(|s| (s.user_id, s.share_bp)).collect();
rows::bp_shares("default_shares", &entries, users)?;
Ok(entries)
}
/// `name_taken` when another non-deleted account has the same `name_key`.
async fn check_name_free(
conn: &mut SqliteConnection,
key: &str,
except: Option<Uuid>,
) -> Result<(), AppError> {
let taken: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM accounts WHERE name_key = ? AND deleted_at IS NULL AND id IS NOT ?)",
)
.bind(key)
.bind(except.map(|id| id.to_string()))
.fetch_one(&mut *conn)
.await?;
if taken {
Err(
AppError::new(ErrorCode::NameTaken, "an account with this name exists")
.with_field("name"),
)
} else {
Ok(())
}
}
async fn replace_shares(
conn: &mut SqliteConnection,
account_id: Uuid,
shares: &[(Uuid, i64)],
) -> Result<(), AppError> {
sqlx::query("DELETE FROM account_default_shares WHERE account_id = ?")
.bind(account_id.to_string())
.execute(&mut *conn)
.await?;
for (user_id, share_bp) in shares {
sqlx::query(
"INSERT INTO account_default_shares (account_id, user_id, share_bp) VALUES (?, ?, ?)",
)
.bind(account_id.to_string())
.bind(user_id.to_string())
.bind(share_bp)
.execute(&mut *conn)
.await?;
}
Ok(())
}
async fn reload(conn: &mut SqliteConnection, id: Uuid) -> Result<Account, AppError> {
rows::account(conn, id)
.await?
.ok_or_else(|| AppError::internal("account vanished inside its transaction"))
}
#[utoipa::path(
post,
path = "/accounts",
operation_id = "createAccount",
summary = "E-18 Create account",
request_body(content = CreateAccountRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 201, description = "The new account.", body = Account, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 409, response = Conflict),
(status = 422, response = Unprocessable),
)
)]
pub async fn create_account(
device: AuthDevice,
State(state): State<AppState>,
ApiJson(body): ApiJson<CreateAccountRequest>,
) -> Result<(StatusCode, JsonBody<Account>), AppError> {
let mut tx = db::begin_immediate(&state.db).await?;
let users = rows::complete_household(&mut tx).await?;
let name = validate::text("name", &body.name, 1, validate::ACCOUNT_NAME_MAX)?;
let shares = validate_shares(&body.default_shares, &users)?;
let key = name_key(&name);
check_name_free(&mut tx, &key, None).await?;
let id = Uuid::now_v7();
let now = state.clock.now_ts();
let version = db::next_server_version(&mut tx).await?;
sqlx::query(
"INSERT INTO accounts (id, name, name_key, created_by_user_id, created_at, updated_at, archived_at, \
deleted_at, server_version) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?)",
)
.bind(id.to_string())
.bind(&name)
.bind(&key)
.bind(device.user_id.to_string())
.bind(&now)
.bind(&now)
.bind(version)
.execute(&mut *tx)
.await?;
replace_shares(&mut tx, id, &shares).await?;
let account = reload(&mut tx, id).await?;
tx.commit().await?;
Ok((StatusCode::CREATED, JsonBody(account)))
}
#[utoipa::path(
patch,
path = "/accounts/{account_id}",
operation_id = "patchAccount",
summary = "E-19 Rename, change default ratio",
params(
("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
request_body(content = PatchAccountRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 200, description = "The updated account.", body = Account, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 404, response = NotFound),
(status = 409, response = Conflict),
(status = 422, response = Unprocessable),
)
)]
pub async fn patch_account(
_device: AuthDevice,
State(state): State<AppState>,
IdPath(account_id): IdPath,
ApiJson(body): ApiJson<PatchAccountRequest>,
) -> Result<JsonBody<Account>, AppError> {
if body.name.is_none() && body.default_shares.is_none() {
return Err(AppError::invalid_request("PATCH needs at least one field"));
}
let mut tx = db::begin_immediate(&state.db).await?;
rows::live_account(&mut tx, account_id).await?;
let name = body
.name
.as_deref()
.map(|n| validate::text("name", n, 1, validate::ACCOUNT_NAME_MAX))
.transpose()?;
let shares = match &body.default_shares {
Some(shares) => {
let users = rows::household(&mut tx).await?;
Some(validate_shares(shares, &users)?)
}
None => None,
};
if let Some(name) = &name {
check_name_free(&mut tx, &name_key(name), Some(account_id)).await?;
}
let now = state.clock.now_ts();
let version = db::next_server_version(&mut tx).await?;
if let Some(name) = &name {
sqlx::query("UPDATE accounts SET name = ?, name_key = ? WHERE id = ?")
.bind(name)
.bind(name_key(name))
.bind(account_id.to_string())
.execute(&mut *tx)
.await?;
}
if let Some(shares) = &shares {
replace_shares(&mut tx, account_id, shares).await?;
}
sqlx::query("UPDATE accounts SET updated_at = ?, server_version = ? WHERE id = ?")
.bind(&now)
.bind(version)
.bind(account_id.to_string())
.execute(&mut *tx)
.await?;
let account = reload(&mut tx, account_id).await?;
tx.commit().await?;
Ok(JsonBody(account))
}
/// E-20 / E-21: sets or clears `archived_at`; a no-op (same row, same `server_version`) when already in that state.
async fn set_archived(
state: &AppState,
account_id: Uuid,
archived: bool,
) -> Result<JsonBody<Account>, AppError> {
let mut tx = db::begin_immediate(&state.db).await?;
let account = rows::live_account(&mut tx, account_id).await?;
if account.archived_at.is_some() == archived {
tx.commit().await?;
return Ok(JsonBody(account));
}
let now = state.clock.now_ts();
let version = db::next_server_version(&mut tx).await?;
sqlx::query(
"UPDATE accounts SET archived_at = ?, updated_at = ?, server_version = ? WHERE id = ?",
)
.bind(archived.then_some(&now))
.bind(&now)
.bind(version)
.bind(account_id.to_string())
.execute(&mut *tx)
.await?;
let account = reload(&mut tx, account_id).await?;
tx.commit().await?;
Ok(JsonBody(account))
}
#[utoipa::path(
post,
path = "/accounts/{account_id}/archive",
operation_id = "archiveAccount",
summary = "E-20 Archive account",
description = "No body. Idempotent.",
params(
("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 200, description = "The account.", body = Account, content_type = "application/json; charset=utf-8"),
(status = 404, response = NotFound),
)
)]
pub async fn archive_account(
_device: AuthDevice,
State(state): State<AppState>,
IdPath(account_id): IdPath,
) -> Result<JsonBody<Account>, AppError> {
set_archived(&state, account_id, true).await
}
#[utoipa::path(
post,
path = "/accounts/{account_id}/unarchive",
operation_id = "unarchiveAccount",
summary = "E-21 Unarchive account",
description = "No body. Idempotent.",
params(
("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 200, description = "The account.", body = Account, content_type = "application/json; charset=utf-8"),
(status = 404, response = NotFound),
)
)]
pub async fn unarchive_account(
_device: AuthDevice,
State(state): State<AppState>,
IdPath(account_id): IdPath,
) -> Result<JsonBody<Account>, AppError> {
set_archived(&state, account_id, false).await
}
#[utoipa::path(
delete,
path = "/accounts/{account_id}",
operation_id = "deleteAccount",
summary = "E-22 Soft delete when empty",
description = "Refused while a non-deleted expense, an allocation of a non-deleted settlement or a non-deleted recurring template references the account (AC-6.5).",
params(
("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 200, description = "The tombstone.", body = Account, content_type = "application/json; charset=utf-8"),
(status = 404, response = NotFound),
(status = 409, response = Conflict),
)
)]
pub async fn delete_account(
_device: AuthDevice,
State(state): State<AppState>,
IdPath(account_id): IdPath,
) -> Result<JsonBody<Account>, AppError> {
let mut tx = db::begin_immediate(&state.db).await?;
rows::live_account(&mut tx, account_id).await?;
let referenced: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM expenses WHERE account_id = ?1 AND deleted_at IS NULL) \
OR EXISTS (SELECT 1 FROM settlement_allocations a JOIN settlements s ON s.id = a.settlement_id \
WHERE a.account_id = ?1 AND s.deleted_at IS NULL) \
OR EXISTS (SELECT 1 FROM recurring_templates WHERE account_id = ?1 AND deleted_at IS NULL)",
)
.bind(account_id.to_string())
.fetch_one(&mut *tx)
.await?;
if referenced {
return Err(AppError::new(
ErrorCode::AccountNotEmpty,
"the account is referenced by an expense, a settlement or a recurring template",
));
}
let now = state.clock.now_ts();
let version = db::next_server_version(&mut tx).await?;
sqlx::query(
"UPDATE accounts SET deleted_at = ?, updated_at = ?, server_version = ? WHERE id = ?",
)
.bind(&now)
.bind(&now)
.bind(version)
.bind(account_id.to_string())
.execute(&mut *tx)
.await?;
let account = reload(&mut tx, account_id).await?;
tx.commit().await?;
Ok(JsonBody(account))
}