//! 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, 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, ) -> 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 { 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, ApiJson(body): ApiJson, ) -> Result<(StatusCode, JsonBody), 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, IdPath(account_id): IdPath, ApiJson(body): ApiJson, ) -> Result, 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, 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, IdPath(account_id): IdPath, ) -> Result, 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, IdPath(account_id): IdPath, ) -> Result, 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, IdPath(account_id): IdPath, ) -> Result, 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)) }