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:
@@ -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 = ?",
|
||||
|
||||
Reference in New Issue
Block a user