From cab3e935d6d42bf65241e1fd7d650ec4052dfe42 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 17 Oct 2026 20:39:02 +0200 Subject: [PATCH] SPEC v0.1 and project rules for Kvitt Write SPEC.md v0.1 from the owner interview (requirements, data model, stack, API outline, security, business rules, deployment, milestones, UX sprint brief, contracts). Fill the CLAUDE.md blanks, move the Gradle pin files into android/, point the tools at android/ and server/, and record the git bootstrap exception in the handoff. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 11 + CLAUDE.md | 56 +- README-agents.md | 10 +- SPEC.md | 2315 +++++++++++++++++ .../gradle.properties | 0 .../gradle}/gradle-daemon-jvm.properties | 0 docs/HANDOFF.md | 16 +- docs/decisions.md | 1 - tools/dependency-report/latest.py | 4 +- tools/git-hooks/pre-commit | 39 +- tools/gradle-serial.sh | 6 +- tools/install-git-hooks.sh | 2 +- 12 files changed, 2412 insertions(+), 48 deletions(-) create mode 100644 SPEC.md rename gradle.properties => android/gradle.properties (100%) rename {gradle => android/gradle}/gradle-daemon-jvm.properties (100%) diff --git a/.gitignore b/.gitignore index 7d3593f..fdc98ff 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,14 @@ export/ .claude/worktrees/ # macOS .DS_Store +# Rust build output +target/ +# Server data, keys and certificates (they live on the Pi or in infra/secrets/, never in git) +infra/secrets/ +*.sqlite +*.sqlite3 +*.db +*.pem +*service-account*.json +google-services.json +*.apk diff --git a/CLAUDE.md b/CLAUDE.md index 7b32bdd..7941130 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Operating guide for AI agents and humans working on **Kvitt**. This is the only working-rules file; it says how to work here, not what the product is. For the product, read `SPEC.md`. It was seeded on 2026-10-17 from the Ebb project's rules after a year of running Opus agents in parallel; keep what fits, delete what does not, but keep one file. -> **Fill in before the first plan:** the one-paragraph product description, the module layout under "Repository layout", the application ids under "The phone" (if this is an Android app), and the fast checks in `tools/git-hooks/pre-commit`. +**The product in one paragraph.** Kvitt is a Splitwise-style shared-expense app for exactly two people, the owner (Jacob, admin) and his spouse, in Sweden. A native Android app (Kotlin, Compose) keeps a full offline copy of the household's expense accounts, expenses, settlements and audit log in Room and syncs with a small Rust server (axum, SQLite) that runs in Docker on a Raspberry Pi 3 at home, reachable at `https://icehack.asuscomm.com:8443`. Money is SEK in integer öre. The phone computes balances, settle-up proposals and spending analytics itself; the server stores, syncs, schedules recurring expenses, sends push notifications and hosts the APK for in-app updates. Everything else is in `SPEC.md`. --- @@ -20,20 +20,26 @@ Operating guide for AI agents and humans working on **Kvitt**. This is the only ``` SPEC.md, PLAN-*.md what to build; prompts/ holds a ready-to-paste start prompt per plan and the orchestrator prompt +contracts/ openapi.yaml, test-vectors/*.json, sync and push examples: the cross-team contracts (SPEC §14) docs/HANDOFF.md state of the work and how it is run docs/decisions.md decisions that changed or refined the spec +docs/ux/ the UX sprint deliverables (SPEC §13) docs/spikes/, docs/validation/ what the device and the data showed (reports are deliverables) -tools/ gradle-serial.sh, clean-builds.sh, install-git-hooks.sh, dependency-report/latest.py -(root) the app: one Gradle project, modules as in SPEC +android/ the Android app: one Gradle project (settings.gradle.kts, gradle/libs.versions.toml), modules as in SPEC §14.7 +server/ the Rust backend: one Cargo workspace, crates kvitt-domain, kvitt-server, kvitt-import (SPEC §14.7) +infra/ docker compose stack for the Pi (caddy, server, cert-sync, backup), image build scripts, ops runbooks +tools/ gradle-serial.sh, clean-builds.sh, install-git-hooks.sh, git-hooks/, dependency-report/latest.py ``` +Who owns which directory during parallel work is in SPEC §14.7. An agent edits only the paths its plan owns. + --- ## ORCHESTRATION: HOW WORK IS RUN The owner talks to one **orchestrator** session. The orchestrator plans, briefs, lands, gates, installs and answers; it **never writes code or repository changes itself**, however small (docs and config included). Every change goes through an **Opus** implementation agent. If the orchestrator slips and edits something, it keeps the work and hands it to an agent to own; it never reverts or redoes it. -* **One Opus agent per task, in its own git worktree** (`.claude/worktrees/agent-`, branch of the same name). The agent's first step is `git reset --hard main`, then copying `local.properties` (and any git-ignored vendor binaries) from the main checkout. It commits on its branch, rebases on `main`, runs the gate, and reports; it never lands, pushes or removes its worktree. +* **One Opus agent per task, in its own git worktree** (`.claude/worktrees/agent-`, branch of the same name). The agent's first step is `git reset --hard main`, then copying `android/local.properties`, `android/app/google-services.json` and anything in `infra/secrets/` it needs (all git-ignored) from the main checkout. It commits on its branch, rebases on `main`, runs the gate, and reports; it never lands, pushes or removes its worktree. * **The orchestrator lands:** `git merge --ff-only ` in the main checkout, re-runs the gate on `main`, installs on the phone if attached, removes the worktree (`git worktree remove --force --force`, falling back to `rm -rf` + `git worktree prune`) and deletes the branch. No merge commits. Never rebase `main`. * **Briefs are the whole context an agent has.** A brief states: the goal, what to read first (file paths), what is already established, the exact numbers it may use (next free decision, spec version, schema), the setup steps, the gate, what to report. If the orchestrator cannot write a clear brief, it does not understand the task yet. * **Agents' reports are not shown to the owner.** The orchestrator extracts the decision: one line per finding, fixed or not, and relays every owner step in chat. @@ -54,13 +60,15 @@ The owner talks to one **orchestrator** session. The orchestrator plans, briefs, --- -## THE PHONE (if this is a device app) +## THE PHONE + +Two phones: the owner's Samsung Galaxy S26 and the spouse's Galaxy S26+, both Android 16. Agents only ever touch the phone attached to this MacBook over USB, which is the owner's unless the brief says otherwise. | App | Application ID | |---|---| -| Real app, release | `` | -| Real app, debug | `.debug` | -| Instrumented-test APKs | `..test` | +| Real app, release | `se.hagstedt.kvitt` | +| Real app, debug | `se.hagstedt.kvitt.debug` | +| Instrumented-test APKs | `se.hagstedt.kvitt..test` | * `adb` is at `/opt/homebrew/share/android-commandlinetools/platform-tools/adb` on this MacBook; it is not on the shell PATH. * Use `adb` **only for this project's apps**. Do not browse, copy or change anything else on the device. @@ -74,24 +82,32 @@ The owner talks to one **orchestrator** session. The orchestrator plans, briefs, Everything builds **locally on this MacBook**. No hosted CI, no remote build service. -**JDK.** Gradle runs on JDK 25 (current LTS): `gradle/gradle-daemon-jvm.properties` asks for 25 and `gradle.properties` points Gradle at Homebrew's `openjdk@25` (auto-download off). The shell's `java` is a macOS stub or a newer non-LTS: run Gradle as `PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH tools/gradle-serial.sh ` and **never set `JAVA_HOME`**. Do not install another JDK or change the pin without asking. +**Two projects, two gates.** The Android app is the Gradle project in `android/`; the server is the Cargo workspace in `server/`. A change that touches both runs both gates. -**Android SDK.** `local.properties` must contain `sdk.dir` (`/opt/homebrew/share/android-commandlinetools` on this MacBook). Add the line if missing; never overwrite the file, it also holds client ids and tokens; never commit it. +**JDK.** Gradle runs on JDK 25 (current LTS): `android/gradle/gradle-daemon-jvm.properties` asks for 25 and `android/gradle.properties` points Gradle at Homebrew's `openjdk@25` (auto-download off). The shell's `java` is a macOS stub or a newer non-LTS: run Gradle as `PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH tools/gradle-serial.sh ` and **never set `JAVA_HOME`**. Do not install another JDK or change the pin without asking. + +**Android SDK.** `android/local.properties` must contain `sdk.dir` (`/opt/homebrew/share/android-commandlinetools` on this MacBook). Add the line if missing; never overwrite the file, it also holds client ids and tokens; never commit it. ```bash -tools/gradle-serial.sh check # THE gate: build, unit tests, lint, architecture rules, screenshot tests -tools/gradle-serial.sh installDebug # install the debug app on the attached phone +# Android gate, from android/ (the script finds the repository root itself and runs android/gradlew): +../tools/gradle-serial.sh check # THE Android gate: build, unit tests, lint, architecture rules, screenshot tests +../tools/gradle-serial.sh installDebug # install the debug app on the attached phone +# Server gate, from server/: +cargo fmt --check && cargo clippy -- -D warnings && cargo test # THE server gate +# Either directory: tools/install-git-hooks.sh # once per checkout -tools/clean-builds.sh [--dry-run] # delete build output in every checkout and worktree (disk) -python3 tools/dependency-report/latest.py # version drift of gradle/libs.versions.toml +tools/clean-builds.sh [--dry-run] # delete Gradle build output in every checkout and worktree (disk) +python3 tools/dependency-report/latest.py # version drift of android/gradle/libs.versions.toml ``` -* **`check` is the single gate.** Every check a plan asks for must be reachable from it. Report gates with the real result lines (BUILD line, actionable tasks, test counts from fresh XML only). Never report a check as passing that you did not run in this session. -* **One Gradle build at a time.** Parallel builds exhaust memory. Always `tools/gradle-serial.sh`; it waits on a lock in `/tmp` and then runs the wrapper of your worktree. +* **`tools/gradle-serial.sh check` (Android) and `cargo fmt --check && cargo clippy -- -D warnings && cargo test` (server) are the only gates.** Every check a plan asks for must be reachable from one of them (the OpenAPI match test and the test-vector tests included). Report gates with the real result lines (BUILD line, actionable tasks, test counts from fresh XML only). Never report a check as passing that you did not run in this session. +* **One Gradle build at a time.** Parallel builds exhaust memory. Always `tools/gradle-serial.sh`; it waits on a lock in `/tmp` and then runs `android/gradlew` of your worktree. * **Disk.** Three agents plus the main checkout can fill a disk. `~/.gradle/init.d/cache-retention.gradle.kts` (user-level, outside the repo) keeps `~/.gradle/caches` at 7 days with cleanup on every build; Gradle 9 accepts build-cache retention only there. Run `tools/clean-builds.sh` after removing a worktree or when free space is under about 5 GB. Deleting `~/.gradle/caches/build-cache-1` and old `~/.gradle/caches/` folders is allowed; the next build is a slow full one. An agent that hits "no space left on device" loses only uncommitted work: free space and resume it, do not respawn. * **Version drift:** run the dependency report at the start of every plan. A library more than one stable minor behind gets a line in the plan's report; a toolchain component more than one line behind triggers an upgrade plan first. * **Screenshots.** Goldens are committed; `check` fails when a render differs. Re-record only for an intended change and **open every changed PNG** before committing. Code that compiles is not evidence of a good screen. -* **Every schema change gets a migration and a migration test.** User decisions stored in the database are not derived data and must survive every migration. +* **Every schema change gets a migration and a migration test** (sqlx migration on the server, Room migration on the phone). User data stored in the database is not derived data and must survive every migration. +* **Contracts are versioned.** A change to any contract file (`contracts/openapi.yaml`, the SQLite schema, the sync protocol, the Room entities, `contracts/test-vectors/*.json`, the push payload) requires a spec version bump and a `docs/decisions.md` entry in the same change. Number both from the brief. +* **Cargo builds** are not serialised by `gradle-serial.sh`; run at most one `cargo build`/`cargo test` per worktree at a time and never build Docker images for the Pi while a Gradle build runs. --- @@ -99,12 +115,14 @@ python3 tools/dependency-report/latest.py # version drift of gradle/libs.versi Enforced by an architecture check inside `check` where possible. Fill in from SPEC; the shape that worked: -* **Layering:** `feature → core:model`, `domain → core:model`, `data → core:model`; only `:app` sees everything. Pure modules (`:core:model`, `:domain:*`) import only `kotlin.*`, `kotlinx.*` and the project's own packages: no `java.time`, no `java.io`, no Android. +* **Layering (Android):** `feature → core:model, core:design`; `core:design → core:model`; `domain → core:model`; `data → core:model, domain`; `core:fakes → core:model, domain`; `adapter:fcm → core:model`. Only `:app` sees everything and binds the `:data` repositories to the `:core:model` interfaces through Hilt. Features never see `:data` or `:domain` (SPEC §6.2). Pure modules (`:core:model`, `:domain`) import only `kotlin.*`, `kotlinx.*` and the project's own packages: no `java.time`, no `java.io`, no Android. * **Vendor SDKs live in one adapter module each** and map to the project's own records inside it. Sources are plugins behind a registry; nothing names a vendor outside its adapter. Source ids are open strings, never enums. * **The UI contract is the contract:** features consume `*State` classes through repository interfaces; a contract change is made in the model, the fakes and the real repositories in the same change. * **Fakes for every repository**, driven by scenarios, so screens render and screenshot-test without a device. Fakes never reach a release build. * **Fast-moving APIs:** confirm current usage from the official documentation at implementation time. Do not rely on memory. * **Licences first:** no vendor binary and no model file is committed, bundled or downloaded by a build before the owner has cleared its licence. +* **Server layering:** `kvitt-domain` has no I/O and depends only on `serde` (and `uuid` for ids); `kvitt-server` and `kvitt-import` depend on it, never the reverse. Money and split rules exist only in `kvitt-domain` and `:domain`, and both pass the same `contracts/test-vectors/*.json`. +* **No `unwrap()`/`expect()` in server non-test code** except in `main` start-up; no `unsafe`; errors map to the error codes of SPEC §14.3. --- @@ -117,7 +135,7 @@ Enforced by an architecture check inside `check` where possible. Fill in from SP * **No hard-coded colours, sizes or strings in feature modules.** Tokens from one design file; strings from resources, every supported language. * **No placeholder text** in previews or screenshots. * **No assumptions:** read a file before modifying or citing it. -* **Never commit:** `local.properties`, keystores, `.aar` or other vendor binaries, model files, client ids, tokens, personal data. +* **Never commit:** `local.properties`, keystores, `.aar` or other vendor binaries, model files, client ids, tokens, personal data, `google-services.json`, service-account keys, TLS keys, SQLite databases, backups, the Splitwise CSV, built APKs. --- diff --git a/README-agents.md b/README-agents.md index c85d51a..9aa021a 100644 --- a/README-agents.md +++ b/README-agents.md @@ -5,11 +5,13 @@ What is here and why: - `CLAUDE.md`: the working rules (orchestrator + Opus agents, worktrees, serial builds, gate, phone, owner steps). Fill in the marked blanks before the first plan. - `docs/HANDOFF.md`, `docs/decisions.md`: templates for the one handoff and the one decisions log. - `prompts/ORCHESTRATOR-prompt.md`: paste this to start an orchestrator session. -- `tools/gradle-serial.sh`: one Gradle build at a time across all worktrees (lock in /tmp/kvitt-gradle.lock). +- `tools/gradle-serial.sh`: one Gradle build at a time across all worktrees (lock in /tmp/kvitt-gradle.lock); runs the wrapper in `android/`. - `tools/clean-builds.sh`: delete build output in every checkout and worktree; `--dry-run` lists. -- `tools/install-git-hooks.sh` + `tools/git-hooks/pre-commit`: secrets guard + fast tests; adjust the Gradle task. -- `tools/dependency-report/latest.py`: version drift of `gradle/libs.versions.toml` (needs that file). -- `gradle.properties`, `gradle/gradle-daemon-jvm.properties`: the JDK 25 pin and caching flags. Merge into the generated project's files. +- `tools/install-git-hooks.sh` + `tools/git-hooks/pre-commit`: secrets guard + fast tests (`:core:model:test` in `android/`, + `cargo test -p kvitt-domain` in `server/`), each skipped until its project exists. +- `tools/dependency-report/latest.py`: version drift of `android/gradle/libs.versions.toml` (needs that file). +- `android/gradle.properties`, `android/gradle/gradle-daemon-jvm.properties`: the JDK 25 pin and caching flags. The Gradle + project lives in `android/`; merge these into its generated files, do not move them back to the root. - `.gitignore`: starter list of what never reaches git. - `.claude/memory-seed/`: the agent memories generalised from Ebb. They are already installed in `~/.claude/projects/-Users-jhag-projects-kvitt/memory/` (the folder Claude Code loads for `~/projects/kvitt`), diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..68bb4d9 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,2315 @@ +# Kvitt: specification + +Version **0.1** (2026-10-17). Schema version **1**. Sync protocol version **1**. Push payload version **1**. + +This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it. + +Conventions used throughout: + +* "Owner" is Jacob, the admin user. "Partner" is the other user. "User" is either of the two. +* "Öre" is 1/100 SEK. All money is an integer number of öre. +* "bp" is basis points. 10000 bp is 100 %. +* Timestamps are RFC 3339 UTC strings in exactly this form: `2026-10-17T06:00:00.000Z` (millisecond precision, literal `Z`). This form sorts correctly as a string. +* Dates without time are ISO 8601 `YYYY-MM-DD`. Months ("periods") are `YYYY-MM`. ISO weeks are `YYYY-Www` with the ISO week-numbering year (week starts Monday). +* Ids are UUIDv7, lowercase, hyphenated, 36 characters, stored and sent as strings. +* JSON keys are `snake_case`. Enum values are lowercase `snake_case` strings. +* "Must", "must not" and "may" are binding. + +--- + +## 1. Overview & goals + +Kvitt is a shared-expense app for one household of exactly two people living in Sweden: the owner and his spouse. It replaces Splitwise for them. It tracks who paid for what, who owes whom, and how the household spends money over time. + +It consists of: + +* A native Android app (Kotlin, Jetpack Compose) on two Samsung phones (Galaxy S26 and Galaxy S26+, Android 16). The app keeps a full copy of the household's data in a local Room database and works fully offline for expenses and settlements. +* A small Rust server (axum, SQLite) in Docker on a Raspberry Pi 3 at the owner's home, reachable at `https://icehack.asuscomm.com:8443`. The server stores the authoritative copy, syncs the phones, creates recurring expenses, sends push notifications, keeps an audit log and hosts the APK for in-app updates. + +Goals for v1: + +1. **G-1 Correct money.** Balances are exact to the öre and computed identically on phone and server, guaranteed by shared test vectors. +2. **G-2 Offline first.** Creating, editing and deleting expenses and settlements never waits for the network. Two weeks offline followed by a sync loses nothing. +3. **G-3 Flexible splitting.** Per-account default ratios (50/50 to start, configurable such as 60/40), custom ratios, exact amounts, or 100 % on one person. +4. **G-4 Clear settling.** One global balance, a per-account breakdown, and a settle-up flow that proposes how a payment is spread over accounts. +5. **G-5 Insight.** Monthly and weekly spending by category and by account, computed on the phone. +6. **G-6 Trust.** Every change to an expense or settlement is visible as a timeline of who did what. +7. **G-7 Low-maintenance hosting.** One docker-compose stack on a 1 GB Raspberry Pi, nightly off-site backups, no cloud services except Firebase Cloud Messaging and Google Drive for backups. +8. **G-8 Bilingual.** Every user-visible string exists in Swedish and English. The phone's locale decides: Swedish when the device language is Swedish, English otherwise. +9. **G-9 History.** Six years of Splitwise history can be imported once, repeatably without duplicates. + +--- + +## 2. Non-goals + +Not in v1. Agents must not build any of these, even partially: + +* Receipt photos or receipt scanning. Profile pictures are the only file upload. +* Any card, bank, Spendwise or other financial-institution integration. +* Multiple currencies or exchange rates. All amounts are SEK. (The importer has a one-off conversion option, §4 FR-17.) +* Joint accounts, shared cards or "paid from the shared pot" as a payer. The payer is always one of the two users. +* More than two users, more than one household, user deletion. +* Passwords, password reset, email, SMS, OAuth sign-in. +* Play Store distribution. +* CSV or any other export. +* A web client. The only HTML the server serves is the invite landing page (§4 FR-2). +* Server-side analytics or reporting endpoints. +* Debt simplification (meaningless with two users) and netting several accounts in opposite directions inside one settlement. +* Editing accounts, categories, recurring templates, profiles or invites while offline. +* Tablet, foldable or landscape-specific layouts. Portrait phone only; landscape must not crash. +* Widgets, Wear OS, iOS. +* In-app language picker. The phone locale decides. +* Comments or chat on expenses. + +--- + +## 3. Users & core user stories + +### 3.1 Roles + +| Role | Who | Count | Extra rights | +|---|---|---|---| +| `admin` | The owner | exactly 1 | Creates invites (partner and device), cancels invites, revokes any device | +| `member` | The partner | 0 or 1 | Revokes own devices other than the one in use | + +Everything else (expenses, settlements, accounts, categories, recurring templates) is open to both users with identical rights. Either user may edit or delete anything the other created. Each user edits only their own display name and picture. + +A user has one or more devices. v1 expects one phone each, but the model allows any number of devices per user without change. + +### 3.2 User stories + +* **US-1** As the owner I set up the server once from the command line and pair my phone with a one-time code. +* **US-2** As the owner I invite my partner with a link; my partner installs the app from the link, picks a name and a picture, and is paired. +* **US-3** As a user I add an expense in a few taps: amount, description, category, account; the split defaults to the account's ratio and the payer defaults to me. +* **US-4** As a user I record an expense my partner paid, so they do not have to. +* **US-5** As a user I split an expense differently: a custom ratio, exact amounts, or the whole amount on one of us. +* **US-6** As a user I see one number saying who owes whom overall, and the same per account. +* **US-7** As a user I settle up: I enter what I paid my partner, the app proposes which accounts it settles (oldest first), I adjust and confirm. +* **US-8** As a user I settle one account from that account's screen. +* **US-9** As a user I create expense accounts ("Vardag", "Huset", "Japan 2027"), give each a default ratio, and archive them when done. +* **US-10** As a user I set up a recurring expense (rent, streaming) that appears automatically each month. +* **US-11** As a user I see how much we spent per month or week, by category or by account, as a household total or as my share. +* **US-12** As a user I add and edit expenses on a plane or abroad without a connection, and everything syncs when I am back. +* **US-13** As a user I get a notification when my partner adds, changes or deletes something, and I can see in the expense who changed what. +* **US-14** As a user I am told when a new app version exists and install it from inside the app. +* **US-15** As the owner I import our Splitwise history once. +* **US-16** As the owner I can restore everything from a backup if the Pi dies. +* **US-17** As the owner I can remove a lost phone's access. + +--- + +## 4. Functional requirements (v1) + +Each requirement has acceptance criteria (AC). An AC is satisfied only when a test (unit, integration, screenshot or instrumented) or a documented manual check in the milestone's gate demonstrates it. Limits and rules referenced as §9.x are stated only there. + +### FR-1 Server bootstrap + +The server starts with an empty database. The household is created from the command line on the Pi. + +* AC-1.1 `kvitt-server bootstrap` on a database with no users creates an invite of kind `admin_setup`, prints its code (format §8.3), its landing link `https://icehack.asuscomm.com:8443/i/` (the base URL from `KVITT_PUBLIC_URL`), its expiry, and exits 0. +* AC-1.2 `kvitt-server bootstrap` on a database that has users prints `already initialized` to stderr and exits 2. It creates nothing. +* AC-1.3 Running `kvitt-server bootstrap` again before the first code is used cancels the previous `admin_setup` invite and prints a new one. +* AC-1.4 Accepting the `admin_setup` invite (FR-2) creates the `admin` user with the given display name and the first device, and returns a device token. +* AC-1.5 `GET /setup/status` returns `{"initialized": false}` before AC-1.4 and `{"initialized": true}` after. +* AC-1.6 `kvitt-server pair-device --user ` creates a `device` invite for an existing user and prints it like AC-1.1. This is the recovery path when the owner has lost every paired phone. + +### FR-2 Invites and pairing + +* AC-2.1 The admin creates a `partner` invite from Settings. It is refused with `household_full` when two users exist. Creating a new `partner` invite cancels any pending one. +* AC-2.2 The admin creates a `device` invite for either user from Settings (to pair a second phone). +* AC-2.3 An invite is single-use and expires 72 hours after creation. An expired, cancelled, used or unknown code is refused with `invite_invalid`; the response does not reveal which. +* AC-2.4 Accepting an invite takes the code, a device name and (for `admin_setup` and `partner`) a display name. It returns a bearer token, the user, the device and the household's users. For a `partner` invite it creates the `member` user. +* AC-2.5 `GET /i/` serves an HTML landing page in Swedish if the request's `Accept-Language` prefers `sv`, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to `kvitt://invite?server=&code=`, the code in groups of 4, and one sentence of instructions. It does not reveal whether the code is valid and contains no personal data. +* AC-2.6 The app opens from `kvitt://invite?server=…&code=…` and from `https://icehack.asuscomm.com:8443/i/` (unverified link, the user may get a chooser) directly into the pairing screen with both fields filled. +* AC-2.7 The pairing screen also accepts manual entry: a server URL (prefilled with `https://icehack.asuscomm.com:8443` in release builds) and the code. Codes are normalised per §8.3 before sending. +* AC-2.8 Release builds accept only `https://` server URLs. Debug builds also accept `http://` for `localhost`, `10.0.2.2` and private LAN addresses (`10.*`, `172.16.*`-`172.31.*`, `192.168.*`). +* AC-2.9 After pairing, the app runs a full sync (§14.4) and then asks for the notification permission (Android `POST_NOTIFICATIONS`). +* AC-2.10 A `partner` pairing continues to a profile step (display name prefilled from the accept call, optional picture) before the Home screen. +* AC-2.11 The admin sees pending invites (kind, created, expiry) in Settings and can cancel each. +* AC-2.12 More than 10 failed invite accepts from one client IP within 60 minutes yield `rate_limited` for the rest of that window. + +### FR-3 Devices and revocation + +* AC-3.1 Settings lists devices: the admin sees all devices of both users; a member sees their own. Each row shows device name, owner, paired date, last seen date, app version, and "This device" for the current one. +* AC-3.2 The admin can revoke any device except the one in use (`cannot_revoke_current_device`). A member can revoke their own devices except the one in use. +* AC-3.3 A revoked device's next request gets HTTP 401 `device_revoked`. The app then deletes its token, its local database and its cached pictures, and shows the "Device removed" screen with a "Pair again" button. +* AC-3.4 A revoked device never receives pushes again. +* AC-3.5 Devices are never deleted from the database; revocation sets `revoked_at`. + +### FR-4 Profiles + +* AC-4.1 Each user has a display name (limits §9.6) and an optional profile picture. +* AC-4.2 A user edits their own display name in Settings (online only). The change reaches the other phone through sync. +* AC-4.3 A user sets their own picture from the Android photo picker (no camera capture in v1) or removes it. Upload is online only. +* AC-4.4 The server accepts JPEG, PNG and WebP up to 5 MiB and at most 8000 × 8000 pixels, applies EXIF orientation, centre-crops to a square, re-encodes as JPEG quality 85 at 512 × 512 and 128 × 128, strips all metadata, and increments the user's `picture_version`. Other input is refused with `image_invalid`, `unsupported_media_type` or `payload_too_large`. +* AC-4.5 Where no picture exists, the app shows the user's initials (first letter of the first two words of the display name, uppercase) on a colour derived from the user id (UX sprint defines the two colours). +* AC-4.6 The app caches pictures on disk (Coil) keyed by user id and `picture_version`, so they show offline. + +### FR-5 Household gate + +* AC-5.1 Until the partner has joined, the admin's Home screen shows "Waiting for your partner" with the "Invite partner" action, and creating accounts, expenses, settlements and recurring templates is not offered. +* AC-5.2 The server refuses account creation, recurring template creation and any expense or settlement mutation with `household_incomplete` while fewer than two users exist. (The rule exists because every split refers to both users.) + +### FR-6 Expense accounts + +* AC-6.1 Either user creates an account (online) with a name and a default split ratio. The editor starts at 50/50. Ratios are entered as percent with up to two decimals per user, the second user's value always being 100 minus the first; stored as bp (§9.1). +* AC-6.2 Account names are unique among non-deleted accounts, compared case-insensitively after trimming (Unicode lowercase). A duplicate is refused with `name_taken`. +* AC-6.3 Either user renames an account or changes its default ratio (online). A ratio change affects only expenses created afterwards; existing expenses keep the shares they were saved with. +* AC-6.4 Either user archives or unarchives an account (online), even with a non-zero balance. Archived accounts are hidden from the account pickers for new expenses and recurring templates, shown in a collapsed "Archived" section on Home, still count toward the global balance, still appear in analytics and can still be settled. +* AC-6.5 Either user deletes an account (online) only when no non-deleted expense, no allocation of a non-deleted settlement and no non-deleted recurring template references it. Otherwise `account_not_empty`. Deletion is a soft delete (tombstone) so phones learn of it through sync. +* AC-6.6 Accounts are listed by `created_at` ascending, the order used by the settlement proposal (§9.4). +* AC-6.7 The account create and edit actions are disabled with a "Needs connection" hint when the phone is offline (§9.9). + +### FR-7 Categories + +* AC-7.1 One global, editable category list. A category has a name, an icon token (§9.7) and a colour token (§9.7). +* AC-7.2 The database is seeded with exactly these ten default categories (fixed ids, §14.1), each shown with its localised default name until a user renames it: + +| `default_key` | English name | Swedish name | `icon` | `color` | `sort_order` | +|---|---|---|---|---|---| +| `groceries` | Groceries | Mat & hushåll | `shopping_cart` | `green` | 10 | +| `restaurants` | Restaurants | Restaurang & café | `restaurant` | `orange` | 20 | +| `home` | Home | Hem | `home` | `brown` | 30 | +| `utilities` | Utilities | Räkningar | `bolt` | `yellow` | 40 | +| `transport` | Transport | Transport | `directions_car` | `blue` | 50 | +| `travel` | Travel | Resor | `flight` | `teal` | 60 | +| `entertainment` | Entertainment | Nöje | `movie` | `purple` | 70 | +| `health` | Health | Hälsa | `medical_services` | `red` | 80 | +| `shopping` | Shopping | Shopping | `shopping_bag` | `pink` | 90 | +| `other` | Other | Övrigt | `category` | `grey` | 100 | + +* AC-7.3 Either user creates a category (online) with a name, icon and colour. New categories get `sort_order` = current maximum + 10. +* AC-7.4 Either user renames, re-icons, recolours or reorders a category (online). Renaming a default category stores the custom name; "Reset name" (only for default categories) clears it so the localised name shows again. +* AC-7.5 Either user archives or unarchives a category. Archived categories are not offered for new expenses or templates but stay on existing ones and in analytics. +* AC-7.6 A category is deletable only if no non-deleted expense or non-deleted recurring template uses it (`category_in_use` otherwise). Default categories (with a `default_key`) cannot be deleted (`category_is_default`); they can be archived. Deletion is a soft delete. +* AC-7.7 Category names have no uniqueness rule. + +### FR-8 Expenses + +* AC-8.1 An expense has: account, amount (öre), description, category, date, payer, split, notes, created-by, last-modified-by, timestamps. Limits in §9.6. +* AC-8.2 Either user creates, edits or deletes any expense, online or offline. The id is a client-generated UUIDv7. +* AC-8.3 The payer is either user. The editor defaults the payer to the current user. +* AC-8.4 The date is any date within §9.6 limits, past or future. It defaults to today in the phone's time zone. This date is the only date used in analytics and lists. +* AC-8.5 Split modes (enum §9.1): `default` (the account's default ratio, copied into the expense when saved), `ratio` (a custom ratio), `exact` (exact öre per user), `full` (the whole amount borne by one chosen user, who may be the payer or not). Share computation per §9.1. +* AC-8.6 The editor defaults: account = the account last used on this phone if still active, else the oldest active account; category = the category last used in that account on this phone if still active, else `other`; split mode `default`. +* AC-8.7 Exact mode: the user types one user's share; the other user's share is the amount minus it and is shown read-only. A typed share larger than the amount blocks saving with an inline error. +* AC-8.8 Changing the account of an expense in mode `default` re-copies the new account's ratio. In other modes the split is kept. +* AC-8.9 Deleting asks for confirmation and is a soft delete. Deleted expenses disappear from lists, balances and analytics on both phones after sync. +* AC-8.10 Expense lists show, per row: category icon and colour, description, date, amount, and a one-line effect from the viewer's side ("You lent 205,00 kr", "You borrowed 205,00 kr", "Not involved" for `full` on the payer, matching §13 copy). Rows are ordered by date descending, then `created_at` descending. +* AC-8.11 An expense created from a recurring template is an ordinary expense in every way (editable, deletable) and shows "Recurring" with the template name in the detail view. + +### FR-9 Recurring expenses + +* AC-9.1 Either user creates, edits, pauses, resumes or deletes a recurring template (online). A template holds the expense fields (account, amount, description, category, payer, split mode and shares, notes) plus `day_of_month` (1-31), `start_period` and an optional `end_period`. +* AC-9.2 The server creates one expense per template per period at 06:00 Europe/Stockholm on the template's day. Days 29, 30 and 31 clamp to the last day of shorter months (day 31 in April creates on 30 April; day 29 in February 2027 creates on 28 February). +* AC-9.3 Generation rules (catch-up after downtime, idempotency, split resolution) are in §9.5. +* AC-9.4 Editing a template changes only expenses generated afterwards. +* AC-9.5 A generated expense that a user deletes is not recreated. +* AC-9.6 Each generated expense writes an audit entry with `source` = `recurring` and sends `recurring_expense_created` to every device of both users. +* AC-9.7 The template list shows each template's description, amount, account, day of month, paused state and next occurrence date (computed by the server, `next_occurrence_date`). +* AC-9.8 The editor's default `start_period` is the current month if `day_of_month` is today or later in the phone's time zone, else the next month. The user may choose a `start_period` up to 12 months in the past (backfill) and any month in the future. + +### FR-10 Balances + +* AC-10.1 Home shows one global balance between the two users from the viewer's side: " owes you X", "You owe X" or "All settled up". +* AC-10.2 Home lists every active account with its balance in the same wording, and archived accounts in a collapsed section. +* AC-10.3 The account screen shows that account's balance and its expenses and settlements interleaved by date. +* AC-10.4 Balances are computed on the phone from the local database with the domain function of §14.5, include pending (unsynced) local changes, exclude deleted rows, and update immediately after a local write. +* AC-10.5 The global balance equals the sum of the per-account balances, archived accounts included. + +### FR-11 Settlements + +* AC-11.1 A settlement is a payment from one user to the other with an amount, a date, an optional note and one or more per-account allocations. Limits in §9.6. +* AC-11.2 The allocations' sum must equal the settlement amount. Each allocation is positive. An account appears at most once. +* AC-11.3 Global settle-up (from Home): the direction defaults to "the user who owes pays", the amount defaults to the absolute global balance (or empty when it is zero). The app computes the proposal with `propose_allocation` (§9.4, §14.5) and shows one editable row per proposed account plus "Add account" to add any other account (active or archived). The confirm button is enabled only when the allocations sum to the amount; the remaining difference is shown live. +* AC-11.4 Per-account settle-up (from the account screen) creates a settlement with exactly one allocation for that account; the amount defaults to the absolute account balance and the direction to "the user who owes in this account pays". +* AC-11.5 An allocation larger than the payer's current debt in that account is allowed (over-settling flips the account's sign) and is marked with a warning per row ("This settles more than is owed in "). The confirm dialog repeats the warning. +* AC-11.6 Settlements can be created, edited and deleted (soft delete) offline, by either user, like expenses. When editing, the proposal and warnings are computed with the edited settlement excluded from the balances. +* AC-11.7 A settlement row in lists shows " paid X" and the account count when more than one allocation exists. +* AC-11.8 The server's `POST /settlements/proposal` returns exactly what the phone's `propose_allocation` returns for the same server state (verified by shared vectors). The phone always computes locally; the endpoint exists for parity tests and server-side tooling. + +### FR-12 Audit log and timeline + +* AC-12.1 The server appends an audit entry for every accepted create, update and delete of an expense or settlement, from the app, the scheduler or the importer. Entries are never updated or deleted. +* AC-12.2 An entry records entity type, entity id, action, source, actor user, actor device (null for scheduler and importer), client time of the change, server time, and a field diff (§14.4.7). +* AC-12.3 Audit entries reach both phones through sync pull. +* AC-12.4 The expense and settlement detail views show a timeline, newest last, one line per entry, such as "Anna created this · 3 Oct 14:02", "You changed the amount from 400,00 kr to 412,00 kr · 3 Oct 18:30", "Anna deleted this". Diffs show field names in the UI language; ids are resolved to names (accounts, categories, users). +* AC-12.5 A pending local change not yet synced shows as a last timeline line "Your change is waiting to sync". + +### FR-13 Push notifications + +* AC-13.1 When the server accepts a create, update or delete of an expense or settlement from a device of user U, it sends a push to every non-revoked device of the other user that has an FCM token. Types and payload in §14.6. +* AC-13.2 Recurring generation sends to every non-revoked device of both users. +* AC-13.3 The importer sends no pushes. +* AC-13.4 One `POST /sync` that produces more than 3 notifiable changes sends one `sync_batch` push instead of one per change. +* AC-13.5 The app shows the notification only after it has synced the change, built from local data in the phone's language (title and body copy in §13). If that sync fails within 20 seconds, it shows a generic text without amounts or descriptions. +* AC-13.6 Tapping a notification opens the detail view of the changed expense or settlement, or Home for `sync_batch` and deletions. +* AC-13.7 The app registers its FCM token after pairing and whenever Firebase rotates it (`PUT /devices/current/push-token`). +* AC-13.8 No personal content (names, amounts, descriptions, notes) is placed in the FCM payload. + +### FR-14 Analytics + +* AC-14.1 Analytics are computed on the phone from Room with the pure function of §14.5.4; the server has no reporting endpoint. They work offline and include pending local changes. +* AC-14.2 Range presets: `this_month`, `last_3_months`, `last_6_months`, `last_12_months`, `this_year`, `all_time`, `custom` (start and end date pickers). "Last N months" means the current month and the N-1 months before it, whole months. Default `last_6_months`. +* AC-14.3 Granularity: `month` or `week` (ISO, Monday start). Default `month`. +* AC-14.4 Measure: `household` (sum of full expense amounts, whoever paid) or `my_share` (sum of the viewer's shares). Default `household`. +* AC-14.5 Group by: `category` or `account`. Default `category`. +* AC-14.6 Charts: (1) spend over time, a stacked bar per bucket coloured by group; (2) category breakdown for the range, horizontal bars sorted by amount descending with amount and percent; (3) account breakdown for the range, same form. A total for the range is shown above the charts. Settlements are never counted as spend. +* AC-14.7 Every bucket in the range appears, empty ones as zero. Buckets at the edges of a custom range count only dates inside the range. +* AC-14.8 Tapping a bar or a breakdown row opens a list of the expenses behind that number. +* AC-14.9 Deleted expenses are excluded; archived accounts and categories are included. +* AC-14.10 Percentages are rounded half-up to one decimal and may not sum to exactly 100. + +### FR-15 Offline and sync + +* AC-15.1 The phone holds a full local copy of: users, accounts, categories, recurring templates, expenses, settlements and audit entries (Room, §14.2). +* AC-15.2 Expenses and settlements can be created, edited and deleted offline. Accounts, categories, recurring templates, profile, pictures, invites and devices need the server; their actions are disabled offline with "Needs connection". +* AC-15.3 Sync runs on app start and every return to the foreground, 2 seconds after the last local write (debounced), every 15 minutes in the background through WorkManager (network constraint), and on every FCM data message. Only one sync runs at a time. +* AC-15.4 The app shows a "Last synced" relative time and a pending-changes count on Home, and a sync status screen (§13). +* AC-15.5 Conflicts resolve by last-write-wins per row on the client `updated_at`, deletes are final, and rejected changes are shown to the user (§14.4). +* AC-15.6 A phone offline for 14 days with 200 local changes syncs all of them on reconnect (integration test with a scripted clock). + +### FR-16 In-app update + +* AC-16.1 The server hosts the latest signed release APK and its metadata (§10.6). +* AC-16.2 Release builds call `GET /apk/latest` on every cold start and at most once per 6 hours while resumed. When `version_code` is greater than the installed one, Home shows an "Update available" banner with the version name. +* AC-16.3 "Update" downloads the APK to the app's cache, verifies the size and SHA-256 from the metadata, and installs through `PackageInstaller`. The first time, Android asks the user to allow "Install unknown apps" for Kvitt; the app explains this beforehand. +* AC-16.4 A verification failure deletes the file and shows "Download failed, try again". +* AC-16.5 Debug builds never check for updates. + +### FR-17 Splitwise import + +* AC-17.1 A one-off CLI `kvitt-import` (crate `kvitt-import`) imports Splitwise CSV exports, one file per Splitwise group, each into a Kvitt account named on the command line. Details §10.8. +* AC-17.2 Re-running the same import creates nothing new (stable import key, §10.8). +* AC-17.3 Splitwise categories map to Kvitt categories by the fixed table in §10.8; unknown names map to `other` and are listed in the summary. +* AC-17.4 Splitwise payments become settlements with one allocation. +* AC-17.5 `--dry-run` prints the summary (rows read, expenses, settlements, skipped duplicates, unknown categories, non-SEK rows) and writes nothing. +* AC-17.6 Imported rows produce audit entries with `source` = `import` and no pushes. + +### FR-18 Backups and restore + +* AC-18.1 Every night at 03:30 Europe/Stockholm the backup sidecar writes a consistent SQLite snapshot and a pictures archive, gzips them and uploads them to the owner's Google Drive folder with rclone. Nothing on Drive is ever deleted or overwritten. +* AC-18.2 A restore drill following §10.9 restores a backup on the Pi and both phones then sync correctly, documented in `docs/validation/restore-drill.md`. + +### FR-19 Localisation and formatting + +* AC-19.1 Every user-visible string is a resource in `values/strings.xml` (English, the fallback) and `values-sv/strings.xml` (Swedish). No string literal appears in feature code. A lint or test in the Android gate fails on a key missing from either file. +* AC-19.2 Money formatting per §9.8, dates per §9.8. +* AC-19.3 Category default names and every enum shown to users have strings in both languages. + +### FR-20 Health + +* AC-20.1 `GET /health` returns status, server version, schema version and a database check, unauthenticated, in under 100 ms on the Pi. +* AC-20.2 The docker healthcheck of the `server` service uses it. + +--- + +## 5. Data model + +This section explains the entities and their meaning. The exact server DDL is §14.1, the phone's Room entities §14.2, the wire JSON §14.3. Limits are in §9.6. + +### 5.1 Entity overview + +| Entity | Id origin | Synced to phones | Written by phones | Notes | +|---|---|---|---|---| +| User | server | yes (pull) | REST only, own profile | Exactly two in a complete household | +| Device | server | no | REST only | One per paired app install | +| Invite | server | no | REST only, admin | Single-use pairing code | +| Account | server | yes (pull) | REST only | Expense account, like a Splitwise group | +| Account default share | with account | yes (inside account) | REST only | One row per user, sums to 10000 bp | +| Category | server (defaults seeded with fixed ids) | yes (pull) | REST only | Global list | +| Recurring template | server | yes (pull) | REST only | Monthly generator | +| Recurring template split | with template | yes (inside template) | REST only | Shares for modes `ratio`, `exact`, `full` | +| Expense | client (UUIDv7) | yes (pull and push) | sync push | Soft delete | +| Expense split | with expense | yes (inside expense) | sync push | One row per user | +| Settlement | client (UUIDv7) | yes (pull and push) | sync push | Soft delete | +| Settlement allocation | with settlement | yes (inside settlement) | sync push | One row per account | +| Audit entry | server | yes (pull) | never | Append-only | +| Sync mutation record | server | no | never | Idempotency of pushes | +| Sync sequence, meta | server | no (epoch is sent in pull) | never | Global `server_version` counter, `sync_epoch` | + +Local-only phone tables: `sync_state` and `sync_rejections` (§14.2). + +### 5.2 Ownership of fields + +* **`server_version`**: an integer from one global, strictly increasing sequence. Every insert or update of a synced row (users, accounts, categories, recurring templates, expenses, settlements, audit entries) takes the next value. Child rows (shares, splits, allocations) have none; a change to a child bumps its parent. Phones never set it. +* **`created_at`, `updated_at`** of expenses and settlements are set by the phone's clock at the time of the local write and are the last-write-wins key. For server-written rows (all others, and expenses created by the scheduler or importer) they are the server clock. +* **`created_by_user_id`** is set by the server from the authenticated device's user when it first stores the row, never taken from the client. `updated_by_user_id` and `updated_by_device_id` are set by the server on every accepted write. +* **`deleted_at`** marks a tombstone. Tombstones are kept forever on both server and phone and are synced like any other row. +* **`archived_at`** (accounts, categories) hides an item from pickers; it never affects money. + +### 5.3 Money + +* Amounts are integer öre in signed 64-bit integers (Kotlin `Long`, Rust `i64`, SQLite `INTEGER`). +* An expense's shares, one per user, always sum exactly to its amount. +* Ratios are integer bp per user summing to 10000. +* A user's **net position** in an account is: what they paid for expenses, minus their shares, plus settlement allocations they paid, minus settlement allocations they received. The two users' net positions always sum to zero. A positive net means the other user owes them that much. Formal definition §9.3. + +### 5.4 Relationships + +* An expense belongs to one account and one category, has one payer and exactly two splits (one per user), and optionally references the recurring template and period it was generated for. +* A settlement has a payer (`from_user_id`), a recipient (`to_user_id`) and one or more allocations, each to one account. +* An audit entry references one expense or settlement by type and id. +* A device belongs to one user. An invite of kind `device` targets one user. +* References from synced rows point only at rows that are never hard-deleted, so a reference always resolves on the server. On the phone a reference may be briefly unresolved during a pull (§14.2); the UI shows such a row only after its references resolve. + +### 5.5 What is not stored + +* No balances, totals or analytics are stored anywhere; they are always computed from rows. +* No exchange rates, no currencies (SEK is implicit). +* No passwords. Tokens and invite codes are stored only as SHA-256 hashes. + +--- + +## 6. Architecture & stack + +### 6.1 System view + +``` + Samsung S26 (owner) Samsung S26+ (partner) + ┌──────────────────┐ ┌──────────────────┐ + │ Kvitt app │ │ Kvitt app │ + │ Compose UI │ │ │ + │ domain (pure) │ │ │ + │ Room (full copy) │ │ │ + └───────┬──────────┘ └────────┬─────────┘ + │ HTTPS :8443 (bearer token) │ + ▼ ▼ + router: external 8443 ──► Kvitt Pi :8443 (80/443 go to the Home Assistant Pi) + ┌───────────────────────── Raspberry Pi 3, docker compose ─────────────────────────┐ + │ caddy (TLS, :8443) ──► server (axum, :8080) ──► /data/kvitt.sqlite (WAL) │ + │ cert-sync (ssh pull from HA Pi nightly, reload caddy) │ + │ backup (sqlite .backup + gzip nightly ──► rclone ──► Google Drive) │ + └───────────────────────────────────────────────────────────────────────────────────┘ + │ FCM HTTP v1 (service account) ▲ Pi pulls images + ▼ │ + Firebase Cloud Messaging ──► phones owner's M4 Mac: build linux/arm64 images ──► local registry +``` + +### 6.2 Android app + +| Concern | Choice | Why | +|---|---|---| +| Language | Kotlin 2.x, latest stable | Owner's requirement | +| UI | Jetpack Compose, Material 3; Material 3 Expressive components where they are stable | Native, modern, screenshot-testable | +| Min / target SDK | `minSdk` 36 (Android 16); `targetSdk` and `compileSdk` the latest stable API at implementation time | Both phones run Android 16; no legacy paths | +| Local DB | Room with KSP, exported schema | Full offline copy; migrations with tests | +| DI | Hilt | Standard, works with WorkManager and ViewModels | +| HTTP | Ktor client (OkHttp engine), kotlinx.serialization JSON | Kotlin-first, multiplatform-style DTOs | +| Background | WorkManager (periodic 15 min sync, expedited sync) | Survives process death, respects Doze | +| Dates | kotlinx-datetime | Allowed in pure modules (no `java.time`) | +| Images | Coil (Compose) with an auth header interceptor | Disk cache for offline avatars | +| Charts | Vico (Compose). If Vico's current release cannot draw a stacked bar chart with Material 3 theming, hand-roll the three charts with Compose `Canvas` in `:core:design` | Mature Compose chart library; fallback is small | +| Push | Firebase Cloud Messaging, only inside `:adapter:fcm` | Free tier; vendor isolated per `CLAUDE.md` | +| Secrets | Android Keystore: an AES-256-GCM key (non-exportable) encrypts the device token; ciphertext in a private DataStore file | `EncryptedSharedPreferences` is deprecated | +| Build | Gradle with version catalog `android/gradle/libs.versions.toml`, Gradle daemon on JDK 25 (pinned), bytecode target Java 17 | Existing pin | +| Tests | JUnit, Compose screenshot tests (Roborazzi or the Android Gradle Plugin's Compose Preview Screenshot Testing, whichever is stable at implementation time; record the choice in decisions), Room migration tests, Ktor `MockEngine` | No mocking libraries (`CLAUDE.md`) | + +Module graph (directory `android/`): + +| Module | Kind | Contents | May depend on | +|---|---|---|---| +| `:core:model` | pure Kotlin/JVM | Domain types (§14.5.1), `*State` classes, repository interfaces (§14.2.4) | kotlin, kotlinx | +| `:domain` | pure Kotlin/JVM | Split, balance, allocation, analytics functions (§14.5); test-vector tests | `:core:model` | +| `:core:design` | Android library | Theme, tokens, typography, shared components (money text, avatar, category chip, balance header, charts), `MoneyFormatter`, `DateFormatter` | `:core:model` | +| `:core:fakes` | Android library, used only by tests, screenshot tests and debug previews | Scenario-driven fakes of every repository interface (they call `:domain` for computed values so fakes never disagree with the real rules) | `:core:model`, `:domain` | +| `:data` | Android library | Room DB, DAOs, entities, Ktor API client and DTOs, sync engine, WorkManager workers, token store, repository implementations, APK updater | `:core:model`, `:domain` | +| `:adapter:fcm` | Android library | `FirebaseMessagingService`, token registration, maps messages to `PushEvent` (`:core:model`) | `:core:model` | +| `:feature:onboarding` | Android library | Pairing, partner profile step, waiting for partner, device removed | `:core:model`, `:core:design` | +| `:feature:home` | Android library | Home (global balance, accounts), sync status, update banner | `:core:model`, `:core:design` | +| `:feature:account` | Android library | Account detail, account editor | `:core:model`, `:core:design` | +| `:feature:expense` | Android library | Expense editor, expense detail and timeline | `:core:model`, `:core:design` | +| `:feature:settle` | Android library | Settle up (global and per account), settlement detail and timeline | `:core:model`, `:core:design` | +| `:feature:analytics` | Android library | Analytics screen and drill-down list | `:core:model`, `:core:design` | +| `:feature:settings` | Android library | Settings hub, profile, categories, recurring templates, devices, invites, about | `:core:model`, `:core:design` | +| `:app` | Android application | `MainActivity`, navigation graph, Hilt wiring, deep links, notification channel and building, `Application` class | everything | + +Features never call `:domain` directly: repositories return computed state (balances, previews, proposals), implemented in `:data` with `:domain`. The architecture check (Gradle task in `check`, owned by milestone M2b) fails the build on any other dependency edge and on `java.time`, `java.io` or `android.*` imports in `:core:model` and `:domain`. + +### 6.3 Server + +| Concern | Choice | Why | +|---|---|---| +| Language | Rust stable, edition 2024 | Owner's requirement; small RSS on a 1 GB Pi | +| HTTP | axum on tokio (multi-thread runtime, 2 worker threads) | Mature, small | +| DB | SQLite via sqlx, WAL mode, migrations embedded with `sqlx::migrate!` | One file, easy backup, ample for two users | +| Serialization | serde, serde_json | Standard | +| OpenAPI | utoipa generates the document from code; `contracts/openapi.yaml` is the committed contract; a test asserts the generated document equals it (§14.3) | One contract, checked by the gate | +| Logging | tracing, tracing-subscriber, JSON lines to stdout | Docker log rotation; no personal content (§8.7) | +| Time | chrono with chrono-tz for Europe/Stockholm | DST-correct scheduling | +| Ids | uuid crate, v7 | Same format as phones | +| Images | image crate (decode JPEG/PNG/WebP, encode JPEG) | Pure Rust, no system libs | +| HTTP client (FCM) | reqwest with rustls | No OpenSSL on the image | +| FCM auth | Service-account JWT (RS256, jsonwebtoken crate) exchanged at `https://oauth2.googleapis.com/token` for an access token with scope `https://www.googleapis.com/auth/firebase.messaging`, cached until 5 minutes before expiry | HTTP v1 API needs OAuth2 | +| Scheduler | A tokio task inside `kvitt-server` (no extra crate) | One process, simple | + +Cargo workspace (directory `server/`): + +| Crate | Kind | Contents | +|---|---|---| +| `kvitt-domain` | library, no I/O | Money types, split, balance, allocation, settlement validation (§14.5), test-vector tests. Depends only on `serde`, `serde_json` (tests) and `uuid` | +| `kvitt-server` | binary `kvitt-server` | Config, DB, migrations, HTTP API, auth, sync, audit, push, scheduler, pictures, APK, CLI subcommands (§10.4) | +| `kvitt-import` | binary `kvitt-import` | Splitwise CSV importer (§10.8). Depends on `kvitt-domain` and reuses `kvitt-server` as a library for DB access and audit writing (`kvitt-server` exposes a `lib.rs`) | + +### 6.4 Why these choices + +* **Phone computes balances and analytics.** Offline is a hard requirement and the data set is small (six years of two-person history is a few thousand rows). Computing on the phone keeps the server dumb and the Pi idle. +* **SQLite over Postgres.** Postgres alone would use a large share of the Pi's 1 GB. SQLite in WAL mode handles two users trivially and backs up as one file. +* **Global `server_version` sequence.** One integer cursor per phone is the simplest correct incremental sync. SQLite's single writer, with every write in a `BEGIN IMMEDIATE` transaction, makes versions commit in order, so a reader never sees a gap that later fills. +* **Last-write-wins on client time.** Two people rarely edit the same expense concurrently; LWW with an audit trail lets them see and fix the rare collision. Clock-skew protection is in §14.4. +* **Push carries ids only.** Personal data does not pass through Google; the phone syncs and builds the text locally. +* **No Play Store.** Two users; a self-hosted APK with `PackageInstaller` is enough. +* **Domain rules twice, one test suite.** Kotlin and Rust implement the same pure functions and must pass the same JSON vectors, so phone and server agree to the öre. + +--- + +## 7. API outline + +Base URL: `https://icehack.asuscomm.com:8443` (the value of `KVITT_PUBLIC_URL`). All paths below are relative to it. There is no path prefix and no API version in the path; the protocol version travels in a header (§14.3.1). + +| # | Method | Path | Auth | Purpose | +|---|---|---|---|---| +| E-1 | GET | `/health` | none | Liveness and DB check | +| E-2 | GET | `/setup/status` | none | Whether the household has been bootstrapped | +| E-3 | POST | `/invites/accept` | none | Pair a device with an invite code (all three invite kinds) | +| E-4 | GET | `/i/{code}` | none | Invite landing page (HTML) | +| E-5 | GET | `/apk/latest` | none | Latest APK metadata | +| E-6 | GET | `/apk/files/{file_name}` | none | APK download | +| E-7 | GET | `/me` | device | Current user, device and household users | +| E-8 | PATCH | `/me` | device | Change own display name | +| E-9 | PUT | `/me/picture` | device | Upload own picture | +| E-10 | DELETE | `/me/picture` | device | Remove own picture | +| E-11 | GET | `/users/{user_id}/picture` | device | Download a user's picture | +| E-12 | POST | `/invites` | admin | Create a `partner` or `device` invite | +| E-13 | GET | `/invites` | admin | List pending invites | +| E-14 | DELETE | `/invites/{invite_id}` | admin | Cancel an invite | +| E-15 | GET | `/devices` | device | List devices (admin: all; member: own) | +| E-16 | DELETE | `/devices/{device_id}` | device | Revoke a device | +| E-17 | PUT | `/devices/current/push-token` | device | Set or clear this device's FCM token | +| E-18 | POST | `/accounts` | device | Create account | +| E-19 | PATCH | `/accounts/{account_id}` | device | Rename, change default ratio | +| E-20 | POST | `/accounts/{account_id}/archive` | device | Archive | +| E-21 | POST | `/accounts/{account_id}/unarchive` | device | Unarchive | +| E-22 | DELETE | `/accounts/{account_id}` | device | Soft delete when empty | +| E-23 | POST | `/categories` | device | Create category | +| E-24 | PATCH | `/categories/{category_id}` | device | Rename, icon, colour, order, reset name | +| E-25 | POST | `/categories/{category_id}/archive` | device | Archive | +| E-26 | POST | `/categories/{category_id}/unarchive` | device | Unarchive | +| E-27 | DELETE | `/categories/{category_id}` | device | Soft delete when unused and not default | +| E-28 | POST | `/recurring-templates` | device | Create template | +| E-29 | PATCH | `/recurring-templates/{template_id}` | device | Edit, pause, resume | +| E-30 | DELETE | `/recurring-templates/{template_id}` | device | Soft delete | +| E-31 | GET | `/sync` | device | Pull changes since a cursor | +| E-32 | POST | `/sync` | device | Push local expense and settlement changes | +| E-33 | POST | `/settlements/proposal` | device | Server-side allocation proposal | + +Reads of accounts, categories, templates, expenses, settlements and audit entries happen only through `GET /sync`. Every REST write that changes a synced row returns that row in its wire form (§14.3.3), with its new `server_version`, so the phone can store it immediately. + +Request and response shapes, error codes and status codes: §14.3. The OpenAPI 3.1 file `contracts/openapi.yaml` is written by milestone M1 from §14.3, which is binding. + +--- + +## 8. Auth & security + +### 8.1 Threat model + +Two trusted users. The server is on the public internet on port 8443. Threats in scope: random internet scanners, a stolen or lost phone, leaked invite links, a compromised backup location, accidental personal data in logs or push payloads. Out of scope: a malicious partner, an attacker with physical access to the Pi. + +### 8.2 Device tokens + +* On a successful invite accept the server generates 32 random bytes from the OS CSPRNG and returns the token `kvt_` + base64url without padding of those bytes (47 characters total). +* The server stores only `token_hash` = lowercase hex SHA-256 of the full token string. +* Tokens do not expire. They stop working only on revocation. +* Every authenticated request sends `Authorization: Bearer `. The server hashes it, looks up a device with that hash and `revoked_at IS NULL`, and loads the user. Missing or unknown token: 401 `unauthorized`. Revoked: 401 `device_revoked`. +* The server updates `devices.last_seen_at` and `devices.app_version_code` at most once per hour per device. +* On the phone the token is encrypted with an Android Keystore AES-256-GCM key (alias `kvitt_token_key`, `setUserAuthenticationRequired(false)`, no export) and the ciphertext and IV are stored in DataStore file `auth.preferences_pb`. The server base URL, user id and device id are stored next to it in plain text. +* The token is never logged, never put in a URL, never included in a crash report or screenshot. + +### 8.3 Invite codes + +* 12 characters from the Crockford base32 alphabet `0123456789ABCDEFGHJKMNPQRSTVWXYZ` (60 bits), generated from the CSPRNG. +* Displayed as three groups of four separated by `-`, as `7K3M-QX9D-2HPA`. URLs carry the 12 characters without hyphens. +* Normalisation before hashing or sending: uppercase; remove `-` and spaces; replace `O` with `0` and `I` and `L` with `1`. A normalised code that is not exactly 12 alphabet characters is `invite_invalid`. +* Stored as `code_hash` = lowercase hex SHA-256 of the normalised code. +* Single-use, 72-hour expiry, cancellable. Rate limit in AC-2.12. + +### 8.4 Authorization rules + +| Action | admin | member | +|---|---|---| +| All expense, settlement, account, category, recurring template operations | yes | yes | +| Edit own display name and picture | yes | yes | +| Edit the other user's display name or picture | no | no | +| Create, list, cancel invites | yes | no (`forbidden`) | +| List devices | all | own only | +| Revoke a device | any except current | own except current | + +### 8.5 Transport + +* TLS terminates in Caddy on port 8443 with the Let's Encrypt certificate for `icehack.asuscomm.com` (§10.5). TLS 1.2 minimum, Caddy defaults otherwise. +* Release builds' network security config: cleartext disabled, system trust anchors only, unless the fallback of §10.5.3 is active, in which case the pinned Caddy root replaces the system anchors for `icehack.asuscomm.com` only. +* Debug builds additionally allow cleartext to the hosts listed in AC-2.8. +* The server trusts `X-Forwarded-For` only from the Caddy container (the request's peer address on the compose network) to get the client IP for rate limiting. + +### 8.6 Rate limiting and input limits + +* Unauthenticated endpoints: at most 60 requests per minute per client IP, then 429 `rate_limited` with a `Retry-After` header in seconds. +* Authenticated endpoints: at most 600 requests per minute per device. +* Invite accept failures: AC-2.12. +* Request bodies: 1 MiB maximum, except `PUT /me/picture` at 5 MiB. Larger: 413 `payload_too_large`. +* JSON strings are validated for length after trimming leading and trailing whitespace; control characters other than newline in `notes` are refused with `validation_failed`. +* The rate limiter is in memory (resets on restart); no table. + +### 8.7 Privacy and logging + +* Server logs contain: timestamp, level, request method, route template (not the raw path with codes), status, latency, device id, error code. They never contain tokens, invite codes, display names, descriptions, notes, amounts or picture bytes. +* The phone never logs personal content, debug builds included (`CLAUDE.md`). Log tags use the prefix `Kvitt`. +* FCM payloads carry ids and types only (§14.6). +* Backups on Google Drive are not encrypted in v1; they go to the owner's own Drive folder, reachable only by his Google account. + +### 8.8 Phone hardening + +* `android:allowBackup="false"` and `android:dataExtractionRules` excluding everything, so neither Google backup nor device transfer copies the token or database. +* Exported components: only `MainActivity` (launcher and deep-link intent filters) and the FCM service as Firebase requires. +* On `device_revoked` the app wipes local data as in AC-3.3. + +### 8.9 Server hardening + +* The server container runs as uid/gid `10001`, with a read-only root filesystem, `/data` as the only writable volume, `/secrets` read-only, no Linux capabilities, `no-new-privileges`. +* Only Caddy publishes a port (8443). The server, Caddy's admin API and the sidecars are reachable only on the internal compose network. +* SQLite `PRAGMA foreign_keys = ON`, `journal_mode = WAL`, `synchronous = NORMAL`, `busy_timeout = 5000` on every connection. + +--- + +## 9. Edge cases & business rules + +### 9.1 Splits + +Split modes: + +| `split_mode` | Meaning | Stored `share_bp` per user | Stored `share_ore` per user | +|---|---|---|---| +| `default` | The account's default ratio at the time of saving | copied from the account | computed by the ratio rule | +| `ratio` | A custom ratio for this expense | as entered | computed by the ratio rule | +| `exact` | Exact amounts for this expense | `null` | as entered | +| `full` | One user bears the whole amount | bearer 10000, other 0 | bearer = amount, other 0 | + +**Ratio rule** (modes `default`, `ratio`, `full`; exactly two users P = payer, N = non-payer): + +``` +share_ore(N) = floor(amount_ore * share_bp(N) / 10000) +share_ore(P) = amount_ore - share_ore(N) +``` + +The non-payer's exact share is rounded down, so the payer absorbs the remainder: the payer's share is 0 or 1 öre more than their exact share. Intermediate products are computed in 64-bit integers (the maximum, 1 000 000 000 × 10000, fits). Examples (also test vectors): + +| amount_ore | payer | bp payer / non-payer | share payer | share non-payer | +|---|---|---|---|---| +| 1001 | A | 5000 / 5000 | 501 | 500 | +| 1 | A | 5000 / 5000 | 1 | 0 | +| 100 | B | 4000 / 6000 | 40 | 60 | +| 999 | A | 3333 / 6667 | 333 | 666 | +| 999 | B | 6667 / 3333 | 667 | 332 | +| 50000 | A | 0 / 10000 | 0 | 50000 | +| 50000 | A | 10000 / 0 | 50000 | 0 | + +**Exact rule:** both shares are given, each 0 or more, and they must sum exactly to the amount, otherwise `exact_sum_mismatch`. + +**Validity:** exactly two share entries, one for each household user, no other user ids; bp each 0 to 10000 and summing to 10000. + +**Server check:** for every pushed expense the server recomputes the shares from `split_mode`, the amount, the payer and the given bp (or exact öre) with `kvitt-domain` and rejects the mutation with reason `validation_failed` if the pushed `share_ore` values differ. For mode `default` the server does not compare the bp with the account's current default (the phone may have copied an older default while offline); it only checks they sum to 10000. + +### 9.2 Effect line from the viewer's side (lists) + +For viewer V and an expense with payer P: + +| Case | Line | +|---|---| +| V = P and the other's share > 0 | "You lent " | +| V ≠ P and V's share > 0 | "You borrowed " | +| the non-payer's share = 0 | "Not involved" when V ≠ P; "Paid for yourself" when V = P | + +### 9.3 Balances + +For an account and user u, over non-deleted expenses and non-deleted settlements: + +``` +net(u) = Σ expenses where payer = u : amount_ore + − Σ expenses : share_ore(u) + + Σ settlement allocations where from = u : allocation.amount_ore + − Σ settlement allocations where to = u : allocation.amount_ore +``` + +* `net(A) + net(B) = 0` always. +* The account balance shown to viewer V is `net(V)`: positive " owes you net(V)", negative "You owe −net(V)", zero "All settled up" (account screens say "Settled up"). +* The global balance is the sum over all accounts, archived included, deleted excluded (a deleted account has no non-deleted rows by AC-6.5). +* Balances include pending local changes. + +### 9.4 Settlement allocation proposal + +Inputs: payer user id, amount (öre, > 0), and for every non-deleted account: id, `created_at`, archived flag, and the payer's current net in it (computed with §9.3, excluding the settlement being edited if any). + +Algorithm (normative; §14.5 has the signature): + +1. Owing accounts = accounts where payer's net < 0. Sort by `created_at` ascending, then by `id` ascending. +2. `remaining = amount`. For each owing account in order while `remaining > 0`: `a = min(remaining, −net)`, emit allocation `(account, a)`, `remaining −= a`. +3. If `remaining > 0` (the amount exceeds the total debt) and at least one owing account exists, add `remaining` to the **last** emitted allocation and report that account as over-settled. +4. If no owing account exists: the target is the oldest non-archived account (same sort); if every account is archived, the oldest account. Emit one allocation of the whole amount to it and report it as over-settled. +5. If there are no accounts: error `no_accounts`. + +Output: ordered allocations, `excess_ore` (the amount added in steps 3 or 4, 0 otherwise), and `over_settled_account_ids`. + +Consequences, stated so nobody "fixes" them: + +* A global settle-up of exactly the global debt can leave individual accounts non-zero when the payer is owed in some account and owes in another (allocations are never negative). The global balance is then zero. Users settle individual accounts with per-account settle-ups in either direction. +* The proposal is only a suggestion; the user may edit any allocation, add accounts or remove rows. + +**Over-settle warning** for a user-edited allocation list: allocation `(account, a)` is warned when `a > max(0, −net_payer(account))`. + +**Settlement validity** (phone before saving, server on push): amount within §9.6; `from_user_id ≠ to_user_id`, both household users; 1 to 50 allocations; each `amount_ore ≥ 1`; account ids distinct and referencing existing non-deleted accounts; allocations sum to the amount. Error tokens in §14.5.3. + +### 9.5 Recurring generation + +* The scheduler runs once at server start and then daily at 06:00 Europe/Stockholm (DST-aware: always local 06:00). +* For each template with `deleted_at IS NULL` and `paused = 0`, the candidate periods are every month `p` with `start_period ≤ p`, `p ≤ end_period` (if set), `p > last_generated_period` (if set), and whose occurrence instant (the clamped date at 06:00 Europe/Stockholm) is at or before now. +* For each candidate in ascending order the server, in one transaction: creates the expense (server-generated UUIDv7 id; `expense_date` = clamped date; fields copied from the template; `created_by_user_id` and `updated_by_user_id` = the template's `created_by_user_id`; `updated_by_device_id` null; `created_at` = `updated_at` = server now; `recurring_template_id` and `recurring_period` set), writes its splits (mode `default` resolves the account's current default ratio at generation time; other modes use the template's splits, with `ratio`/`full` recomputed by the ratio rule for the template amount), writes an audit entry (`create`, `source` = `recurring`), sets the template's `last_generated_period` (bumping its `server_version`), then enqueues the push (AC-9.6). +* The unique index on `(recurring_template_id, recurring_period)` makes generation idempotent; a conflict means the period was already generated and is skipped silently. +* Pausing stops generation. Resuming does not backfill periods whose occurrence passed while paused: on resume the server sets `last_generated_period` to the latest period whose occurrence instant is before now, if that is later than the current value. +* Changing `day_of_month` or `start_period` never regenerates past periods. +* `next_occurrence_date` (in the wire form) is the clamped date of the first candidate period after `last_generated_period` that is ≥ `start_period` and ≤ `end_period`; `null` when paused, deleted or past `end_period`. +* A template's account may be archived (generation continues). Account deletion is blocked while a non-deleted template references it (AC-6.5). + +### 9.6 Limits and validation + +All string lengths are counted in Unicode code points after trimming. One table, used by phone validation and server validation alike: + +| Field | Rule | Error (REST) / reason (sync) | +|---|---|---| +| `amount_ore` (expense, settlement, template) | integer 1 to 1 000 000 000 (10 000 000,00 kr) | `validation_failed` | +| allocation `amount_ore` | integer ≥ 1 | `validation_failed` | +| allocations per settlement | 1 to 50 | `validation_failed` | +| `share_bp` | integer 0 to 10000; the two sum to 10000 | `validation_failed` | +| `share_ore` | integer ≥ 0; the two sum to the amount | `validation_failed` | +| expense `description`, template `description` | 1 to 100 code points | `validation_failed` | +| expense `notes`, template `notes` | 0 to 1000 code points; newlines allowed | `validation_failed` | +| settlement `note` | 0 to 200 code points | `validation_failed` | +| `expense_date`, `settlement_date` | valid date, from `2000-01-01` to server today (Europe/Stockholm) + 365 days | `validation_failed` | +| account `name` | 1 to 60 code points; unique (AC-6.2) | `validation_failed`, `name_taken` | +| category `name` | 1 to 40 code points, or `null` for a default category | `validation_failed` | +| user `display_name` | 1 to 40 code points | `validation_failed` | +| device `name` | 1 to 60 code points | `validation_failed` | +| `day_of_month` | integer 1 to 31 | `validation_failed` | +| `start_period` | `YYYY-MM`, not earlier than 12 months before the current month | `validation_failed` | +| `end_period` | `null` or `YYYY-MM` ≥ `start_period` | `validation_failed` | +| `updated_at` in a push | ≤ server now + 10 minutes | `clock_skew` | +| mutations per `POST /sync` | 1 to 100 | `batch_too_large` | +| `GET /sync` `limit` | 1 to 1000, default 500 | `invalid_request` | +| picture upload | ≤ 5 MiB, ≤ 8000 × 8000 px, JPEG/PNG/WebP | `payload_too_large`, `image_invalid`, `unsupported_media_type` | + +The phone input field for amounts accepts digits, one decimal separator (`,` or `.`) and at most two decimals, without sign, and converts to öre exactly (string parsing, never floating point). + +### 9.7 Icon and colour tokens + +Category icon tokens are Material Symbols names. The allowed set (exhaustive, v1): + +`shopping_cart`, `restaurant`, `local_cafe`, `home`, `bolt`, `water_drop`, `wifi`, `directions_car`, `train`, `local_gas_station`, `flight`, `hotel`, `luggage`, `movie`, `music_note`, `sports_esports`, `fitness_center`, `medical_services`, `medication`, `shopping_bag`, `checkroom`, `pets`, `child_care`, `school`, `card_giftcard`, `celebration`, `build`, `chair`, `subscriptions`, `category`. + +Colour tokens (exhaustive, v1) with provisional values; the UX sprint may change the hex values and must keep the token names: + +| Token | Light | Dark | +|---|---|---| +| `green` | `#2E7D32` | `#81C784` | +| `orange` | `#E65100` | `#FFB74D` | +| `brown` | `#6D4C41` | `#BCAAA4` | +| `yellow` | `#F9A825` | `#FFF176` | +| `blue` | `#1565C0` | `#90CAF9` | +| `teal` | `#00796B` | `#80CBC4` | +| `purple` | `#6A1B9A` | `#CE93D8` | +| `red` | `#C62828` | `#EF9A9A` | +| `pink` | `#AD1457` | `#F48FB1` | +| `grey` | `#546E7A` | `#B0BEC5` | +| `indigo` | `#283593` | `#9FA8DA` | +| `lime` | `#827717` | `#DCE775` | + +The server validates icon and colour against these lists (`validation_failed`). + +### 9.8 Formatting + +* Money, Swedish locale: `1 234,50 kr` (U+00A0 no-break space as the thousands separator and before `kr`; comma decimal; always two decimals). +* Money, any other locale: `SEK 1,234.50` (U+00A0 after `SEK`; comma thousands; point decimal; always two decimals). +* Money is never shown with a minus sign; direction is in words (§9.2, §9.3). +* Dates in lists: Swedish `3 okt.`, English `3 Oct`; with year when not the current year (`3 okt. 2025`, `3 Oct 2025`). Detail views: Swedish `lördag 3 oktober 2026`, English `Saturday, 3 October 2026`. Timeline times: 24-hour `14:02` in both languages. +* Relative "last synced": "just now" (< 1 minute), "N min ago" (< 60 minutes), "N h ago" (< 24 hours), otherwise the list date and time. Swedish: "nyss", "för N min sedan", "för N h sedan". +* Formatting lives in `:core:design` (`MoneyFormatter`, `DateFormatter`), never in `:domain`. + +### 9.9 Online-only actions + +When the phone has no validated network (Android `NET_CAPABILITY_VALIDATED`) or the last request failed with a network error less than 30 seconds ago, these actions are disabled with "Needs connection": create, edit, archive, unarchive, delete of accounts, categories and recurring templates; profile name and picture; invites; device revoke; in-app update. A REST call that fails with a network error shows "Could not reach the server" and changes nothing locally. + +### 9.10 Other edge cases + +| Situation | Rule | +|---|---| +| Both users edit the same expense offline | Last `updated_at` wins; the loser's phone receives `stale` with the winner's row, overwrites locally and lists it under "Changes not synced" (§14.4.6). Both edits are in the audit log only if both were accepted, so only the winning edit is logged. | +| One user deletes, the other edits the same expense offline | Delete wins regardless of arrival order and timestamps: a pushed delete is always applied, and any non-delete write to a tombstoned row is rejected `deleted` (§14.4.4). | +| An expense is created offline in an account that was deleted meanwhile | Rejected `reference_not_found`; the row is removed locally and listed under "Changes not synced". Cannot happen for archived accounts (writes to archived accounts are accepted). | +| An expense uses a category that was archived meanwhile | Accepted. | +| Phone clock far in the future | Pushes rejected `clock_skew`; the app shows "Your phone's clock is wrong" on the sync status screen and keeps the changes pending. | +| Phone clock behind | Its edits may lose LWW against older real edits. Accepted risk. | +| Server restored from an older backup | The server's `sync_epoch` changes (§10.9); phones detect it and re-push everything they hold (§14.4.8), so changes made after the backup are recovered from the phones. | +| Partner never joins | Admin stays in the waiting state (AC-5.1). | +| Admin loses phone | `kvitt-server pair-device --user ` on the Pi (AC-1.6); then revoke the lost device from the new phone. | +| Both users' devices revoked | Same recovery as above for each user. | +| An account is renamed to a name used by a deleted account | Allowed (uniqueness ignores deleted accounts). | +| Recurring template's category deleted | Cannot happen: category deletion is blocked while a template uses it. | +| Expense amount edited in mode `exact` | The editor keeps the typed user's share and recomputes the other; if the typed share now exceeds the amount, saving is blocked (AC-8.7). | +| Settlement edited so that an allocation's account was deleted meanwhile | Cannot happen: an account with allocations cannot be deleted. | +| Same user on two devices edits offline | Same as two users: LWW per row. | +| Time zones | Dates (`expense_date`, `settlement_date`) are calendar dates chosen by the user; analytics never convert them. Only "today" defaults use the phone's zone; server validation and scheduling use Europe/Stockholm. | +| A day with a DST change | Scheduling uses local 06:00, which exists on every day in Europe/Stockholm. | + +--- + +## 10. Deployment & ops + +All infrastructure files live in `infra/` (ownership §14.7). Secrets live in `infra/secrets/` on the Mac (git-ignored) and in `/opt/kvitt/secrets/` on the Pi; neither is ever committed. + +### 10.1 Hosts and network + +| Host | Role | Facts | +|---|---|---| +| Kvitt Pi | Runs the compose stack | Raspberry Pi 3, 1 GB RAM, 64-bit Raspberry Pi OS Lite (Debian bookworm or later), Docker Engine with the compose plugin, static LAN address. LAN address and SSH user: `TODO(spec)` OQ-3 | +| Home Assistant Pi | Owns ports 80/443 and the Let's Encrypt certificate (certbot) for `icehack.asuscomm.com` | Reached by cert-sync over SSH. Address, SSH user and certificate paths: `TODO(spec)` OQ-3 | +| Owner's M4 Mac | Builds images and APKs, runs the local registry | Apple Silicon, so `linux/arm64` builds run natively in Docker | +| Router (ASUS) | `icehack.asuscomm.com` DDNS | Forwards external TCP 8443 to Kvitt Pi TCP 8443 (HUMAN step) | +| Local container registry | Holds Kvitt images | Address: `TODO(spec)` OQ-1 | + +The Pi never builds anything. It only pulls images and runs them. + +### 10.2 Compose stack + +File `infra/compose.yaml`, deployed to `/opt/kvitt/compose.yaml` on the Pi with `/opt/kvitt/.env` (from `infra/env.example`). Services: + +| Service | Image | Ports / volumes | Memory limit | Healthcheck | +|---|---|---|---|---| +| `caddy` | official `caddy` image, pinned by version and digest | publishes `8443:8443`; `./Caddyfile:/etc/caddy/Caddyfile:ro`; `certs:/certs:ro`; `caddy_data:/data` | 64 MiB | `wget -qO- http://localhost:2019/config/ >/dev/null` | +| `server` | `${KVITT_REGISTRY}/kvitt/server:${KVITT_TAG}` | `data:/data`; `/opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro`; no published port (listens on 8080 internally) | 160 MiB (budget: RSS under 100 MB in steady state) | `GET http://localhost:8080/health` via the binary's own `kvitt-server healthcheck` subcommand (no curl in the image) | +| `cert-sync` | `${KVITT_REGISTRY}/kvitt/cert-sync:${KVITT_TAG}` | `certs:/certs`; `./Caddyfile:/etc/caddy/Caddyfile:ro`; `/opt/kvitt/secrets/ha_ssh_key:/secrets/ha_ssh_key:ro`; `/opt/kvitt/secrets/ha_known_hosts:/secrets/known_hosts:ro` | 32 MiB | both cert files exist and are non-empty | +| `backup` | `${KVITT_REGISTRY}/kvitt/backup:${KVITT_TAG}` | `data:/data`; `backups:/backups`; `/opt/kvitt/secrets/rclone.conf:/config/rclone/rclone.conf:ro` | 96 MiB | last successful run marker newer than 26 hours, or no run yet since start | + +* Named volumes: `data` (SQLite, pictures, APKs), `certs`, `caddy_data`, `backups`. +* `caddy` `depends_on` `cert-sync` with `condition: service_healthy` and `server` with `condition: service_started`. +* Every service: `restart: unless-stopped`, logging driver `json-file` with `max-size: 10m`, `max-file: 3`, `TZ=Europe/Stockholm`. +* One internal bridge network `kvitt`. + +`.env` keys (exhaustive): `KVITT_REGISTRY`, `KVITT_TAG`, `HA_HOST`, `HA_PORT`, `HA_USER`, `HA_CERT_DIR`, `RCLONE_REMOTE` (default `gdrive:`), `RCLONE_FOLDER_ID`. + +### 10.3 Images, registry and build + +* Script `infra/build-images.sh [tag]` on the Mac: `docker buildx build --platform linux/arm64 --push` for the three Kvitt images, tag defaults to the short git SHA of `HEAD`, also tags `latest`. Refuses to run with a dirty working tree. +* `server` image: multi-stage. Stage 1 `rust:-bookworm` builds `kvitt-server` and `kvitt-import` in release mode with `--locked` for `aarch64-unknown-linux-gnu` (native on the M4; no `target-cpu=native`, the Pi 3 is a Cortex-A53). Stage 2 `gcr.io/distroless/cc-debian12:nonroot` (arm64) with the two binaries in `/usr/local/bin/`, user 10001, entrypoint `kvitt-server`, command `serve`. +* `cert-sync` image: `alpine` with `openssh-client`, `curl` and a shell script `infra/cert-sync/sync.sh` run by `crond`. +* `backup` image: `alpine` with `sqlite`, `gzip`, `tar`, `rclone` and `infra/backup/backup.sh` run by `crond`. +* Script `infra/deploy.sh ` on the Mac: copies `compose.yaml` and `Caddyfile` to the Pi over SSH, sets `KVITT_TAG` in `/opt/kvitt/.env`, runs `docker compose pull && docker compose up -d`, waits for `server` to be healthy, prints `GET /health`. +* The Pi's Docker daemon must trust the registry (`insecure-registries` if it is plain HTTP on the LAN); the infra plan writes the exact step once OQ-1 is answered. + +### 10.4 Server configuration and CLI + +Environment variables (exhaustive): + +| Variable | Default | Meaning | +|---|---|---| +| `KVITT_DATA_DIR` | `/data` | Holds `kvitt.sqlite`, `pictures/`, `apk/` | +| `KVITT_BIND` | `0.0.0.0:8080` | Listen address | +| `KVITT_PUBLIC_URL` | `https://icehack.asuscomm.com:8443` | Used in invite links and the landing page | +| `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | Service-account key; if the file is missing, push is disabled and a warning is logged at start | +| `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project | +| `KVITT_TRUSTED_PROXY` | `caddy` | Host name whose resolved address may set `X-Forwarded-For` | +| `RUST_LOG` | `info` | tracing filter | + +The time zone `Europe/Stockholm` is a constant in code, not configuration. + +Files under `KVITT_DATA_DIR`: + +* `kvitt.sqlite` (+ `-wal`, `-shm`) +* `pictures//-512.jpg` and `-128.jpg`; older versions are deleted after a successful upload +* `apk/latest.json` and `apk/kvitt-.apk` + +`kvitt-server` subcommands (exhaustive): + +| Command | Effect | Exit codes | +|---|---|---| +| `serve` | Run migrations, start scheduler, push sender and HTTP server | 0 on clean shutdown (SIGTERM drains in-flight requests up to 10 s) | +| `bootstrap` | FR-1 | 0 ok, 2 already initialized | +| `pair-device --user ` | AC-1.6 | 0 ok, 3 unknown user | +| `rotate-epoch` | Sets a new random `sync_epoch` (§10.9) | 0 | +| `healthcheck` | Calls `http://127.0.0.1:8080/health`, exit 0 if 200 | 0 or 1 | +| `migrate` | Run migrations only | 0 | + +Run them on the Pi with `docker compose exec server kvitt-server ` (or `docker compose run --rm server ` when `server` is stopped). + +### 10.5 TLS + +#### 10.5.1 Certificate sync + +* The Home Assistant Pi renews the Let's Encrypt certificate for `icehack.asuscomm.com` (its own certbot; Kvitt does not touch it). +* `cert-sync` runs `sync.sh` at container start and daily at 04:00 Europe/Stockholm. It connects to `${HA_USER}@${HA_HOST}:${HA_PORT}` with the key `/secrets/ha_ssh_key` and `StrictHostKeyChecking=yes` against `/secrets/known_hosts`, and copies `${HA_CERT_DIR}/fullchain.pem` and `${HA_CERT_DIR}/privkey.pem` into a temp directory. +* The key is dedicated to Kvitt and, where the Home Assistant SSH setup allows `authorized_keys` options, restricted with `restrict,command="tar -C -cf - fullchain.pem privkey.pem"` (the script then reads a tar stream instead of using `scp`). The infra plan records which variant the Home Assistant SSH add-on supports. +* If both files parse as PEM, the certificate's not-after is in the future, and the SHA-256 of either differs from `/certs`, the script moves them into `/certs` (mode 0640, group readable by Caddy) and reloads Caddy: `curl -fsS -X POST http://caddy:2019/load -H 'Content-Type: text/caddyfile' -H 'Cache-Control: must-revalidate' --data-binary @/etc/caddy/Caddyfile`. +* Any failure logs one line and keeps the existing certificate. The healthcheck stays green while valid files exist. + +#### 10.5.2 Caddyfile + +``` +{ + admin 0.0.0.0:2019 + auto_https off +} + +icehack.asuscomm.com:8443 { + tls /certs/fullchain.pem /certs/privkey.pem + request_body { + max_size 6MB + } + reverse_proxy server:8080 + log { + output stdout + format json + } +} +``` + +Caddy logs must not include the `Authorization` header (Caddy redacts it by default; the infra plan verifies). + +#### 10.5.3 Fallback: Caddy internal CA + +Used only if the certificate sync cannot be made to work. Documented in `infra/README.md`, not deployed by default. + +* Caddyfile site block uses `tls internal` instead of the file pair. +* The Caddy root certificate (`/data/caddy/pki/authorities/local/root.crt` in `caddy_data`) is copied once to `android/app/src/release/res/raw/kvitt_root_ca.crt`. This file is a public certificate and is committed. +* The release network security config gets a `domain-config` for `icehack.asuscomm.com` whose trust anchors are only `@raw/kvitt_root_ca`. Switching is a build property `kvitt.tls.pinnedRoot=true` in `android/gradle.properties`. +* Caddy's internal root is valid 10 years; intermediates renew automatically. + +### 10.6 APK hosting and release + +* `version_code` = `git rev-list --count HEAD` on the commit being released; `version_name` = `YYYY.MM.DD-` of that commit. Both are passed to Gradle as properties `kvitt.versionCode` and `kvitt.versionName` by the release script; debug builds use `1` and `dev`. +* The release keystore is created once by the owner (HUMAN, §11) at `android/keystore/release.jks` (git-ignored); its passwords and alias are in `android/local.properties` keys `kvitt.release.storeFile`, `kvitt.release.storePassword`, `kvitt.release.keyAlias`, `kvitt.release.keyPassword`. Losing it means the phones must uninstall and reinstall (data comes back by sync). The owner keeps a copy in their password manager. +* `infra/publish-apk.sh` on the Mac: builds `:app:assembleRelease` through `tools/gradle-serial.sh`, computes SHA-256 and size, copies the APK to the Pi's `data` volume as `apk/kvitt-.apk`, writes `apk/latest.json` atomically (write temp, rename): + +```json +{ + "version_code": 412, + "version_name": "2026.11.02-1a2b3c4", + "file_name": "kvitt-412.apk", + "size_bytes": 18234567, + "sha256": "9f2c…64 hex chars…", + "released_at": "2026-11-02T19:40:00.000Z" +} +``` + +* The server reads `latest.json` on each `GET /apk/latest` (no cache) and serves `GET /apk/files/{file_name}` only for a `file_name` matching `^kvitt-[0-9]+\.apk$` that exists, with `Content-Type: application/vnd.android.package-archive`. +* Older APKs stay on the Pi; the publish script keeps the newest 5 and deletes older ones. + +### 10.7 Firebase setup (infra plan, with HUMAN sign-ins) + +1. HUMAN: `gcloud auth login` and `firebase login` on the Mac with the owner's Google account. +2. `gcloud config set project kvitt-1337`; `gcloud services enable fcm.googleapis.com firebase.googleapis.com`. +3. `firebase projects:addfirebase kvitt-1337` (skip if already a Firebase project). +4. `firebase apps:create ANDROID "Kvitt" --package-name se.hagstedt.kvitt` and `firebase apps:create ANDROID "Kvitt debug" --package-name se.hagstedt.kvitt.debug`. +5. `firebase apps:sdkconfig ANDROID ` for each, merged into one `android/app/google-services.json` (git-ignored; the file holds both clients). +6. `gcloud iam service-accounts create kvitt-fcm --display-name "Kvitt FCM sender"`; grant `roles/firebasecloudmessaging.admin` on the project; `gcloud iam service-accounts keys create infra/secrets/fcm-service-account.json --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com`. +7. Copy the key to `/opt/kvitt/secrets/fcm-service-account.json` on the Pi (mode 0440, group 10001). +8. If an organisation policy blocks service-account key creation, stop and report; do not change org policies. + +The FCM free tier is sufficient; no billing account is required. + +### 10.8 Splitwise import + +Command (run on the Pi with the server stopped, or on the Mac against a copy of the database): + +``` +kvitt-import --db /data/kvitt.sqlite --file --account "" \ + --user "=" \ + --user "=" \ + [--fx =]... [--dry-run] +``` + +* One CSV file per Splitwise group (Splitwise's group "Export as spreadsheet"). The account is created with default 50/50 if no non-deleted account with that name exists; otherwise rows go into the existing one. +* Expected columns, in order: `Date`, `Description`, `Category`, `Cost`, `Currency`, then one column per group member named by the member's Splitwise name, holding that member's net effect for the row (positive = paid more than their share). A final row whose `Description` is `Total balance` is ignored, as are blank rows. **To be confirmed against the owner's real file: `TODO(spec)` OQ-2.** The importer must fail with a clear message, writing nothing, if the header differs. +* `Date` is `YYYY-MM-DD`; `Cost` and member columns are decimal numbers with `.` as separator, converted to öre exactly by string parsing. +* For each expense row with members A and B and cost C (öre): the payer is the member with positive net. If `net_A > 0`: payer A, `share_B = net_A`, `share_A = C − net_A`, mode `exact`. Symmetric for B. If both nets are 0: payer is the owner, mode `full` on the owner. If `net_A + net_B ≠ 0` or a resulting share is negative, the row is reported as invalid and the import aborts (nothing written). +* Rows whose `Category` is `Payment` become settlements: `from` = the member with positive net, `to` = the other, amount = that net, one allocation to the account, `note` = the Splitwise description truncated to 200 code points. +* Rows whose `Currency` is not `SEK` abort the import unless a `--fx =` is given for that currency; then every amount is converted with `round_half_up(amount_ore × rate)` per value, and for expenses the exact shares are recomputed as `share_payer = C' − share_nonpayer'`. +* `import_key` = lowercase hex SHA-256 of `splitwise|v1||||||||` where `` is the 0-based occurrence index of an identical line earlier in the same file. Rows whose key exists are skipped. +* Every created row: `created_by_user_id` = `updated_by_user_id` = the owner; `updated_by_device_id` null; `created_at` = `updated_at` = the import time; one audit entry `create` with `source` = `import`; new `server_version`s from the global sequence; no pushes. +* Description is truncated to 100 code points; the full original goes into `notes` when truncated. +* Category mapping (case-insensitive exact match on the `Category` column): + +| Splitwise category | Kvitt `default_key` | +|---|---| +| Groceries, Food and drink - Other, Liquor | `groceries` | +| Dining out | `restaurants` | +| Electronics, Furniture, Household supplies, Maintenance, Mortgage, Rent, Services, Pets, Home - Other | `home` | +| Electricity, Heat/gas, Water, Trash, TV/Phone/Internet, Cleaning, Utilities - Other | `utilities` | +| Bicycle, Bus/train, Car, Gas/fuel, Parking, Taxi, Transportation - Other | `transport` | +| Hotel, Plane | `travel` | +| Games, Movies, Music, Sports, Entertainment - Other | `entertainment` | +| Medical expenses | `health` | +| Clothing, Gifts | `shopping` | +| General, Childcare, Education, Insurance, Taxes, Life - Other, Other, any other value | `other` | + +* The summary lists each unmapped category name with its row count. + +### 10.9 Backups and restore + +Backup (`infra/backup/backup.sh`, daily 03:30 Europe/Stockholm, also runnable as `docker compose exec backup /backup.sh`): + +1. `sqlite3 /data/kvitt.sqlite ".backup /backups/staging/kvitt.sqlite"` (online, consistent). +2. `sqlite3 /backups/staging/kvitt.sqlite "PRAGMA integrity_check"` must print `ok`, else abort with a log line. +3. `gzip -9` to `/backups/kvitt-.sqlite.gz`; `tar -czf /backups/pictures-.tar.gz -C /data pictures`. +4. `rclone copy /backups/ "${RCLONE_REMOTE}" --drive-root-folder-id "${RCLONE_FOLDER_ID}" --immutable` (copy only; never `sync`, never delete, never overwrite). +5. On success write `/backups/last-success` with the stamp. Local files older than 30 days are deleted from the Pi's `backups` volume only after they are confirmed on Drive (`rclone lsf`); nothing on Drive is ever deleted. + +rclone setup (HUMAN, once): on the owner's laptop run `rclone authorize "drive"` and sign in; the infra agent writes `infra/secrets/rclone.conf` with a `[gdrive]` remote (`type = drive`, `scope = drive`, the token from the authorize step) and copies it to the Pi. The owner chooses the Drive folder and gives its folder id (`TODO(spec)` OQ-4). + +Restore (documented step by step in `infra/RESTORE.md`): + +1. `docker compose stop server`. +2. Download the chosen `kvitt-*.sqlite.gz` and `pictures-*.tar.gz` from Drive (rclone or browser) to the Pi. +3. `gunzip`; `sqlite3 "PRAGMA integrity_check"` must print `ok`. +4. Move the current `/data/kvitt.sqlite*` aside (never delete), place the restored file as `/data/kvitt.sqlite`, extract pictures into `/data/pictures`. +5. `docker compose run --rm server rotate-epoch`. +6. `docker compose start server`; check `/health`. +7. Open the app on both phones; they detect the new epoch and re-push their data (§14.4.8). Check the balances match on both phones. + +### 10.10 Operations + +* Logs: `docker compose logs `. +* Disk: SQLite is a few MB; pictures under 1 MB; APKs about 20 MB each, 5 kept. +* Upgrades: build, push, `infra/deploy.sh `. Migrations run on start; a failed migration exits non-zero, the container restarts in a loop, and the previous tag is redeployed with `infra/deploy.sh ` (migrations are forward-only, so the infra plan's runbook says to restore the pre-upgrade backup if a migration had partially applied; sqlx runs each migration in a transaction, so this should not happen). +* Before every deploy that includes a migration, `infra/deploy.sh` triggers a backup run and waits for `last-success` to update. +* Memory check: `docker stats --no-stream` after the release milestone; server RSS must be under 100 MB with both phones syncing. + +--- + +## 11. Milestones + +Each milestone is one plan (`PLAN-.md`, written by the orchestrator's plans agent from this section) run by one Opus agent in its own worktree, unless the row says otherwise. "Gate" is what must pass before landing. The Android gate is `tools/gradle-serial.sh check`; the server gate is `cargo fmt --check && cargo clippy -- -D warnings && cargo test` in `server/`; "both gates" means both. HUMAN steps are what only the owner can do. + +### 11.1 Dependency graph + +``` +M0 spec ──┬─► U1 UX directions ──(owner picks)──► U2 UX design spec ──► M6 design system ──► M7a..M7f features ─┐ + │ │ + ├─► M1 contracts + vectors ─┬─► M2a server skeleton + domain ─┬─► M3 server core API ───────────┐ │ + │ │ ├─► M4 server sync/audit/push ─┬──┤ │ + │ │ │ └──┼─► M9 importer + │ └─► M2b android skeleton + domain ┴─► M5 android data layer ───────┼───────┤ + │ │ ▼ + └─► M2c infra base (Pi, compose, TLS, registry, Firebase, backups) ───────────────────────────┴──► M8 app integration + │ + M10 first release ◄─────────────────┘ + M11 import real history ◄── M9, M10 +``` + +After M1, M2a and M2b land, these run in parallel without touching each other's files (§14.7): M3, M4, M5, M2c, M6 (once U2 is approved), M7a to M7f (once M6 lands), M9 (once M4 lands). + +### 11.2 Milestone table + +| Id | Name | Deliverable | Gate | HUMAN | Depends on | +|---|---|---|---|---|---| +| M0 | Spec v0.1 | `SPEC.md`, filled `CLAUDE.md`, repository bootstrap | none (docs only) | Review the spec; answer §12 | none | +| U1 | UX directions | §13.4 phase 1 files in `docs/ux/` | Files exist and open; every screen of §13.3 appears in each direction; Swedish copy, no placeholder text | Review 2-3 directions, pick one, give comments | M0 | +| U2 | UX design spec | §13.4 phase 2 files in `docs/ux/` | Every string key has `en` and `sv`; every screen has all states of §13.3 | Approve the design spec | U1 and the owner's pick | +| M1 | Contracts and vectors | `contracts/openapi.yaml`, `contracts/schema/server-v1.sql`, `contracts/test-vectors/*.json`, `contracts/examples/**/*.json`, `tools/contracts-check.py` | `python3 tools/contracts-check.py` passes: all JSON parses, every vector file matches §14.5.5, a stdlib-only Python reference of §9.1, §9.3, §9.4 reproduces every expected value, every example validates against the shapes of §14.3 by key set | none | M0 | +| M2a | Server skeleton and `kvitt-domain` | Cargo workspace, three crates, config, DB pool, migration `0001_init.sql` byte-identical to `contracts/schema/server-v1.sql`, error type with every §14.3.2 code, auth extractor (§8.2), all routes of §7 registered with handlers returning 501 `not_implemented`, utoipa document equal to `contracts/openapi.yaml`, `GET /health` and `GET /setup/status` implemented, `kvitt-domain` passing all vectors, `server/Dockerfile` | Server gate (includes the OpenAPI match test, the migration identity test, the vector tests) | none | M1 | +| M2b | Android skeleton and `:domain` | Gradle project in `android/` with every module of §6.2, version catalog, architecture check, `:core:model` types and repository interfaces of §14.2.4, `:domain` passing all vectors (including analytics), `:core:fakes` with an empty scenario for each repository, `:app` launching to an empty themed screen, string resource files for both languages, lint rule for missing translations | Android gate | Plug the phone in once to confirm `installDebug` works | M1 | +| M2c | Infra base | `infra/` compose stack, Caddyfile, cert-sync and backup images, build, deploy and publish scripts, `infra/README.md` (Pi provisioning, router forwarding, registry trust), `infra/RESTORE.md`, Firebase project and apps, service-account key in `infra/secrets/`, `google-services.json` in `android/app/` | `docker compose -f infra/compose.yaml config` succeeds; `shellcheck infra/**/*.sh` clean if shellcheck is installed; on the Pi: cert-sync fetched a valid certificate, `https://icehack.asuscomm.com:8443/health` answers (with the M2a image) from the phone on mobile data; one backup file appears in the Drive folder | Answer OQ-1, OQ-3, OQ-4; install Docker on the Pi if missing; set the router forward 8443 → Kvitt Pi; add the cert-sync public key to the Home Assistant SSH add-on; `gcloud auth login`, `firebase login`; `rclone authorize "drive"` | M0 (uses the M2a image for the health check when available) | +| M3 | Server core API | E-2 to E-30 and E-33 implemented (§14.3), invite landing page, pictures, rate limiting, `bootstrap`, `pair-device` | Server gate; integration tests per endpoint and per error code listed for it | none | M2a | +| M4 | Server sync, audit, push, scheduler | E-31, E-32, audit writer and diff, `sync_mutations` pruning, FCM sender, scheduler, `rotate-epoch` | Server gate; integration tests for every sync rule of §14.4 and every case of §9.5; FCM sender tested against a local stub HTTP server | none | M2a | +| M5 | Android data layer | Room DB v1 (§14.2) with exported schema, DAOs, Ktor client and DTOs, token store, sync engine and workers, real repositories, APK updater | Android gate; DTO round-trip tests over every file in `contracts/examples/`; sync engine tests with Ktor `MockEngine` covering §14.4 including the 14-day offline case (AC-15.6) | none | M1, M2b | +| M6 | Design system | `:core:design` theme, tokens, typography, shared components, charts, formatters (§9.8) per `docs/ux/design-spec.md`, screenshot tests of every component in light and dark, Swedish and English | Android gate | Look at the component screenshots the agent opens and posts (yes/no) | U2, M2b | +| M7a | Feature: onboarding | `:feature:onboarding` screens S-1, S-2, S-3, S-18 | Android gate with screenshot tests of every state, sv and en, light and dark | none | M6 | +| M7b | Feature: home and account | `:feature:home`, `:feature:account` screens S-4, S-5, S-6, S-16, S-17 | as M7a | none | M6 | +| M7c | Feature: expense | `:feature:expense` screens S-7, S-8 | as M7a | none | M6 | +| M7d | Feature: settle | `:feature:settle` screens S-9, S-10 | as M7a | none | M6 | +| M7e | Feature: analytics | `:feature:analytics` screens S-11, S-12 | as M7a | none | M6 | +| M7f | Feature: settings | `:feature:settings` screens S-13, S-14, S-15, S-19, S-20, S-21, S-22 | as M7a | none | M6 | +| M8 | App integration | `:app` navigation and deep links, Hilt bindings from interfaces to `:data` and `:domain`, `:adapter:fcm`, notification building (AC-13.5), update banner wiring, revoke wipe | Android gate; on the owner's phone against the real server: pair, add an expense offline, reconnect, see it synced | Tap through the pairing and the offline test as relayed by the orchestrator | M3, M4, M5, M7a-M7f, M2c (Firebase) | +| M9 | Splitwise importer | `kvitt-import` per §10.8 with tests over synthetic CSV fixtures | Server gate | Provide the CSV files (kept outside git) when OQ-2 is answered | M2a, M4 | +| M10 | First release | Release build signed, published, both phones paired, push working both ways, nightly backup on Drive, restore drill report, memory check | `docs/validation/first-release.md` and `docs/validation/restore-drill.md` complete; server RSS under 100 MB | Create the release keystore; install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive | M2c, M3, M4, M8 | +| M11 | Import history | Real import run, `docs/validation/splitwise-import.md` with per-account totals compared to Splitwise's balances (aggregates only) | Imported balances equal Splitwise's "Total balance" rows per group | Provide the CSVs; confirm the balances on the phone match Splitwise | M9, M10 | + +### 11.3 Notes per milestone + +* **M1** writes the OpenAPI file by hand from §14.3 (no code exists yet). M2a then annotates handlers so the generated document matches it exactly; M2a may only change `contracts/openapi.yaml` for representational differences that do not change meaning (key order, `$ref` naming), recorded in the plan report. Any semantic change follows the contract-change rule (§14.8). +* **M2a** owns the router and registers every route up front so that M3 and M4 only fill in handler bodies in their own files. The same for the utoipa `ApiDoc`. +* **M2b** writes the `*State` classes and repository interfaces of §14.2.4 so that M5 and M7 work against the same interfaces in parallel. +* **M7x** features use `:core:fakes` only; they never depend on `:data`. Each feature agent adds its fake scenarios in `android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes//` and nowhere else in that module. +* **M8** is the only plan that edits `:app` after M2b. +* **M10**: the orchestrator relays every HUMAN step in chat. + +--- + +## 12. Open questions + +Only things that cannot be decided without the owner. Everything else in this spec is decided. + +| Id | Question | Needed by | Default if unanswered | +|---|---|---|---| +| OQ-1 | `TODO(spec)` Address of the owner's local container registry (host:port), whether it is HTTP or HTTPS, and whether it needs credentials. | M2c | none; M2c blocks on it | +| OQ-2 | `TODO(spec)` Confirm the Splitwise CSV export format against a real file: the exact header line (expected `Date,Description,Category,Cost,Currency,,`), the date format, the decimal separator, the name of the payment category (expected `Payment`), the trailing `Total balance` row, and the two member names as they appear in the header. Also: which groups exist and what Kvitt account name each should become; whether any group has non-SEK rows and which rates to use. | M9 | M9 builds against the expected format with synthetic fixtures and makes the header check strict | +| OQ-3 | `TODO(spec)` LAN address and SSH user of the Kvitt Pi; LAN address, SSH port and user of the Home Assistant Pi; the directory holding `fullchain.pem` and `privkey.pem` there (expected `/ssl` on Home Assistant OS); which SSH add-on is used. | M2c | none; M2c blocks on it | +| OQ-4 | `TODO(spec)` Which Google Drive folder receives backups (its folder id). | M2c | none; M2c blocks on the backup part only | + +--- + +## 13. UX sprint + +A brief for one UX agent (Opus) who has read this spec and nothing else. It runs right after M0, in parallel with M1, and before any UI work (M6, M7). It produces proposals; the owner chooses; the UX agent then writes the binding design spec. The UX agent writes no Kotlin. + +### 13.1 Context the UX agent must design for + +* Two adults in Sweden, one household, using Samsung Galaxy S26 (about 411 × 891 dp viewport, 6.3") and S26+ (about 411 × 891 dp at a higher density, 6.7"). Portrait only. Android 16, One UI on top; the app uses Material 3 (Expressive where stable) and must not imitate One UI. +* Most sessions are 10 seconds: add an expense right after paying. The add flow is the most important flow; it must need at most: open app, tap add, type amount, type description, tap save (category and account defaulted, AC-8.6). +* The second most important view is "who owes whom" on Home. +* Swedish is the primary language (both users' phones are Swedish), English must fit too. Swedish words are long ("Utgiftskonto", "Återkommande"); designs must survive them at 200 % font scale. +* Light and dark theme follow the system. Dynamic colour (Material You wallpaper colours) is offered as an option per direction only if the direction still works with it; the default is the direction's own palette. +* Money must be legible at a glance: amounts in tabular figures, direction ("owes you", "you owe") always in words plus colour, never colour alone. +* Accessibility: touch targets ≥ 48 dp, contrast ≥ 4.5:1 for text, TalkBack labels for every icon-only control, nothing conveyed by colour only, layouts that reflow at 200 % font scale. +* Category icons are Material Symbols from the set in §9.7; category colours are the tokens of §9.7 (the UX agent may change hex values, not token names, and must give light and dark values with contrast checked against both surfaces). +* Users are shown by avatar (picture or initials, AC-4.5) and display name. The viewer is always "you" / "du". + +### 13.2 Navigation model (binding unless a direction proposes a better one with reasons) + +* Bottom navigation with three destinations: **Home** (Översikt), **Analytics** (Statistik), **Settings** (Inställningar). +* A floating action button "Add expense" (Lägg till utgift) on Home and on Account detail. +* Account detail, expense editor, expense detail, settle up, settlement detail and all settings sub-screens are pushed screens with a top app bar and back. +* The expense editor is a full-screen screen, not a bottom sheet (the numeric keyboard needs the room). + +### 13.3 Screens and what each must show + +Each screen must be designed in these states where they apply: **loaded**, **empty**, **offline** (with pending changes), **loading** (first sync only; afterwards data is local), **error** (a failed online action), and, for lists, **long content** (long Swedish names, 7-digit amounts). + +| Id | Screen | Must show | Actions | +|---|---|---|---| +| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled); invite code field (12 characters, shown in groups of 4); explanation of where the code comes from; progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code | +| S-2 | Partner profile step | Display name field (prefilled), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob") | Continue | +| S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any | Invite partner (goes to S-21); Settings | +| S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16; update banner (S-17) when available; list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") | Add expense (FAB); Settle up; open account; New account | +| S-5 | Account detail | Account name, default split ("50 / 50" with names), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend), each expense row per AC-8.10, each settlement row per AC-11.7; archived badge if archived; pending-sync marker per row | Add expense; Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed) | +| S-6 | Account editor | Name field; default split control (one slider or stepper plus two percent fields that always sum to 100, two decimals allowed); preview "Of 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr"; "Needs connection" state | Save; Cancel | +| S-7 | Expense editor | Amount entry (large, numeric keyboard, `kr`); description; account picker (active accounts); category picker (grid of icon + colour + name, active categories); date (today default, date picker); payer toggle (two avatars: "Paid by"); split section: mode chooser (`default` "Account default 60/40", `ratio` "Custom ratio", `exact` "Exact amounts", `full` "All on one person") and its controls; live preview of each person's share; notes (collapsed by default); validation errors inline; delete in edit mode | Save; Cancel; Delete (edit mode) | +| S-8 | Expense detail | Amount, description, category, account, date, payer, each user's share and the split mode, notes, "Recurring: