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
+92 -74
View File
@@ -1,7 +1,7 @@
//! Recurring templates: E-28 to E-30 (FR-9) and the generation of due periods (SPEC §9.5, D-015).
//!
//! [`generate_due_periods`] is the one function that creates the expenses of a template's due periods. E-28 and E-29
//! call it before responding; M4's scheduler calls it for every template (or [`generate_all_due`]).
//! call it before responding; the daily scheduler (`tasks::scheduler`) calls [`generate_all_due`].
use std::collections::BTreeMap;
@@ -13,7 +13,8 @@ use sqlx::SqliteConnection;
use uuid::Uuid;
use crate::AppState;
use crate::api::{ApiJson, IdPath, JsonBody, rows, validate};
use crate::api::rows::{self, Origin};
use crate::api::{ApiJson, IdPath, JsonBody, validate};
use crate::audit::AuditEvent;
use crate::auth::AuthDevice;
use crate::db;
@@ -23,7 +24,8 @@ use crate::push::{PushMessage, PushType};
use crate::time::{STOCKHOLM, format_ts};
use crate::wire::{
AuditAction, AuditSource, CreateRecurringTemplateRequest, EntityType,
PatchRecurringTemplateRequest, RecurringTemplate, SplitMode, TemplateSplit, User, UuidSchema,
PatchRecurringTemplateRequest, PushedExpense, RecurringTemplate, Split, SplitMode,
TemplateSplit, User, UuidSchema,
};
/// Local time of day at which a period's expense is created (SPEC §9.5, AC-9.2).
@@ -56,12 +58,12 @@ pub struct GeneratedExpense {
pub period: String,
}
/// The expense splits of a generated expense: `(user_id, share_ore, share_bp)`.
/// The expense splits of a generated expense.
async fn resolve_splits(
conn: &mut SqliteConnection,
template: &RecurringTemplate,
users: &[User],
) -> Result<Vec<(Uuid, i64, Option<i64>)>, AppError> {
) -> Result<Vec<Split>, AppError> {
let ids = rows::user_ids(users);
let payer = template.payer_user_id.to_string();
let bp: BTreeMap<String, u32> = match template.split_mode {
@@ -116,7 +118,13 @@ async fn resolve_splits(
})?;
return shares
.into_iter()
.map(|(user, ore)| Ok((rows::uuid(&user)?, ore, None)))
.map(|(user, share_ore)| {
Ok(Split {
user_id: rows::uuid(&user)?,
share_ore,
share_bp: None,
})
})
.collect();
}
};
@@ -130,13 +138,84 @@ async fn resolve_splits(
.map_err(|e| AppError::internal(format!("stored template split is invalid: {e}")))?;
shares
.into_iter()
.map(|(user, ore)| {
.map(|(user, share_ore)| {
let share_bp = bp.get(&user).map(|v| i64::from(*v));
Ok((rows::uuid(&user)?, ore, share_bp))
Ok(Split {
user_id: rows::uuid(&user)?,
share_ore,
share_bp,
})
})
.collect()
}
/// Creates the expense of one due `period` of `template` inside the caller's transaction, with its audit entry
/// (see [`generate_due_periods`]). Returns the new expense id.
async fn create_period_expense(
state: &AppState,
conn: &mut SqliteConnection,
template: &RecurringTemplate,
period: Period,
day: u8,
now_ts: &str,
) -> Result<Uuid, AppError> {
let users = rows::complete_household(conn).await?;
let splits = resolve_splits(conn, template, &users).await?;
let period_text = period.to_string();
let expense = PushedExpense {
id: Uuid::now_v7(),
account_id: template.account_id,
category_id: template.category_id,
amount_ore: template.amount_ore,
description: template.description.clone(),
notes: template.notes.clone(),
expense_date: period.clamped_date(day).map_err(AppError::internal)?,
payer_user_id: template.payer_user_id,
split_mode: template.split_mode,
splits,
created_at: now_ts.to_owned(),
updated_at: now_ts.to_owned(),
deleted_at: None,
};
let version = db::next_server_version(conn).await?;
rows::insert_expense(
conn,
&expense,
Some((template.id, &period_text)),
Origin {
user_id: template.created_by_user_id,
device_id: None,
import_key: None,
received_at: now_ts,
server_version: version,
},
)
.await?;
let stored = rows::expense(conn, expense.id)
.await?
.ok_or_else(|| AppError::internal("generated expense vanished"))?;
let after = serde_json::to_value(&stored).map_err(AppError::internal)?;
state
.audit
.write(
conn,
AuditEvent {
entity_type: EntityType::Expense,
entity_id: expense.id,
action: AuditAction::Create,
source: AuditSource::Recurring,
actor_user_id: template.created_by_user_id,
actor_device_id: None,
client_at: now_ts.to_owned(),
server_at: now_ts.to_owned(),
before: None,
after,
},
)
.await?;
Ok(expense.id)
}
/// Generates every due period of one template (SPEC §9.5), oldest first, each in its own `BEGIN IMMEDIATE`
/// transaction: the expense (server UUIDv7, `expense_date` the clamped date, fields copied from the template,
/// created and updated by the template's creator, `updated_by_device_id` null), its splits (mode `default` resolves
@@ -183,72 +262,11 @@ pub async fn generate_due_periods(
.fetch_one(&mut *tx)
.await?;
let now_ts = format_ts(now);
let mut created = None;
if !exists {
let users = rows::complete_household(&mut tx).await?;
let splits = resolve_splits(&mut tx, &template, &users).await?;
let expense_id = Uuid::now_v7();
let version = db::next_server_version(&mut tx).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, ?, ?)",
)
.bind(expense_id.to_string())
.bind(template.account_id.to_string())
.bind(template.category_id.to_string())
.bind(template.amount_ore)
.bind(&template.description)
.bind(&template.notes)
.bind(period.clamped_date(day).map_err(AppError::internal)?)
.bind(template.payer_user_id.to_string())
.bind(rows::token_of(&template.split_mode)?)
.bind(template_id.to_string())
.bind(&period_text)
.bind(template.created_by_user_id.to_string())
.bind(template.created_by_user_id.to_string())
.bind(&now_ts)
.bind(&now_ts)
.bind(&now_ts)
.bind(version)
.execute(&mut *tx)
.await?;
for (user_id, share_ore, share_bp) in &splits {
sqlx::query(
"INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES (?, ?, ?, ?)",
)
.bind(expense_id.to_string())
.bind(user_id.to_string())
.bind(share_ore)
.bind(share_bp)
.execute(&mut *tx)
.await?;
}
let expense = rows::expense(&mut tx, expense_id)
.await?
.ok_or_else(|| AppError::internal("generated expense vanished"))?;
let after = serde_json::to_value(&expense).map_err(AppError::internal)?;
state
.audit
.write(
&mut tx,
AuditEvent {
entity_type: EntityType::Expense,
entity_id: expense_id,
action: AuditAction::Create,
source: AuditSource::Recurring,
actor_user_id: template.created_by_user_id,
actor_device_id: None,
client_at: now_ts.clone(),
server_at: now_ts.clone(),
before: None,
after,
},
)
.await?;
created = Some(expense_id);
}
let created = if exists {
None
} else {
Some(create_period_expense(state, &mut tx, &template, period, day, &now_ts).await?)
};
let version = db::next_server_version(&mut tx).await?;
sqlx::query(
"UPDATE recurring_templates SET last_generated_period = ?, updated_at = ?, server_version = ? WHERE id = ?",