50a0d6792a
Build Kvitt images / build (push) Failing after 1m20s
Part A (D-132): KVITT_WEB_DIR (default /app) served under /app/ by a small handler: /app redirects to /app/, app routes fall back to index.html, a missing assets/* file is 404, assets cached immutable, index.html no-store, the app's own CSP plus nosniff, DENY and no-referrer. Unsafe paths never reach the file system. /app paths count against the unauthenticated per-IP limit. Without the directory every /app path stays 404 not_found. server/Dockerfile gains a Node 24 LTS stage (pinned by digest) building web/, passed through the Rust stage so npm and rustc never run at once on the Pi 3; the build context is the repository root with an allowlist .dockerignore (server/.dockerignore removed); workflow, build-images.sh and infra/dev/build.sh follow. Part B (D-133): GET /analytics/trends (E-34) and GET /analytics/forecast (E-35), authenticated, read-only, SQL month buckets; rules (half-up average, trailing 3-month moving average, percent against the rounded average, template occurrence per month) in kvitt-domain::analytics. OpenAPI, examples, contracts-check and tests updated (35 operations). Cargo.lock: rustls-webpki 0.103.16 and webpki-root-certs 1.0.10 (RUSTSEC-2026-0382, -0383) so that cargo audit passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
704 lines
27 KiB
Rust
704 lines
27 KiB
Rust
//! 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; the daily scheduler (`tasks::scheduler`) calls [`generate_all_due`].
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
|
|
use kvitt_domain::{Period, SplitSpec};
|
|
use sqlx::SqliteConnection;
|
|
use uuid::Uuid;
|
|
|
|
use crate::AppState;
|
|
use crate::api::rows::{self, Origin};
|
|
use crate::api::{ApiJson, IdPath, JsonBody, validate};
|
|
use crate::audit::AuditEvent;
|
|
use crate::auth::AuthDevice;
|
|
use crate::db;
|
|
use crate::error::AppError;
|
|
use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable};
|
|
use crate::push::{PushMessage, PushType};
|
|
use crate::time::{STOCKHOLM, format_ts};
|
|
use crate::wire::{
|
|
AuditAction, AuditSource, CreateRecurringTemplateRequest, EntityType,
|
|
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).
|
|
pub const GENERATION_TIME: NaiveTime = match NaiveTime::from_hms_opt(6, 0, 0) {
|
|
Some(t) => t,
|
|
None => NaiveTime::MIN,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------
|
|
// Generation (SPEC §9.5)
|
|
// ---------------------------------------------------------------------------------------------------------------
|
|
|
|
/// The occurrence instant of `period` for `day_of_month`: the clamped date at 06:00 Europe/Stockholm.
|
|
pub fn occurrence_instant(period: Period, day_of_month: u8) -> Result<DateTime<Utc>, AppError> {
|
|
let date_text = period
|
|
.clamped_date(day_of_month)
|
|
.map_err(AppError::internal)?;
|
|
let date = NaiveDate::parse_from_str(&date_text, "%Y-%m-%d").map_err(AppError::internal)?;
|
|
let local = STOCKHOLM
|
|
.from_local_datetime(&date.and_time(GENERATION_TIME))
|
|
.earliest()
|
|
.ok_or_else(|| AppError::internal("06:00 does not exist in Europe/Stockholm"))?;
|
|
Ok(local.with_timezone(&Utc))
|
|
}
|
|
|
|
/// One expense created by [`generate_due_periods`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct GeneratedExpense {
|
|
pub expense_id: Uuid,
|
|
pub period: String,
|
|
}
|
|
|
|
/// The expense splits of a generated expense (also E-35's `my_share` forecast of a template).
|
|
pub(crate) async fn resolve_splits(
|
|
conn: &mut SqliteConnection,
|
|
template: &RecurringTemplate,
|
|
users: &[User],
|
|
) -> 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 {
|
|
// Mode default resolves the account's current default ratio at generation time.
|
|
SplitMode::Default => {
|
|
let account = rows::account(conn, template.account_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::internal("template account is missing"))?;
|
|
account
|
|
.default_shares
|
|
.iter()
|
|
.map(|s| {
|
|
Ok((
|
|
s.user_id.to_string(),
|
|
u32::try_from(s.share_bp).map_err(AppError::internal)?,
|
|
))
|
|
})
|
|
.collect::<Result<_, AppError>>()?
|
|
}
|
|
SplitMode::Ratio | SplitMode::Full => template
|
|
.splits
|
|
.iter()
|
|
.map(|s| {
|
|
let bp = s
|
|
.share_bp
|
|
.ok_or_else(|| AppError::internal("template split without share_bp"))?;
|
|
Ok((
|
|
s.user_id.to_string(),
|
|
u32::try_from(bp).map_err(AppError::internal)?,
|
|
))
|
|
})
|
|
.collect::<Result<_, AppError>>()?,
|
|
SplitMode::Exact => {
|
|
let ore: BTreeMap<String, i64> = template
|
|
.splits
|
|
.iter()
|
|
.map(|s| {
|
|
let ore = s
|
|
.share_ore
|
|
.ok_or_else(|| AppError::internal("template split without share_ore"))?;
|
|
Ok((s.user_id.to_string(), ore))
|
|
})
|
|
.collect::<Result<_, AppError>>()?;
|
|
let shares = kvitt_domain::compute_split(
|
|
template.amount_ore,
|
|
&payer,
|
|
&ids,
|
|
&SplitSpec::Exact { ore },
|
|
)
|
|
.map_err(|e| {
|
|
AppError::internal(format!("stored exact template split is invalid: {e}"))
|
|
})?;
|
|
return shares
|
|
.into_iter()
|
|
.map(|(user, share_ore)| {
|
|
Ok(Split {
|
|
user_id: rows::uuid(&user)?,
|
|
share_ore,
|
|
share_bp: None,
|
|
})
|
|
})
|
|
.collect();
|
|
}
|
|
};
|
|
// Ratio rule for default, ratio and full (full is ratio with the bearer at 10000 bp, SPEC §14.5.2).
|
|
let shares = kvitt_domain::compute_split(
|
|
template.amount_ore,
|
|
&payer,
|
|
&ids,
|
|
&SplitSpec::Ratio { bp: bp.clone() },
|
|
)
|
|
.map_err(|e| AppError::internal(format!("stored template split is invalid: {e}")))?;
|
|
shares
|
|
.into_iter()
|
|
.map(|(user, share_ore)| {
|
|
let share_bp = bp.get(&user).map(|v| i64::from(*v));
|
|
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
|
|
/// the account's current default ratio; other modes use the template's splits, `ratio`/`full` recomputed by the ratio
|
|
/// rule), an audit `create` with `source` `recurring` through [`crate::audit::AuditWriter`], and the template's
|
|
/// `last_generated_period` (new `server_version`). After each commit it enqueues `recurring_expense_created`.
|
|
///
|
|
/// A period whose expense already exists (the unique index on `(recurring_template_id, recurring_period)`, a
|
|
/// deleted generated expense included) is skipped silently and only `last_generated_period` advances. Deleted or
|
|
/// paused templates generate nothing. "Now" is `state.clock`.
|
|
pub async fn generate_due_periods(
|
|
state: &AppState,
|
|
template_id: Uuid,
|
|
) -> Result<Vec<GeneratedExpense>, AppError> {
|
|
let now = state.clock.now();
|
|
let mut generated = Vec::new();
|
|
loop {
|
|
let mut tx = db::begin_immediate(&state.db).await?;
|
|
let Some(template) = rows::template(&mut tx, template_id).await? else {
|
|
break;
|
|
};
|
|
if template.deleted_at.is_some() || template.paused {
|
|
break;
|
|
}
|
|
let Some(period) = kvitt_domain::next_candidate_period(
|
|
&template.start_period,
|
|
template.end_period.as_deref(),
|
|
template.last_generated_period.as_deref(),
|
|
)
|
|
.map_err(AppError::internal)?
|
|
else {
|
|
break;
|
|
};
|
|
let day = u8::try_from(template.day_of_month).map_err(AppError::internal)?;
|
|
if occurrence_instant(period, day)? > now {
|
|
break;
|
|
}
|
|
let period_text = period.to_string();
|
|
let exists: bool = sqlx::query_scalar(
|
|
"SELECT EXISTS (SELECT 1 FROM expenses WHERE recurring_template_id = ? AND recurring_period = ?)",
|
|
)
|
|
.bind(template_id.to_string())
|
|
.bind(&period_text)
|
|
.fetch_one(&mut *tx)
|
|
.await?;
|
|
let now_ts = format_ts(now);
|
|
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 = ?",
|
|
)
|
|
.bind(&period_text)
|
|
.bind(&now_ts)
|
|
.bind(version)
|
|
.bind(template_id.to_string())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
if let Some(expense_id) = created {
|
|
state.push.enqueue(PushMessage {
|
|
push_type: PushType::RecurringExpenseCreated,
|
|
entity_id: Some(expense_id),
|
|
actor_user_id: template.created_by_user_id,
|
|
count: 1,
|
|
});
|
|
generated.push(GeneratedExpense {
|
|
expense_id,
|
|
period: period_text,
|
|
});
|
|
}
|
|
}
|
|
Ok(generated)
|
|
}
|
|
|
|
/// [`generate_due_periods`] for every non-deleted, non-paused template, in id order. Returns the number of
|
|
/// expenses created.
|
|
pub async fn generate_all_due(state: &AppState) -> Result<usize, AppError> {
|
|
let ids: Vec<String> = sqlx::query_scalar(
|
|
"SELECT id FROM recurring_templates WHERE deleted_at IS NULL AND paused = 0 ORDER BY id",
|
|
)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
let mut created = 0;
|
|
for id in ids {
|
|
created += generate_due_periods(state, rows::uuid(&id)?).await?.len();
|
|
}
|
|
Ok(created)
|
|
}
|
|
|
|
/// The latest period whose occurrence instant is before `now` (the resume rule of SPEC §9.5).
|
|
pub fn latest_past_period(day_of_month: u8, now: DateTime<Utc>) -> Result<Period, AppError> {
|
|
let current = validate::current_period(now)?;
|
|
if occurrence_instant(current, day_of_month)? < now {
|
|
Ok(current)
|
|
} else {
|
|
validate::months_before(current, 1).ok_or_else(|| AppError::internal("no month before"))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------
|
|
// Validation and storage of template fields
|
|
// ---------------------------------------------------------------------------------------------------------------
|
|
|
|
/// A stored template split: `(user_id, share_bp, share_ore)`.
|
|
type StoredSplit = (Uuid, Option<i64>, Option<i64>);
|
|
|
|
/// Every client-writable field of a template, after merging a PATCH.
|
|
struct Fields {
|
|
account_id: Uuid,
|
|
category_id: Uuid,
|
|
amount_ore: i64,
|
|
description: String,
|
|
notes: String,
|
|
payer_user_id: Uuid,
|
|
split_mode: SplitMode,
|
|
splits: Vec<TemplateSplit>,
|
|
day_of_month: i64,
|
|
start_period: String,
|
|
end_period: Option<String>,
|
|
paused: bool,
|
|
}
|
|
|
|
/// The validated template splits as stored: `(user_id, share_bp, share_ore)` (SPEC §9.1, §14.1).
|
|
fn validate_splits(fields: &Fields, users: &[User]) -> Result<Vec<StoredSplit>, AppError> {
|
|
let fail = |message: &str| AppError::validation("splits", message);
|
|
match fields.split_mode {
|
|
SplitMode::Default => {
|
|
if fields.splits.is_empty() {
|
|
Ok(Vec::new())
|
|
} else {
|
|
Err(fail("splits must be empty for mode default"))
|
|
}
|
|
}
|
|
SplitMode::Ratio | SplitMode::Full => {
|
|
let mut entries = Vec::new();
|
|
for split in &fields.splits {
|
|
match (split.share_bp, split.share_ore) {
|
|
(Some(bp), None) => entries.push((split.user_id, bp)),
|
|
_ => return Err(fail("modes ratio and full take share_bp only")),
|
|
}
|
|
}
|
|
rows::bp_shares("splits", &entries, users)?;
|
|
if fields.split_mode == SplitMode::Full {
|
|
let mut values: Vec<i64> = entries.iter().map(|(_, bp)| *bp).collect();
|
|
values.sort_unstable();
|
|
if values != [0, 10_000] {
|
|
return Err(fail(
|
|
"mode full needs one user at 10000 bp and the other at 0",
|
|
));
|
|
}
|
|
}
|
|
Ok(entries
|
|
.into_iter()
|
|
.map(|(u, bp)| (u, Some(bp), None))
|
|
.collect())
|
|
}
|
|
SplitMode::Exact => {
|
|
let mut ore = BTreeMap::new();
|
|
for split in &fields.splits {
|
|
match (split.share_bp, split.share_ore) {
|
|
(None, Some(value)) => {
|
|
ore.insert(split.user_id.to_string(), value);
|
|
}
|
|
_ => return Err(fail("mode exact takes share_ore only")),
|
|
}
|
|
}
|
|
if ore.len() != fields.splits.len() {
|
|
return Err(fail(kvitt_domain::SplitError::ShareUsersMismatch.token()));
|
|
}
|
|
kvitt_domain::compute_split(
|
|
fields.amount_ore,
|
|
&fields.payer_user_id.to_string(),
|
|
&rows::user_ids(users),
|
|
&SplitSpec::Exact { ore },
|
|
)
|
|
.map_err(|e| fail(e.token()))?;
|
|
Ok(fields
|
|
.splits
|
|
.iter()
|
|
.map(|s| (s.user_id, None, s.share_ore))
|
|
.collect())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validates `fields` (SPEC §9.6, §9.1, E-28). `start_changed` applies the 12-month backfill limit, which binds only
|
|
/// a `start_period` the client sends.
|
|
async fn validate_fields(
|
|
conn: &mut SqliteConnection,
|
|
fields: &mut Fields,
|
|
start_changed: bool,
|
|
now: DateTime<Utc>,
|
|
) -> Result<Vec<StoredSplit>, AppError> {
|
|
let users = rows::household(conn).await?;
|
|
let account = rows::account(conn, fields.account_id).await?;
|
|
if account.is_none_or(|a| a.deleted_at.is_some()) {
|
|
return Err(AppError::validation(
|
|
"account_id",
|
|
"account_id refers to no account",
|
|
));
|
|
}
|
|
let category = rows::category(conn, fields.category_id).await?;
|
|
if category.is_none_or(|c| c.deleted_at.is_some()) {
|
|
return Err(AppError::validation(
|
|
"category_id",
|
|
"category_id refers to no category",
|
|
));
|
|
}
|
|
validate::amount("amount_ore", fields.amount_ore)?;
|
|
fields.description = validate::text(
|
|
"description",
|
|
&fields.description,
|
|
1,
|
|
validate::DESCRIPTION_MAX,
|
|
)?;
|
|
fields.notes = validate::notes("notes", &fields.notes)?;
|
|
if !users.iter().any(|u| u.id == fields.payer_user_id) {
|
|
return Err(AppError::validation(
|
|
"payer_user_id",
|
|
kvitt_domain::SplitError::PayerUnknown.token(),
|
|
));
|
|
}
|
|
let splits = validate_splits(fields, &users)?;
|
|
validate::day_of_month(fields.day_of_month)?;
|
|
let start = if start_changed {
|
|
validate::start_period(&fields.start_period, now)?
|
|
} else {
|
|
validate::period("start_period", &fields.start_period)?
|
|
};
|
|
if let Some(end) = &fields.end_period
|
|
&& validate::period("end_period", end)? < start
|
|
{
|
|
return Err(AppError::validation(
|
|
"end_period",
|
|
"end_period must not be before start_period",
|
|
));
|
|
}
|
|
Ok(splits)
|
|
}
|
|
|
|
async fn replace_splits(
|
|
conn: &mut SqliteConnection,
|
|
template_id: Uuid,
|
|
splits: &[StoredSplit],
|
|
) -> Result<(), AppError> {
|
|
sqlx::query("DELETE FROM recurring_template_splits WHERE template_id = ?")
|
|
.bind(template_id.to_string())
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
for (user_id, share_bp, share_ore) in splits {
|
|
sqlx::query(
|
|
"INSERT INTO recurring_template_splits (template_id, user_id, share_bp, share_ore) VALUES (?, ?, ?, ?)",
|
|
)
|
|
.bind(template_id.to_string())
|
|
.bind(user_id.to_string())
|
|
.bind(share_bp)
|
|
.bind(share_ore)
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Generates what is due, then reads the template back in wire form.
|
|
async fn generate_and_reload(
|
|
state: &AppState,
|
|
template_id: Uuid,
|
|
) -> Result<RecurringTemplate, AppError> {
|
|
generate_due_periods(state, template_id).await?;
|
|
let mut conn = state.db.acquire().await?;
|
|
rows::template(&mut conn, template_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::internal("template vanished"))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/recurring-templates",
|
|
operation_id = "createRecurringTemplate",
|
|
summary = "E-28 Create recurring template",
|
|
description = "If a period is already due (SPEC §9.5), the server generates it before responding.",
|
|
request_body(content = CreateRecurringTemplateRequest, content_type = "application/json; charset=utf-8"),
|
|
responses(
|
|
(status = 201, description = "The new template.", body = RecurringTemplate, content_type = "application/json; charset=utf-8"),
|
|
(status = 400, response = BadRequest),
|
|
(status = 409, response = Conflict),
|
|
(status = 422, response = Unprocessable),
|
|
)
|
|
)]
|
|
pub async fn create_recurring_template(
|
|
device: AuthDevice,
|
|
State(state): State<AppState>,
|
|
ApiJson(body): ApiJson<CreateRecurringTemplateRequest>,
|
|
) -> Result<(StatusCode, JsonBody<RecurringTemplate>), AppError> {
|
|
let now = state.clock.now();
|
|
let mut tx = db::begin_immediate(&state.db).await?;
|
|
rows::complete_household(&mut tx).await?;
|
|
let mut fields = Fields {
|
|
account_id: body.account_id,
|
|
category_id: body.category_id,
|
|
amount_ore: body.amount_ore,
|
|
description: body.description,
|
|
notes: body.notes,
|
|
payer_user_id: body.payer_user_id,
|
|
split_mode: body.split_mode,
|
|
splits: body.splits,
|
|
day_of_month: body.day_of_month,
|
|
start_period: body.start_period,
|
|
end_period: body.end_period,
|
|
paused: body.paused,
|
|
};
|
|
let splits = validate_fields(&mut tx, &mut fields, true, now).await?;
|
|
let id = Uuid::now_v7();
|
|
let now_ts = format_ts(now);
|
|
let version = db::next_server_version(&mut tx).await?;
|
|
sqlx::query(
|
|
"INSERT INTO recurring_templates (id, account_id, category_id, amount_ore, description, notes, payer_user_id, \
|
|
split_mode, day_of_month, start_period, end_period, paused, last_generated_period, created_by_user_id, \
|
|
updated_by_user_id, created_at, updated_at, deleted_at, server_version) \
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, NULL, ?)",
|
|
)
|
|
.bind(id.to_string())
|
|
.bind(fields.account_id.to_string())
|
|
.bind(fields.category_id.to_string())
|
|
.bind(fields.amount_ore)
|
|
.bind(&fields.description)
|
|
.bind(&fields.notes)
|
|
.bind(fields.payer_user_id.to_string())
|
|
.bind(rows::token_of(&fields.split_mode)?)
|
|
.bind(fields.day_of_month)
|
|
.bind(&fields.start_period)
|
|
.bind(&fields.end_period)
|
|
.bind(i64::from(fields.paused))
|
|
.bind(device.user_id.to_string())
|
|
.bind(device.user_id.to_string())
|
|
.bind(&now_ts)
|
|
.bind(&now_ts)
|
|
.bind(version)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
replace_splits(&mut tx, id, &splits).await?;
|
|
tx.commit().await?;
|
|
let template = generate_and_reload(&state, id).await?;
|
|
Ok((StatusCode::CREATED, JsonBody(template)))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
patch,
|
|
path = "/recurring-templates/{template_id}",
|
|
operation_id = "patchRecurringTemplate",
|
|
summary = "E-29 Edit, pause, resume",
|
|
description = "Any subset of the E-28 fields; `split_mode` and `splits` must be sent together.",
|
|
params(
|
|
("template_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
|
),
|
|
request_body(content = PatchRecurringTemplateRequest, content_type = "application/json; charset=utf-8"),
|
|
responses(
|
|
(status = 200, description = "The updated template.", body = RecurringTemplate, content_type = "application/json; charset=utf-8"),
|
|
(status = 400, response = BadRequest),
|
|
(status = 404, response = NotFound),
|
|
(status = 422, response = Unprocessable),
|
|
)
|
|
)]
|
|
pub async fn patch_recurring_template(
|
|
device: AuthDevice,
|
|
State(state): State<AppState>,
|
|
IdPath(template_id): IdPath,
|
|
ApiJson(body): ApiJson<PatchRecurringTemplateRequest>,
|
|
) -> Result<JsonBody<RecurringTemplate>, AppError> {
|
|
if body == PatchRecurringTemplateRequest::default() {
|
|
return Err(AppError::invalid_request("PATCH needs at least one field"));
|
|
}
|
|
if body.split_mode.is_some() != body.splits.is_some() {
|
|
return Err(AppError::invalid_request(
|
|
"split_mode and splits must be sent together",
|
|
));
|
|
}
|
|
let now = state.clock.now();
|
|
let mut tx = db::begin_immediate(&state.db).await?;
|
|
let current = rows::live_template(&mut tx, template_id).await?;
|
|
let start_changed = body.start_period.is_some();
|
|
let mut fields = Fields {
|
|
account_id: body.account_id.unwrap_or(current.account_id),
|
|
category_id: body.category_id.unwrap_or(current.category_id),
|
|
amount_ore: body.amount_ore.unwrap_or(current.amount_ore),
|
|
description: body.description.unwrap_or(current.description),
|
|
notes: body.notes.unwrap_or(current.notes),
|
|
payer_user_id: body.payer_user_id.unwrap_or(current.payer_user_id),
|
|
split_mode: body.split_mode.unwrap_or(current.split_mode),
|
|
splits: body.splits.unwrap_or(current.splits),
|
|
day_of_month: body.day_of_month.unwrap_or(current.day_of_month),
|
|
start_period: body.start_period.unwrap_or(current.start_period),
|
|
end_period: body.end_period.unwrap_or(current.end_period),
|
|
paused: body.paused.unwrap_or(current.paused),
|
|
};
|
|
let splits = validate_fields(&mut tx, &mut fields, start_changed, now).await?;
|
|
|
|
// Resume does not backfill (SPEC §9.5): last_generated_period jumps to the latest period whose occurrence is
|
|
// already past, when that is later than the current value (and not before start_period).
|
|
let mut last_generated = current.last_generated_period.clone();
|
|
if current.paused && !fields.paused {
|
|
let day = validate::day_of_month(fields.day_of_month)?;
|
|
let latest = latest_past_period(day, now)?;
|
|
let start = validate::period("start_period", &fields.start_period)?;
|
|
let current_last = last_generated
|
|
.as_deref()
|
|
.map(|p| validate::period("last_generated_period", p))
|
|
.transpose()?;
|
|
if latest >= start && current_last.is_none_or(|last| latest > last) {
|
|
last_generated = Some(latest.to_string());
|
|
}
|
|
}
|
|
|
|
let version = db::next_server_version(&mut tx).await?;
|
|
sqlx::query(
|
|
"UPDATE recurring_templates SET account_id = ?, category_id = ?, amount_ore = ?, description = ?, notes = ?, \
|
|
payer_user_id = ?, split_mode = ?, day_of_month = ?, start_period = ?, end_period = ?, paused = ?, \
|
|
last_generated_period = ?, updated_by_user_id = ?, updated_at = ?, server_version = ? WHERE id = ?",
|
|
)
|
|
.bind(fields.account_id.to_string())
|
|
.bind(fields.category_id.to_string())
|
|
.bind(fields.amount_ore)
|
|
.bind(&fields.description)
|
|
.bind(&fields.notes)
|
|
.bind(fields.payer_user_id.to_string())
|
|
.bind(rows::token_of(&fields.split_mode)?)
|
|
.bind(fields.day_of_month)
|
|
.bind(&fields.start_period)
|
|
.bind(&fields.end_period)
|
|
.bind(i64::from(fields.paused))
|
|
.bind(&last_generated)
|
|
.bind(device.user_id.to_string())
|
|
.bind(format_ts(now))
|
|
.bind(version)
|
|
.bind(template_id.to_string())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
replace_splits(&mut tx, template_id, &splits).await?;
|
|
tx.commit().await?;
|
|
Ok(JsonBody(generate_and_reload(&state, template_id).await?))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/recurring-templates/{template_id}",
|
|
operation_id = "deleteRecurringTemplate",
|
|
summary = "E-30 Soft delete recurring template",
|
|
params(
|
|
("template_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
|
|
),
|
|
responses(
|
|
(status = 200, description = "The tombstone.", body = RecurringTemplate, content_type = "application/json; charset=utf-8"),
|
|
(status = 404, response = NotFound),
|
|
)
|
|
)]
|
|
pub async fn delete_recurring_template(
|
|
device: AuthDevice,
|
|
State(state): State<AppState>,
|
|
IdPath(template_id): IdPath,
|
|
) -> Result<JsonBody<RecurringTemplate>, AppError> {
|
|
let mut tx = db::begin_immediate(&state.db).await?;
|
|
rows::live_template(&mut tx, template_id).await?;
|
|
let now = state.clock.now_ts();
|
|
let version = db::next_server_version(&mut tx).await?;
|
|
sqlx::query(
|
|
"UPDATE recurring_templates SET deleted_at = ?, updated_at = ?, updated_by_user_id = ?, server_version = ? \
|
|
WHERE id = ?",
|
|
)
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.bind(device.user_id.to_string())
|
|
.bind(version)
|
|
.bind(template_id.to_string())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
let template = rows::template(&mut tx, template_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::internal("template vanished"))?;
|
|
tx.commit().await?;
|
|
Ok(JsonBody(template))
|
|
}
|