Server overhaul: one place for stored-row reads and the shared inserts

api::rows now also loads settlements, audit entries and the epoch (sync::rows is gone; audit enums parse through
rows::token) and owns the expense, split, settlement and allocation inserts that sync push, recurring generation and
the importer each had their own copy of (rows::Origin carries the server-owned columns). accounts::insert_account and
replace_shares are shared with the importer. Recurring generation moves the per-period expense into
create_period_expense. No SQL, wire or log change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 22:28:51 +02:00
parent a0259ab68d
commit 8103dae2be
8 changed files with 426 additions and 453 deletions
+28 -113
View File
@@ -7,12 +7,12 @@ use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use kvitt_domain::{BalanceExpense, BalanceSettlement, compute_balances};
use kvitt_server::api::{accounts::name_key, rows};
use kvitt_server::api::accounts::{self, name_key};
use kvitt_server::api::rows::{self, Origin};
use kvitt_server::audit::{AuditEvent, AuditWriter};
use kvitt_server::db as server_db;
use kvitt_server::error::AppError;
use kvitt_server::sync::check::{self, Fail};
use kvitt_server::sync::rows as sync_rows;
use kvitt_server::time::format_ts;
use kvitt_server::wire::{
Allocation, AuditAction, AuditSource, EntityType, PushedExpense, PushedSettlement, Split,
@@ -66,32 +66,16 @@ async fn find_or_create_account(
if let Some(id) = existing {
return Ok((id, false));
}
let id = Uuid::now_v7().to_string();
let id = Uuid::now_v7();
let version = server_db::next_server_version(conn).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)
.bind(name)
.bind(&key)
.bind(household.owner.to_string())
.bind(now_ts)
.bind(now_ts)
.bind(version)
.execute(&mut *conn)
.await?;
for user in household.users {
sqlx::query(
"INSERT INTO account_default_shares (account_id, user_id, share_bp) VALUES (?, ?, ?)",
)
.bind(&id)
.bind(user.id.to_string())
.bind(DEFAULT_SHARE_BP)
.execute(&mut *conn)
.await?;
}
Ok((id, true))
accounts::insert_account(conn, id, name, household.owner, now_ts, version).await?;
let shares: Vec<(Uuid, i64)> = household
.users
.iter()
.map(|user| (user.id, DEFAULT_SHARE_BP))
.collect();
accounts::replace_shares(conn, id, &shares).await?;
Ok((id.to_string(), true))
}
/// The id of each non-deleted default category, by key.
@@ -194,91 +178,20 @@ fn pushed_settlement(
})
}
async fn insert_expense(
/// The server-owned columns of an imported row: created by the owner, no device, a new `server_version`.
async fn origin<'a>(
conn: &mut SqliteConnection,
p: &PushedExpense,
import_key: &str,
import_key: &'a str,
owner: Uuid,
now_ts: &str,
) -> Result<(), AppError> {
let version = server_db::next_server_version(conn).await?;
sqlx::query(
"INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, \
payer_user_id, split_mode, recurring_template_id, recurring_period, import_key, created_by_user_id, \
updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, server_received_at, \
server_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL, ?, ?, NULL, ?, ?)",
)
.bind(p.id.to_string())
.bind(p.account_id.to_string())
.bind(p.category_id.to_string())
.bind(p.amount_ore)
.bind(&p.description)
.bind(&p.notes)
.bind(&p.expense_date)
.bind(p.payer_user_id.to_string())
.bind(rows::token_of(&p.split_mode)?)
.bind(import_key)
.bind(owner.to_string())
.bind(owner.to_string())
.bind(now_ts)
.bind(now_ts)
.bind(now_ts)
.bind(version)
.execute(&mut *conn)
.await?;
for split in &p.splits {
sqlx::query(
"INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES (?, ?, ?, ?)",
)
.bind(p.id.to_string())
.bind(split.user_id.to_string())
.bind(split.share_ore)
.bind(split.share_bp)
.execute(&mut *conn)
.await?;
}
Ok(())
}
async fn insert_settlement(
conn: &mut SqliteConnection,
p: &PushedSettlement,
import_key: &str,
owner: Uuid,
now_ts: &str,
) -> Result<(), AppError> {
let version = server_db::next_server_version(conn).await?;
sqlx::query(
"INSERT INTO settlements (id, from_user_id, to_user_id, amount_ore, settlement_date, note, import_key, \
created_by_user_id, updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, \
server_received_at, server_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, ?, ?)",
)
.bind(p.id.to_string())
.bind(p.from_user_id.to_string())
.bind(p.to_user_id.to_string())
.bind(p.amount_ore)
.bind(&p.settlement_date)
.bind(&p.note)
.bind(import_key)
.bind(owner.to_string())
.bind(owner.to_string())
.bind(now_ts)
.bind(now_ts)
.bind(now_ts)
.bind(version)
.execute(&mut *conn)
.await?;
for allocation in &p.allocations {
sqlx::query(
"INSERT INTO settlement_allocations (settlement_id, account_id, amount_ore) VALUES (?, ?, ?)",
)
.bind(p.id.to_string())
.bind(allocation.account_id.to_string())
.bind(allocation.amount_ore)
.execute(&mut *conn)
.await?;
}
Ok(())
now_ts: &'a str,
) -> Result<Origin<'a>, AppError> {
Ok(Origin {
user_id: owner,
device_id: None,
import_key: Some(import_key),
received_at: now_ts,
server_version: server_db::next_server_version(conn).await?,
})
}
async fn audit_create(
@@ -365,7 +278,8 @@ pub async fn write(
continue;
}
};
insert_expense(conn, &checked, &row.import_key, owner, &now_ts).await?;
let origin = origin(conn, &row.import_key, owner, &now_ts).await?;
rows::insert_expense(conn, &checked, None, origin).await?;
let stored = rows::expense(conn, checked.id)
.await?
.ok_or_else(|| AppError::internal("imported expense vanished"))?;
@@ -391,8 +305,9 @@ pub async fn write(
continue;
}
};
insert_settlement(conn, &checked, &row.import_key, owner, &now_ts).await?;
let stored = sync_rows::settlement(conn, checked.id)
let origin = origin(conn, &row.import_key, owner, &now_ts).await?;
rows::insert_settlement(conn, &checked, origin).await?;
let stored = rows::settlement(conn, checked.id)
.await?
.ok_or_else(|| AppError::internal("imported settlement vanished"))?;
let after = serde_json::to_value(&stored).map_err(AppError::internal)?;