7869ec48d8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
11 KiB
Rust
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))
|
|
}
|