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
+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>,
}