M3: server core API (E-3 to E-30, E-33), landing page, pictures, rate limiting, recurring generation, bootstrap and pair-device
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,482 @@
|
||||
//! Recurring templates: E-28 to E-30 (FR-9, SPEC §9.5).
|
||||
//! Recurring templates: E-28 to E-30 (FR-9) and the generation of due periods (SPEC §9.5, D-015).
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
//! [`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`]).
|
||||
|
||||
use crate::api::{IdPath, not_implemented};
|
||||
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::{ApiJson, IdPath, JsonBody, rows, 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::{
|
||||
CreateRecurringTemplateRequest, PatchRecurringTemplateRequest, RecurringTemplate, UuidSchema,
|
||||
AuditAction, AuditSource, CreateRecurringTemplateRequest, EntityType,
|
||||
PatchRecurringTemplateRequest, RecurringTemplate, 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: `(user_id, share_ore, share_bp)`.
|
||||
async fn resolve_splits(
|
||||
conn: &mut SqliteConnection,
|
||||
template: &RecurringTemplate,
|
||||
users: &[User],
|
||||
) -> Result<Vec<(Uuid, i64, Option<i64>)>, 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, ore)| Ok((rows::uuid(&user)?, ore, 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, ore)| {
|
||||
let share_bp = bp.get(&user).map(|v| i64::from(*v));
|
||||
Ok((rows::uuid(&user)?, ore, share_bp))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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 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 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",
|
||||
@@ -25,8 +491,61 @@ use crate::wire::{
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn create_recurring_template(_device: AuthDevice) -> AppError {
|
||||
not_implemented()
|
||||
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(
|
||||
@@ -47,10 +566,82 @@ pub async fn create_recurring_template(_device: AuthDevice) -> AppError {
|
||||
)
|
||||
)]
|
||||
pub async fn patch_recurring_template(
|
||||
_device: AuthDevice,
|
||||
IdPath(_template_id): IdPath,
|
||||
) -> AppError {
|
||||
not_implemented()
|
||||
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(
|
||||
@@ -67,8 +658,28 @@ pub async fn patch_recurring_template(
|
||||
)
|
||||
)]
|
||||
pub async fn delete_recurring_template(
|
||||
_device: AuthDevice,
|
||||
IdPath(_template_id): IdPath,
|
||||
) -> AppError {
|
||||
not_implemented()
|
||||
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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user