06f48ef9e9
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>
231 lines
12 KiB
SQL
231 lines
12 KiB
SQL
-- Kvitt server schema v1. Do not edit; add a new migration and bump the schema version (SPEC §14.8).
|
|
|
|
CREATE TABLE meta (
|
|
key TEXT PRIMARY KEY NOT NULL,
|
|
value TEXT NOT NULL
|
|
) STRICT;
|
|
|
|
-- One global, strictly increasing counter for server_version. Exactly one row.
|
|
CREATE TABLE sync_sequence (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
value INTEGER NOT NULL
|
|
) STRICT;
|
|
|
|
CREATE TABLE users (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
|
|
display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 40),
|
|
picture_version INTEGER CHECK (picture_version IS NULL OR picture_version >= 1),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
server_version INTEGER NOT NULL UNIQUE
|
|
) STRICT;
|
|
CREATE UNIQUE INDEX users_single_admin ON users (role) WHERE role = 'admin';
|
|
|
|
CREATE TABLE devices (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
user_id TEXT NOT NULL REFERENCES users (id),
|
|
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60),
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
fcm_token TEXT,
|
|
app_version_code INTEGER,
|
|
created_at TEXT NOT NULL,
|
|
last_seen_at TEXT NOT NULL,
|
|
revoked_at TEXT,
|
|
revoked_by_user_id TEXT REFERENCES users (id)
|
|
) STRICT;
|
|
CREATE INDEX devices_user ON devices (user_id);
|
|
|
|
CREATE TABLE invites (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
kind TEXT NOT NULL CHECK (kind IN ('admin_setup', 'partner', 'device')),
|
|
code_hash TEXT NOT NULL UNIQUE,
|
|
target_user_id TEXT REFERENCES users (id),
|
|
created_by_user_id TEXT REFERENCES users (id),
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
accepted_at TEXT,
|
|
accepted_device_id TEXT REFERENCES devices (id),
|
|
cancelled_at TEXT,
|
|
CHECK ((kind = 'device') = (target_user_id IS NOT NULL)),
|
|
CHECK ((kind = 'admin_setup') = (created_by_user_id IS NULL) OR kind = 'device')
|
|
) STRICT;
|
|
|
|
CREATE TABLE accounts (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60),
|
|
name_key TEXT NOT NULL,
|
|
created_by_user_id TEXT NOT NULL REFERENCES users (id),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
archived_at TEXT,
|
|
deleted_at TEXT,
|
|
server_version INTEGER NOT NULL UNIQUE
|
|
) STRICT;
|
|
-- name_key = Unicode-lowercased, trimmed name, computed in Rust.
|
|
CREATE UNIQUE INDEX accounts_name_live ON accounts (name_key) WHERE deleted_at IS NULL;
|
|
|
|
CREATE TABLE account_default_shares (
|
|
account_id TEXT NOT NULL REFERENCES accounts (id),
|
|
user_id TEXT NOT NULL REFERENCES users (id),
|
|
share_bp INTEGER NOT NULL CHECK (share_bp BETWEEN 0 AND 10000),
|
|
PRIMARY KEY (account_id, user_id)
|
|
) STRICT, WITHOUT ROWID;
|
|
|
|
CREATE TABLE categories (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
default_key TEXT UNIQUE CHECK (default_key IS NULL OR default_key IN
|
|
('groceries', 'restaurants', 'home', 'utilities', 'transport',
|
|
'travel', 'entertainment', 'health', 'shopping', 'other')),
|
|
name TEXT CHECK (name IS NULL OR length(name) BETWEEN 1 AND 40),
|
|
icon TEXT NOT NULL,
|
|
color TEXT NOT NULL,
|
|
sort_order INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
archived_at TEXT,
|
|
deleted_at TEXT,
|
|
server_version INTEGER NOT NULL UNIQUE,
|
|
CHECK (name IS NOT NULL OR default_key IS NOT NULL)
|
|
) STRICT;
|
|
|
|
CREATE TABLE recurring_templates (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
account_id TEXT NOT NULL REFERENCES accounts (id),
|
|
category_id TEXT NOT NULL REFERENCES categories (id),
|
|
amount_ore INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000),
|
|
description TEXT NOT NULL CHECK (length(description) BETWEEN 1 AND 100),
|
|
notes TEXT NOT NULL DEFAULT '' CHECK (length(notes) <= 1000),
|
|
payer_user_id TEXT NOT NULL REFERENCES users (id),
|
|
split_mode TEXT NOT NULL CHECK (split_mode IN ('default', 'ratio', 'exact', 'full')),
|
|
day_of_month INTEGER NOT NULL CHECK (day_of_month BETWEEN 1 AND 31),
|
|
start_period TEXT NOT NULL,
|
|
end_period TEXT,
|
|
paused INTEGER NOT NULL DEFAULT 0 CHECK (paused IN (0, 1)),
|
|
last_generated_period TEXT,
|
|
created_by_user_id TEXT NOT NULL REFERENCES users (id),
|
|
updated_by_user_id TEXT NOT NULL REFERENCES users (id),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
deleted_at TEXT,
|
|
server_version INTEGER NOT NULL UNIQUE
|
|
) STRICT;
|
|
CREATE INDEX recurring_templates_account ON recurring_templates (account_id);
|
|
|
|
-- Modes ratio and full: share_bp set, share_ore NULL. Mode exact: share_ore set, share_bp NULL. Mode default: no rows.
|
|
CREATE TABLE recurring_template_splits (
|
|
template_id TEXT NOT NULL REFERENCES recurring_templates (id),
|
|
user_id TEXT NOT NULL REFERENCES users (id),
|
|
share_bp INTEGER CHECK (share_bp IS NULL OR share_bp BETWEEN 0 AND 10000),
|
|
share_ore INTEGER CHECK (share_ore IS NULL OR share_ore >= 0),
|
|
PRIMARY KEY (template_id, user_id),
|
|
CHECK ((share_bp IS NULL) <> (share_ore IS NULL))
|
|
) STRICT, WITHOUT ROWID;
|
|
|
|
CREATE TABLE expenses (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
account_id TEXT NOT NULL REFERENCES accounts (id),
|
|
category_id TEXT NOT NULL REFERENCES categories (id),
|
|
amount_ore INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000),
|
|
description TEXT NOT NULL CHECK (length(description) BETWEEN 1 AND 100),
|
|
notes TEXT NOT NULL DEFAULT '' CHECK (length(notes) <= 1000),
|
|
expense_date TEXT NOT NULL,
|
|
payer_user_id TEXT NOT NULL REFERENCES users (id),
|
|
split_mode TEXT NOT NULL CHECK (split_mode IN ('default', 'ratio', 'exact', 'full')),
|
|
recurring_template_id TEXT REFERENCES recurring_templates (id),
|
|
recurring_period TEXT,
|
|
import_key TEXT UNIQUE,
|
|
created_by_user_id TEXT NOT NULL REFERENCES users (id),
|
|
updated_by_user_id TEXT NOT NULL REFERENCES users (id),
|
|
updated_by_device_id TEXT REFERENCES devices (id),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
deleted_at TEXT,
|
|
server_received_at TEXT NOT NULL,
|
|
server_version INTEGER NOT NULL UNIQUE,
|
|
CHECK ((recurring_template_id IS NULL) = (recurring_period IS NULL))
|
|
) STRICT;
|
|
CREATE UNIQUE INDEX expenses_recurring_once ON expenses (recurring_template_id, recurring_period)
|
|
WHERE recurring_template_id IS NOT NULL;
|
|
CREATE INDEX expenses_account ON expenses (account_id);
|
|
CREATE INDEX expenses_category ON expenses (category_id);
|
|
|
|
-- Exactly two rows per expense, one per household user. share_bp NULL for mode exact.
|
|
CREATE TABLE expense_splits (
|
|
expense_id TEXT NOT NULL REFERENCES expenses (id),
|
|
user_id TEXT NOT NULL REFERENCES users (id),
|
|
share_ore INTEGER NOT NULL CHECK (share_ore >= 0),
|
|
share_bp INTEGER CHECK (share_bp IS NULL OR share_bp BETWEEN 0 AND 10000),
|
|
PRIMARY KEY (expense_id, user_id)
|
|
) STRICT, WITHOUT ROWID;
|
|
|
|
CREATE TABLE settlements (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
from_user_id TEXT NOT NULL REFERENCES users (id),
|
|
to_user_id TEXT NOT NULL REFERENCES users (id),
|
|
amount_ore INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000),
|
|
settlement_date TEXT NOT NULL,
|
|
note TEXT NOT NULL DEFAULT '' CHECK (length(note) <= 200),
|
|
import_key TEXT UNIQUE,
|
|
created_by_user_id TEXT NOT NULL REFERENCES users (id),
|
|
updated_by_user_id TEXT NOT NULL REFERENCES users (id),
|
|
updated_by_device_id TEXT REFERENCES devices (id),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
deleted_at TEXT,
|
|
server_received_at TEXT NOT NULL,
|
|
server_version INTEGER NOT NULL UNIQUE,
|
|
CHECK (from_user_id <> to_user_id)
|
|
) STRICT;
|
|
|
|
CREATE TABLE settlement_allocations (
|
|
settlement_id TEXT NOT NULL REFERENCES settlements (id),
|
|
account_id TEXT NOT NULL REFERENCES accounts (id),
|
|
amount_ore INTEGER NOT NULL CHECK (amount_ore >= 1),
|
|
PRIMARY KEY (settlement_id, account_id)
|
|
) STRICT, WITHOUT ROWID;
|
|
CREATE INDEX settlement_allocations_account ON settlement_allocations (account_id);
|
|
|
|
-- Append-only. changes is a JSON array of {"field","old","new"} (SPEC §14.4.7).
|
|
CREATE TABLE audit_log (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
entity_type TEXT NOT NULL CHECK (entity_type IN ('expense', 'settlement')),
|
|
entity_id TEXT NOT NULL,
|
|
action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')),
|
|
source TEXT NOT NULL CHECK (source IN ('app', 'recurring', 'import')),
|
|
actor_user_id TEXT NOT NULL REFERENCES users (id),
|
|
actor_device_id TEXT REFERENCES devices (id),
|
|
client_at TEXT NOT NULL,
|
|
server_at TEXT NOT NULL,
|
|
changes TEXT NOT NULL,
|
|
server_version INTEGER NOT NULL UNIQUE
|
|
) STRICT;
|
|
CREATE INDEX audit_log_entity ON audit_log (entity_type, entity_id);
|
|
|
|
-- Idempotency of POST /sync. Rows older than 90 days are pruned daily.
|
|
CREATE TABLE sync_mutations (
|
|
mutation_id TEXT PRIMARY KEY NOT NULL,
|
|
device_id TEXT NOT NULL REFERENCES devices (id),
|
|
entity_type TEXT NOT NULL CHECK (entity_type IN ('expense', 'settlement')),
|
|
entity_id TEXT NOT NULL,
|
|
result TEXT NOT NULL,
|
|
received_at TEXT NOT NULL
|
|
) STRICT;
|
|
CREATE INDEX sync_mutations_received ON sync_mutations (received_at);
|
|
|
|
INSERT INTO sync_sequence (id, value) VALUES (1, 10);
|
|
INSERT INTO meta (key, value) VALUES ('schema_version', '1');
|
|
INSERT INTO meta (key, value) VALUES ('sync_epoch', lower(hex(randomblob(16))));
|
|
|
|
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version) VALUES
|
|
('01920000-0000-7000-8000-000000000001', 'groceries', NULL, 'shopping_cart', 'green', 10, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 1),
|
|
('01920000-0000-7000-8000-000000000002', 'restaurants', NULL, 'restaurant', 'orange', 20, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 2),
|
|
('01920000-0000-7000-8000-000000000003', 'home', NULL, 'home', 'brown', 30, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 3),
|
|
('01920000-0000-7000-8000-000000000004', 'utilities', NULL, 'bolt', 'yellow', 40, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 4),
|
|
('01920000-0000-7000-8000-000000000005', 'transport', NULL, 'directions_car', 'blue', 50, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 5),
|
|
('01920000-0000-7000-8000-000000000006', 'travel', NULL, 'flight', 'teal', 60, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 6),
|
|
('01920000-0000-7000-8000-000000000007', 'entertainment', NULL, 'movie', 'purple', 70, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 7),
|
|
('01920000-0000-7000-8000-000000000008', 'health', NULL, 'medical_services', 'red', 80, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 8),
|
|
('01920000-0000-7000-8000-000000000009', 'shopping', NULL, 'shopping_bag', 'pink', 90, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 9),
|
|
('01920000-0000-7000-8000-00000000000a', 'other', NULL, 'category', 'grey', 100, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 10);
|