M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile

Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned.
kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015).
kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to
the contract schema, error type for all 25 codes, auth extractor and protocol
layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal
to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:25:35 +02:00
parent c277ee37c2
commit 06f48ef9e9
71 changed files with 9187 additions and 0 deletions
@@ -0,0 +1,65 @@
//! Account objects (SPEC §14.3.3, §14.3.4 E-18, E-19).
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use super::{ServerVersionSchema, TimestampSchema, UuidSchema};
/// A share of an account default.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Share {
#[schema(value_type = UuidSchema)]
pub user_id: Uuid,
#[schema(schema_with = super::int)]
pub share_bp: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Account {
#[schema(value_type = UuidSchema)]
pub id: Uuid,
pub name: String,
/// Sorted by `user_id` ([`Account::sort`]).
#[schema(min_items = 2, max_items = 2)]
pub default_shares: Vec<Share>,
#[schema(value_type = UuidSchema)]
pub created_by_user_id: Uuid,
#[schema(value_type = TimestampSchema)]
pub created_at: String,
#[schema(value_type = TimestampSchema)]
pub updated_at: String,
#[schema(value_type = Option<TimestampSchema>, required = true)]
pub archived_at: Option<String>,
#[schema(value_type = Option<TimestampSchema>, required = true)]
pub deleted_at: Option<String>,
#[schema(value_type = ServerVersionSchema)]
pub server_version: i64,
}
impl Account {
/// Sorts `default_shares` by `user_id` (SPEC §14.3.3).
pub fn sort(&mut self) {
self.default_shares.sort_by_key(|s| s.user_id);
}
}
/// E-18 `POST /accounts`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct CreateAccountRequest {
pub name: String,
#[schema(min_items = 2, max_items = 2)]
pub default_shares: Vec<Share>,
}
/// E-19 `PATCH /accounts/{account_id}`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[schema(min_properties = 1)]
pub struct PatchAccountRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = String)]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Vec<Share>, min_items = 2, max_items = 2)]
pub default_shares: Option<Vec<Share>>,
}
@@ -0,0 +1,104 @@
//! Audit entries (SPEC §14.3.3, §14.4.7).
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use super::{ServerVersionSchema, TimestampSchema, UuidSchema};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum EntityType {
Expense,
Settlement,
}
impl EntityType {
pub fn as_str(self) -> &'static str {
match self {
Self::Expense => "expense",
Self::Settlement => "settlement",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum AuditAction {
Create,
Update,
Delete,
}
impl AuditAction {
pub fn as_str(self) -> &'static str {
match self {
Self::Create => "create",
Self::Update => "update",
Self::Delete => "delete",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum AuditSource {
App,
Recurring,
Import,
}
impl AuditSource {
pub fn as_str(self) -> &'static str {
match self {
Self::App => "app",
Self::Recurring => "recurring",
Self::Import => "import",
}
}
}
/// One field of the audit diff (SPEC §14.4.7). `old` and `new` are JSON values of the field's type, or null.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct AuditChange {
pub field: String,
#[schema(value_type = AnyValue, required = true)]
pub old: serde_json::Value,
#[schema(value_type = AnyValue, required = true)]
pub new: serde_json::Value,
}
/// `{}`: any JSON value, `null` included.
pub struct AnyValue;
impl utoipa::PartialSchema for AnyValue {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::RefOr::T(utoipa::openapi::schema::Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::SchemaType::AnyValue)
.build(),
))
}
}
impl ToSchema for AnyValue {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct AuditEntry {
#[schema(value_type = UuidSchema)]
pub id: Uuid,
pub entity_type: EntityType,
#[schema(value_type = UuidSchema)]
pub entity_id: Uuid,
pub action: AuditAction,
pub source: AuditSource,
#[schema(value_type = UuidSchema)]
pub actor_user_id: Uuid,
#[schema(value_type = TimestampSchema)]
pub client_at: String,
#[schema(value_type = TimestampSchema)]
pub server_at: String,
pub changes: Vec<AuditChange>,
#[schema(value_type = ServerVersionSchema)]
pub server_version: i64,
}
@@ -0,0 +1,79 @@
//! Category objects (SPEC §14.3.3, §14.3.4 E-23, E-24).
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use super::{ServerVersionSchema, TimestampSchema, UuidSchema};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DefaultCategoryKey {
Groceries,
Restaurants,
Home,
Utilities,
Transport,
Travel,
Entertainment,
Health,
Shopping,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Category {
#[schema(value_type = UuidSchema)]
pub id: Uuid,
#[schema(required = true)]
pub default_key: Option<DefaultCategoryKey>,
#[schema(required = true)]
pub name: Option<String>,
/// An icon token of SPEC §9.7.
pub icon: String,
/// A colour token of SPEC §9.7.
pub color: String,
#[schema(schema_with = super::int)]
pub sort_order: i64,
#[schema(value_type = TimestampSchema)]
pub created_at: String,
#[schema(value_type = TimestampSchema)]
pub updated_at: String,
#[schema(value_type = Option<TimestampSchema>, required = true)]
pub archived_at: Option<String>,
#[schema(value_type = Option<TimestampSchema>, required = true)]
pub deleted_at: Option<String>,
#[schema(value_type = ServerVersionSchema)]
pub server_version: i64,
}
/// E-23 `POST /categories`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct CreateCategoryRequest {
pub name: String,
pub icon: String,
pub color: String,
}
/// E-24 `PATCH /categories/{category_id}`. `name`: absent = unchanged (`None`), `null` = reset a default category's
/// name (`Some(None)`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[schema(min_properties = 1)]
pub struct PatchCategoryRequest {
#[serde(
default,
deserialize_with = "super::double_option",
skip_serializing_if = "Option::is_none"
)]
#[schema(value_type = Option<String>, required = false)]
pub name: Option<Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = String)]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = String)]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(schema_with = super::int)]
pub sort_order: Option<i64>,
}
@@ -0,0 +1,40 @@
//! Device objects (SPEC §14.3.3, §14.3.4 E-15, E-17).
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use super::{TimestampSchema, UuidSchema};
/// Only in `/me`, `/devices` and `/invites/accept`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Device {
#[schema(value_type = UuidSchema)]
pub id: Uuid,
#[schema(value_type = UuidSchema)]
pub user_id: Uuid,
pub name: String,
#[schema(schema_with = super::nullable_int, required = true)]
pub app_version_code: Option<i64>,
#[schema(value_type = TimestampSchema)]
pub created_at: String,
#[schema(value_type = TimestampSchema)]
pub last_seen_at: String,
#[schema(value_type = Option<TimestampSchema>, required = true)]
pub revoked_at: Option<String>,
pub is_current: bool,
}
/// E-15 `GET /devices`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct DeviceList {
pub devices: Vec<Device>,
}
/// E-17 `PUT /devices/current/push-token`: the key is required, its value nullable (null clears).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct PushTokenRequest {
#[serde(deserialize_with = "super::required_nullable")]
#[schema(required = true)]
pub fcm_token: Option<String>,
}
@@ -0,0 +1,74 @@
//! Expense objects (SPEC §14.3.3).
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use super::{
DateSchema, OreSchema, PeriodSchema, ServerVersionSchema, TimestampSchema, UuidSchema,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SplitMode {
Default,
Ratio,
Exact,
Full,
}
/// A split of an expense; `share_bp` null for mode exact.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Split {
#[schema(value_type = UuidSchema)]
pub user_id: Uuid,
#[schema(value_type = OreSchema)]
pub share_ore: i64,
#[schema(schema_with = super::nullable_int, required = true)]
pub share_bp: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Expense {
#[schema(value_type = UuidSchema)]
pub id: Uuid,
#[schema(value_type = UuidSchema)]
pub account_id: Uuid,
#[schema(value_type = UuidSchema)]
pub category_id: Uuid,
#[schema(value_type = OreSchema)]
pub amount_ore: i64,
pub description: String,
pub notes: String,
#[schema(value_type = DateSchema)]
pub expense_date: String,
#[schema(value_type = UuidSchema)]
pub payer_user_id: Uuid,
pub split_mode: SplitMode,
/// Sorted by `user_id` ([`Expense::sort`]).
#[schema(min_items = 2, max_items = 2)]
pub splits: Vec<Split>,
#[schema(value_type = Option<UuidSchema>, required = true)]
pub recurring_template_id: Option<Uuid>,
#[schema(value_type = Option<PeriodSchema>, required = true)]
pub recurring_period: Option<String>,
#[schema(value_type = UuidSchema)]
pub created_by_user_id: Uuid,
#[schema(value_type = UuidSchema)]
pub updated_by_user_id: Uuid,
#[schema(value_type = TimestampSchema)]
pub created_at: String,
#[schema(value_type = TimestampSchema)]
pub updated_at: String,
#[schema(value_type = Option<TimestampSchema>, required = true)]
pub deleted_at: Option<String>,
#[schema(value_type = ServerVersionSchema)]
pub server_version: i64,
}
impl Expense {
/// Sorts `splits` by `user_id` (SPEC §14.3.3).
pub fn sort(&mut self) {
self.splits.sort_by_key(|s| s.user_id);
}
}
@@ -0,0 +1,80 @@
//! Invite objects (SPEC §14.3.3, §14.3.4 E-3, E-12, E-13).
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use super::device::Device;
use super::user::User;
use super::{TimestampSchema, UuidSchema};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum InviteKind {
AdminSetup,
Partner,
Device,
}
/// `code` and `link` only in the `POST /invites` response; `null` in `GET /invites`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Invite {
#[schema(value_type = UuidSchema)]
pub id: Uuid,
pub kind: InviteKind,
#[schema(value_type = Option<UuidSchema>, required = true)]
pub target_user_id: Option<Uuid>,
#[schema(required = true)]
pub code: Option<String>,
#[schema(required = true)]
pub link: Option<String>,
#[schema(value_type = TimestampSchema)]
pub created_at: String,
#[schema(value_type = TimestampSchema)]
pub expires_at: String,
}
/// E-13 `GET /invites`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct InviteList {
pub invites: Vec<Invite>,
}
/// E-3 `POST /invites/accept`. `display_name` is required for `admin_setup` and `partner` invites
/// (`invalid_request` if missing) and ignored for `device`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct AcceptInviteRequest {
pub code: String,
pub device_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = String)]
pub display_name: Option<String>,
}
/// E-3 response. The token is shown only here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct AcceptInviteResponse {
pub token: String,
pub user: User,
pub device: Device,
pub household: Vec<User>,
}
/// The kinds an admin may create (E-12).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CreateInviteKind {
Partner,
Device,
}
/// E-12 `POST /invites`. `target_user_id` is optional and nullable (absent = null); required for `device`, must be
/// null for `partner`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct CreateInviteRequest {
#[schema(inline)]
pub kind: CreateInviteKind,
#[serde(default)]
#[schema(value_type = Option<UuidSchema>)]
pub target_user_id: Option<Uuid>,
}
+240
View File
@@ -0,0 +1,240 @@
//! Wire objects (SPEC §14.3.3, §14.4) with their serde form and utoipa schemas.
//!
//! Conventions: ids are [`uuid::Uuid`] (lowercase, hyphenated on the wire); timestamps, dates and periods are
//! `String`s in the fixed forms of the SPEC conventions; optional response fields are `Option`s that serialise as
//! `null`, never omitted. Arrays are sorted as SPEC §14.3.3 says by the code that builds them (`sort_*` helpers).
//! Request bodies ignore unknown fields (serde's default).
pub mod account;
pub mod audit;
pub mod category;
pub mod device;
pub mod expense;
pub mod invite;
pub mod recurring;
pub mod settlement;
pub mod sync;
pub mod user;
pub use account::{Account, CreateAccountRequest, PatchAccountRequest, Share};
pub use audit::{AuditAction, AuditChange, AuditEntry, AuditSource, EntityType};
pub use category::{Category, CreateCategoryRequest, DefaultCategoryKey, PatchCategoryRequest};
pub use device::{Device, DeviceList, PushTokenRequest};
pub use expense::{Expense, Split, SplitMode};
pub use invite::{
AcceptInviteRequest, AcceptInviteResponse, CreateInviteKind, CreateInviteRequest, Invite,
InviteKind, InviteList,
};
pub use recurring::{
CreateRecurringTemplateRequest, PatchRecurringTemplateRequest, RecurringTemplate, TemplateSplit,
};
pub use settlement::{Allocation, ProposalRequest, ProposalResponse, Settlement};
pub use sync::{
AcceptedResult, ExpenseMutation, Mutation, MutationResult, PushedExpense, PushedSettlement,
RejectedResult, RejectionReason, SettlementMutation, SyncPullResponse, SyncPushRequest,
SyncPushResponse,
};
pub use user::{
ApkLatest, Health, HealthStatus, MeResponse, PatchMeRequest, Role, SetupStatus, User,
};
use utoipa::openapi::schema::{ObjectBuilder, Schema, SchemaFormat, SchemaType, Type};
use utoipa::openapi::{KnownFormat, RefOr};
use utoipa::{PartialSchema, ToSchema};
/// Declares a schema-only marker type for a primitive of SPEC §14.3.3 (used with `#[schema(value_type = …)]`).
macro_rules! primitive_schema {
($(#[$doc:meta])* $marker:ident, $name:literal, $schema:expr) => {
$(#[$doc])*
pub struct $marker;
impl PartialSchema for $marker {
fn schema() -> RefOr<Schema> {
let builder: ObjectBuilder = $schema;
RefOr::T(Schema::Object(builder.build()))
}
}
impl ToSchema for $marker {
fn name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed($name)
}
}
};
}
primitive_schema!(
/// `uuid`: lowercase, hyphenated, 36 characters (UUIDv7).
UuidSchema,
"Uuid",
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::String))
.format(Some(SchemaFormat::KnownFormat(KnownFormat::Uuid)))
.description(Some("Lowercase, hyphenated, 36 characters (UUIDv7)."))
.pattern(Some("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"))
);
primitive_schema!(
/// `ts`: RFC 3339 UTC with millisecond precision and a literal `Z`.
TimestampSchema,
"Timestamp",
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::String))
.format(Some(SchemaFormat::KnownFormat(KnownFormat::DateTime)))
.description(Some("RFC 3339 UTC, millisecond precision, literal Z."))
.pattern(Some(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$"))
);
primitive_schema!(
/// `date`: `YYYY-MM-DD`.
DateSchema,
"Date",
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::String))
.format(Some(SchemaFormat::KnownFormat(KnownFormat::Date)))
.pattern(Some(r"^\d{4}-\d{2}-\d{2}$"))
);
primitive_schema!(
/// `period`: a month, `YYYY-MM`.
PeriodSchema,
"Period",
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::String))
.description(Some("A month, YYYY-MM."))
.pattern(Some(r"^\d{4}-(0[1-9]|1[0-2])$"))
);
primitive_schema!(
/// Money in öre (1/100 SEK), 64-bit.
OreSchema,
"Ore",
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::Integer))
.format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64)))
.description(Some("Money in öre (1/100 SEK)."))
);
primitive_schema!(
/// A `server_version` from the global sequence.
ServerVersionSchema,
"ServerVersionNumber",
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::Integer))
.format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64)))
.description(Some("From the one global, strictly increasing sequence (SPEC §5.2)."))
);
/// `{ "type": "integer" }` without a format: the contract leaves small integers (bp, sort order, day of month,
/// versions) unformatted. Used with `#[schema(schema_with = int)]`.
pub fn int() -> Schema {
Schema::Object(
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::Integer))
.build(),
)
}
/// `{ "type": ["integer", "null"] }`.
pub fn nullable_int() -> Schema {
Schema::Object(
ObjectBuilder::new()
.schema_type(SchemaType::from_iter([Type::Integer, Type::Null]))
.build(),
)
}
/// `{ "type": ["integer", "null"], "format": "int64" }`.
pub fn nullable_int64() -> Schema {
Schema::Object(
ObjectBuilder::new()
.schema_type(SchemaType::from_iter([Type::Integer, Type::Null]))
.format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64)))
.build(),
)
}
/// Deserialises a field that distinguishes "absent" (`None`, via `#[serde(default)]`) from `null` (`Some(None)`),
/// for PATCH bodies where `null` means something (SPEC §14.3.4 E-24 `name`).
pub fn double_option<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
<Option<T> as serde::Deserialize>::deserialize(deserializer).map(Some)
}
/// Deserialises a key that must be present but may be `null` (with `deserialize_with`, serde no longer treats a
/// missing `Option` as `None`, so absence is `invalid_request`). SPEC §14.3.4 E-17 `fcm_token`, E-28 `end_period`.
pub fn required_nullable<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
<Option<T> as serde::Deserialize>::deserialize(deserializer)
}
primitive_schema!(
/// `{ "type": "integer" }` without a format (query parameters and headers).
IntSchema,
"Int",
ObjectBuilder::new().schema_type(SchemaType::Type(Type::Integer))
);
primitive_schema!(
/// The `size` query parameter of E-11: 128 or 512, default 512.
PictureSizeSchema,
"PictureSize",
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::Integer))
.enum_values(Some([128, 512]))
.default(Some(serde_json::json!(512)))
);
primitive_schema!(
/// The `limit` query parameter of E-31: default 500.
SyncLimitSchema,
"SyncLimit",
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::Integer))
.default(Some(serde_json::json!(500)))
);
/// Declares a schema-only marker for a raw binary body of the given media type.
macro_rules! binary_schema {
($(#[$doc:meta])* $marker:ident, $name:literal, $media:literal) => {
primitive_schema!(
$(#[$doc])*
$marker,
$name,
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::String))
.content_media_type($media)
);
};
}
binary_schema!(
/// Raw JPEG bytes.
JpegBytes,
"JpegBytes",
"image/jpeg"
);
binary_schema!(
/// Raw PNG bytes.
PngBytes,
"PngBytes",
"image/png"
);
binary_schema!(
/// Raw WebP bytes.
WebpBytes,
"WebpBytes",
"image/webp"
);
binary_schema!(
/// Raw APK bytes.
ApkBytes,
"ApkBytes",
"application/vnd.android.package-archive"
);
@@ -0,0 +1,150 @@
//! Recurring template objects (SPEC §14.3.3, §14.3.4 E-28, E-29).
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use super::expense::SplitMode;
use super::{
DateSchema, OreSchema, PeriodSchema, ServerVersionSchema, TimestampSchema, UuidSchema,
};
/// Exactly one of `share_bp` / `share_ore` non-null.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct TemplateSplit {
#[schema(value_type = UuidSchema)]
pub user_id: Uuid,
#[schema(schema_with = super::nullable_int, required = true)]
pub share_bp: Option<i64>,
#[schema(schema_with = super::nullable_int64, required = true)]
pub share_ore: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct RecurringTemplate {
#[schema(value_type = UuidSchema)]
pub id: Uuid,
#[schema(value_type = UuidSchema)]
pub account_id: Uuid,
#[schema(value_type = UuidSchema)]
pub category_id: Uuid,
#[schema(value_type = OreSchema)]
pub amount_ore: i64,
pub description: String,
pub notes: String,
#[schema(value_type = UuidSchema)]
pub payer_user_id: Uuid,
pub split_mode: SplitMode,
/// Sorted by `user_id`; empty for mode default ([`RecurringTemplate::sort`]).
pub splits: Vec<TemplateSplit>,
#[schema(schema_with = super::int)]
pub day_of_month: i64,
#[schema(value_type = PeriodSchema)]
pub start_period: String,
#[schema(value_type = Option<PeriodSchema>, required = true)]
pub end_period: Option<String>,
pub paused: bool,
#[schema(value_type = Option<PeriodSchema>, required = true)]
pub last_generated_period: Option<String>,
/// Computed with `kvitt_domain::next_occurrence_date` (SPEC §9.5).
#[schema(value_type = Option<DateSchema>, required = true)]
pub next_occurrence_date: Option<String>,
#[schema(value_type = UuidSchema)]
pub created_by_user_id: Uuid,
#[schema(value_type = UuidSchema)]
pub updated_by_user_id: Uuid,
#[schema(value_type = TimestampSchema)]
pub created_at: String,
#[schema(value_type = TimestampSchema)]
pub updated_at: String,
#[schema(value_type = Option<TimestampSchema>, required = true)]
pub deleted_at: Option<String>,
#[schema(value_type = ServerVersionSchema)]
pub server_version: i64,
}
impl RecurringTemplate {
/// Sorts `splits` by `user_id` (SPEC §14.3.3).
pub fn sort(&mut self) {
self.splits.sort_by_key(|s| s.user_id);
}
}
/// E-28 `POST /recurring-templates`. `splits` per SPEC §9.1: none for `default`; two bp entries for `ratio` and
/// `full`; two öre entries summing to the amount for `exact`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct CreateRecurringTemplateRequest {
#[schema(value_type = UuidSchema)]
pub account_id: Uuid,
#[schema(value_type = UuidSchema)]
pub category_id: Uuid,
#[schema(value_type = OreSchema)]
pub amount_ore: i64,
pub description: String,
pub notes: String,
#[schema(value_type = UuidSchema)]
pub payer_user_id: Uuid,
pub split_mode: SplitMode,
pub splits: Vec<TemplateSplit>,
#[schema(schema_with = super::int)]
pub day_of_month: i64,
#[schema(value_type = PeriodSchema)]
pub start_period: String,
#[serde(deserialize_with = "super::required_nullable")]
#[schema(value_type = Option<PeriodSchema>, required = true)]
pub end_period: Option<String>,
pub paused: bool,
}
/// E-29 `PATCH /recurring-templates/{template_id}`: any subset of the E-28 fields; `split_mode` and `splits` must
/// be sent together (the schema's `dependentRequired`, added by [`patch_template_dependent_required`]).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)]
#[schema(min_properties = 1)]
pub struct PatchRecurringTemplateRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = UuidSchema)]
pub account_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = UuidSchema)]
pub category_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = OreSchema)]
pub amount_ore: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = String)]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = String)]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = UuidSchema)]
pub payer_user_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = SplitMode)]
pub split_mode: Option<SplitMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Vec<TemplateSplit>)]
pub splits: Option<Vec<TemplateSplit>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(schema_with = super::int)]
pub day_of_month: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = PeriodSchema)]
pub start_period: Option<String>,
#[serde(
default,
deserialize_with = "super::double_option",
skip_serializing_if = "Option::is_none"
)]
#[schema(value_type = Option<PeriodSchema>, required = false)]
pub end_period: Option<Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = bool)]
pub paused: Option<bool>,
}
/// The `dependentRequired` keyword of [`PatchRecurringTemplateRequest`]: `split_mode` and `splits` together. utoipa
/// has no attribute for it, so `openapi.rs` writes it into the generated schema.
pub fn patch_template_dependent_required() -> serde_json::Value {
serde_json::json!({ "split_mode": ["splits"], "splits": ["split_mode"] })
}
@@ -0,0 +1,73 @@
//! Settlement and proposal objects (SPEC §14.3.3, §14.3.4 E-33).
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use super::{DateSchema, OreSchema, ServerVersionSchema, TimestampSchema, UuidSchema};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Allocation {
#[schema(value_type = UuidSchema)]
pub account_id: Uuid,
#[schema(value_type = OreSchema)]
pub amount_ore: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Settlement {
#[schema(value_type = UuidSchema)]
pub id: Uuid,
#[schema(value_type = UuidSchema)]
pub from_user_id: Uuid,
#[schema(value_type = UuidSchema)]
pub to_user_id: Uuid,
#[schema(value_type = OreSchema)]
pub amount_ore: i64,
#[schema(value_type = DateSchema)]
pub settlement_date: String,
pub note: String,
/// Sorted by `account_id` ([`Settlement::sort`]).
pub allocations: Vec<Allocation>,
#[schema(value_type = UuidSchema)]
pub created_by_user_id: Uuid,
#[schema(value_type = UuidSchema)]
pub updated_by_user_id: Uuid,
#[schema(value_type = TimestampSchema)]
pub created_at: String,
#[schema(value_type = TimestampSchema)]
pub updated_at: String,
#[schema(value_type = Option<TimestampSchema>, required = true)]
pub deleted_at: Option<String>,
#[schema(value_type = ServerVersionSchema)]
pub server_version: i64,
}
impl Settlement {
/// Sorts `allocations` by `account_id` (SPEC §14.3.3).
pub fn sort(&mut self) {
self.allocations.sort_by_key(|a| a.account_id);
}
}
/// E-33 `POST /settlements/proposal`. `exclude_settlement_id` is optional and nullable (absent = null).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct ProposalRequest {
#[schema(value_type = UuidSchema)]
pub payer_user_id: Uuid,
#[schema(value_type = OreSchema)]
pub amount_ore: i64,
#[serde(default)]
#[schema(value_type = Option<UuidSchema>)]
pub exclude_settlement_id: Option<Uuid>,
}
/// E-33 response, computed with `kvitt_domain::propose_allocation`. Allocations in the order of SPEC §9.4.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct ProposalResponse {
pub allocations: Vec<Allocation>,
#[schema(value_type = OreSchema)]
pub excess_ore: i64,
#[schema(value_type = Vec<UuidSchema>)]
pub over_settled_account_ids: Vec<Uuid>,
}
+242
View File
@@ -0,0 +1,242 @@
//! Sync protocol objects (SPEC §14.4.2, §14.4.3, §14.4.6).
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use super::account::Account;
use super::audit::{AuditEntry, EntityType};
use super::category::Category;
use super::expense::{Expense, Split, SplitMode};
use super::recurring::RecurringTemplate;
use super::settlement::{Allocation, Settlement};
use super::user::User;
use super::{DateSchema, OreSchema, ServerVersionSchema, TimestampSchema, UuidSchema};
/// E-31 `GET /sync` response: one page; every array present (possibly empty) and sorted by `server_version`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct SyncPullResponse {
/// `meta.sync_epoch`, 32 lowercase hex characters.
#[schema(pattern = "^[0-9a-f]{32}$")]
pub epoch: String,
pub since: i64,
/// The highest `server_version` in the page, or `since` when the page is empty.
pub next_since: i64,
pub has_more: bool,
#[schema(value_type = TimestampSchema)]
pub server_time: String,
pub users: Vec<User>,
pub categories: Vec<Category>,
pub accounts: Vec<Account>,
pub recurring_templates: Vec<RecurringTemplate>,
pub expenses: Vec<Expense>,
pub settlements: Vec<Settlement>,
pub audit_entries: Vec<AuditEntry>,
}
/// Exactly the client-writable fields of an expense (SPEC §14.4.3); server-owned fields are ignored if sent. A
/// delete is the full entity with `deleted_at` set.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct PushedExpense {
#[schema(value_type = UuidSchema)]
pub id: Uuid,
#[schema(value_type = UuidSchema)]
pub account_id: Uuid,
#[schema(value_type = UuidSchema)]
pub category_id: Uuid,
#[schema(value_type = OreSchema)]
pub amount_ore: i64,
pub description: String,
pub notes: String,
#[schema(value_type = DateSchema)]
pub expense_date: String,
#[schema(value_type = UuidSchema)]
pub payer_user_id: Uuid,
pub split_mode: SplitMode,
pub splits: Vec<Split>,
#[schema(value_type = TimestampSchema)]
pub created_at: String,
#[schema(value_type = TimestampSchema)]
pub updated_at: String,
#[serde(deserialize_with = "super::required_nullable")]
#[schema(value_type = Option<TimestampSchema>, required = true)]
pub deleted_at: Option<String>,
}
/// Exactly the client-writable fields of a settlement (SPEC §14.4.3).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct PushedSettlement {
#[schema(value_type = UuidSchema)]
pub id: Uuid,
#[schema(value_type = UuidSchema)]
pub from_user_id: Uuid,
#[schema(value_type = UuidSchema)]
pub to_user_id: Uuid,
#[schema(value_type = OreSchema)]
pub amount_ore: i64,
#[schema(value_type = DateSchema)]
pub settlement_date: String,
pub note: String,
pub allocations: Vec<Allocation>,
#[schema(value_type = TimestampSchema)]
pub created_at: String,
#[schema(value_type = TimestampSchema)]
pub updated_at: String,
#[serde(deserialize_with = "super::required_nullable")]
#[schema(value_type = Option<TimestampSchema>, required = true)]
pub deleted_at: Option<String>,
}
/// The constant `"expense"` (the discriminator value of [`ExpenseMutation`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub enum ExpenseTag {
#[serde(rename = "expense")]
Expense,
}
/// The constant `"settlement"` (the discriminator value of [`SettlementMutation`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub enum SettlementTag {
#[serde(rename = "settlement")]
Settlement,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct ExpenseMutation {
#[schema(value_type = UuidSchema)]
pub mutation_id: Uuid,
#[schema(inline)]
pub entity_type: ExpenseTag,
pub entity: PushedExpense,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct SettlementMutation {
#[schema(value_type = UuidSchema)]
pub mutation_id: Uuid,
#[schema(inline)]
pub entity_type: SettlementTag,
pub entity: PushedSettlement,
}
/// One pushed change, discriminated by `entity_type`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
#[schema(discriminator(property_name = "entity_type", mapping(
("expense" = "#/components/schemas/ExpenseMutation"),
("settlement" = "#/components/schemas/SettlementMutation"),
)))]
pub enum Mutation {
Expense(ExpenseMutation),
Settlement(SettlementMutation),
}
impl Mutation {
pub fn mutation_id(&self) -> Uuid {
match self {
Self::Expense(m) => m.mutation_id,
Self::Settlement(m) => m.mutation_id,
}
}
pub fn entity_type(&self) -> EntityType {
match self {
Self::Expense(_) => EntityType::Expense,
Self::Settlement(_) => EntityType::Settlement,
}
}
}
/// E-32 `POST /sync` request: 1 to 100 mutations (more is 422 `batch_too_large`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct SyncPushRequest {
pub mutations: Vec<Mutation>,
}
/// A full wire object of the type named by `entity_type`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
pub enum SyncEntity {
Expense(Expense),
Settlement(Settlement),
}
/// The constant `"accepted"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub enum AcceptedTag {
#[serde(rename = "accepted")]
Accepted,
}
/// The constant `"rejected"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub enum RejectedTag {
#[serde(rename = "rejected")]
Rejected,
}
/// Exhaustive (SPEC §14.4.6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum RejectionReason {
Stale,
Deleted,
ValidationFailed,
ReferenceNotFound,
ClockSkew,
}
/// An accepted mutation. Accepted and rejected results have different key sets (SPEC §14.4.3).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct AcceptedResult {
#[schema(value_type = UuidSchema)]
pub mutation_id: Uuid,
pub entity_type: EntityType,
#[schema(value_type = UuidSchema)]
pub entity_id: Uuid,
#[schema(inline)]
pub status: AcceptedTag,
#[schema(value_type = ServerVersionSchema)]
pub server_version: i64,
// The full wire object after processing. (A doc comment here would make utoipa wrap the schema in `allOf`.)
#[schema(inline)]
pub entity: SyncEntity,
}
/// A rejected mutation (SPEC §14.4.6).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct RejectedResult {
#[schema(value_type = UuidSchema)]
pub mutation_id: Uuid,
pub entity_type: EntityType,
#[schema(value_type = UuidSchema)]
pub entity_id: Uuid,
#[schema(inline)]
pub status: RejectedTag,
pub reason: RejectionReason,
/// For validation failures, the domain error token of SPEC §14.5.3.
pub message: String,
// The stored row, or null when the server has no row with that id.
#[schema(inline, required = true)]
pub current: Option<SyncEntity>,
}
/// One result per mutation, discriminated by `status`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
#[schema(discriminator(property_name = "status", mapping(
("accepted" = "#/components/schemas/AcceptedResult"),
("rejected" = "#/components/schemas/RejectedResult"),
)))]
pub enum MutationResult {
Accepted(AcceptedResult),
Rejected(RejectedResult),
}
/// E-32 response: one result per mutation, in request order.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct SyncPushResponse {
#[schema(value_type = TimestampSchema)]
pub server_time: String,
pub results: Vec<MutationResult>,
}
+105
View File
@@ -0,0 +1,105 @@
//! User, health, setup, `/me` and APK objects (SPEC §14.3.3, §14.3.4 E-1, E-2, E-5, E-7, E-8).
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use super::device::Device;
use super::{ServerVersionSchema, TimestampSchema, UuidSchema};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Role {
Admin,
Member,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Self::Admin => "admin",
Self::Member => "member",
}
}
/// The `users.role` column value.
pub fn from_db(text: &str) -> Option<Self> {
match text {
"admin" => Some(Self::Admin),
"member" => Some(Self::Member),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct User {
#[schema(value_type = UuidSchema)]
pub id: Uuid,
pub role: Role,
pub display_name: String,
#[schema(schema_with = super::nullable_int, required = true)]
pub picture_version: Option<i64>,
#[schema(value_type = TimestampSchema)]
pub created_at: String,
#[schema(value_type = TimestampSchema)]
pub updated_at: String,
#[schema(value_type = ServerVersionSchema)]
pub server_version: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum HealthStatus {
Ok,
Degraded,
}
/// E-1 `GET /health`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[schema(as = Health)]
pub struct Health {
#[schema(inline)]
pub status: HealthStatus,
pub server_version: String,
#[schema(schema_with = super::int)]
pub schema_version: i64,
pub db_ok: bool,
}
/// E-2 `GET /setup/status`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct SetupStatus {
pub initialized: bool,
}
/// E-5 `GET /apk/latest`: the content of `latest.json` (SPEC §10.6).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct ApkLatest {
#[schema(schema_with = super::int)]
pub version_code: i64,
pub version_name: String,
pub file_name: String,
pub size_bytes: i64,
pub sha256: String,
#[schema(value_type = TimestampSchema)]
pub released_at: String,
}
/// E-7 `GET /me`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct MeResponse {
pub user: User,
pub device: Device,
pub household: Vec<User>,
pub public_url: String,
}
/// E-8 `PATCH /me`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[schema(min_properties = 1)]
pub struct PatchMeRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = String)]
pub display_name: Option<String>,
}