> Taken at `ed1b130` (main, 2026-10-18, SPEC v0.16; `956908a` after the history rewrite, so the hashes below are pre-rewrite); committed verbatim with SPEC v0.18. Its two S-16 and S-22 mismatches are fixed since (D-109, D-110; SPEC §11.4). # AC coverage audit: is Kvitt feature complete against SPEC.md? Audited 2026-10-18 at `main` `ed1b130` (SPEC v0.16), read-only: code and tests were read (`git grep`, file reads); no build, no test run, no phone. Scope: SPEC §4 (FR-1 to FR-20, every AC), §9 rules, §13.3 screen actions S-1 to S-22, §14.2.4 UI contract, §14.3.4 endpoints E-1 to E-33, §14.4 sync, §14.6 push, §10.4 server CLI. Aggregates only; no personal data. Status meanings: **Done** = implemented in the real path (`:data`, features, `:app`, server crates) and demonstrated by a test, or by the recorded M8 phone check; **Partial** = implemented but a sub-clause or the §4 evidence rule is not met; **Missing** = not present; **Not verifiable without device** = only on-device or on-Pi behaviour (system UI) can prove it. ## 1. Summary | Status | Rows | |---|---| | Done | 260 | | Partial | 5 | | Missing | 1 | | Not verifiable without device | 4 | | **Total** | **270** | Per part: server side (Part A, 98 rows): 95 Done, 2 Partial, 1 Missing. Android FR-2 to FR-12 and S-1 to S-10 (Part B, 100 rows): 96 Done, 1 Partial, 3 device-only. Android FR-9, FR-13 to FR-16, FR-19, S-11 to S-22, UI contract (Part C, 72 rows): 69 Done, 2 Partial, 1 device-only. Further findings: - All 33 endpoints are routed (`server/crates/kvitt-server/src/router.rs:31-102`) and tested, and the OpenAPI equality test is in the server gate. - All six CLI subcommands of §10.4 exist. - Every S-1 to S-22 action reaches a real destination or repository call in `android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt`; none is wired to a no-op. - `:core:model` matches the §14.2.4 block exactly, the v0.13 additions included. ## 2. Verdict **Feature complete in code: yes. Feature complete against SPEC §4's acceptance rule: no, not yet.** Every functional requirement the app and server must implement is built. What remains falls into three groups: **(a) Blocked on the Pi or the first release, which are ops milestones rather than code:** - AC-18.2 restore drill (Missing). - AC-18.1 nightly Drive backup observed on the Pi. - AC-20.1 latency under 100 ms on the Pi. - AC-16.3 the real PackageInstaller install. All belong to M2c-pi or M10 by the milestone table. **(b) Two spec mismatches in shipped behaviour:** - S-16 "open a rejected change (read-only)" opens the current editable row instead of the lost change. - S-22 reads the server version from a response header, not from `/health`. **(c) Test-evidence gaps where the code reads correctly** (§9.2 effect line, AC-2.9, a few `:data` and sync-engine paths). None of (b) or (c) blocks the code overhaul or the security review. The (a) items are the M10 gate itself. Blockers for "feature complete": 1. AC-18.2 restore drill report. 2. S-16 read-only rejected-change view: implement it, or have a docs decision accept the summary line. 3. S-22 server-version source: fix the code, or amend the spec. The first release (M10) additionally needs AC-18.1, AC-20.1 and AC-16.3 shown on the Pi and phones. ## 3. Prioritised gaps (Missing and Partial first, then device-only, then evidence and robustness) 1. **AC-18.2 Restore drill (Missing).** `docs/validation/` does not exist, and no drill has been run; the procedure is in `infra/RESTORE.md`. Smallest change: once the Pi stack runs, or earlier on the dev stack as a rehearsal, follow `infra/RESTORE.md`: - restore the latest backup; - run `kvitt-server rotate-epoch`; - let both phones sync and confirm the epoch recovery; - write `docs/validation/restore-drill.md` with a "Results (owner's phone)" section. Owner: M10 (first release), as an ops and docs agent plus the owner's taps. 2. **S-16 "open a rejected change (read-only)" (Partial).** Evidence: `android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt` (RejectionCard, around line 274). The tap opens the ordinary editable S-8 or S-10 for the entity id, which holds the server's winning row (or the "missing" state). The lost local version in `sync_rejections.rejected_payload` is never shown, so "what was lost" is only the one-line summary. Smallest change: a read-only sheet on S-16 that renders the rejected payload. That needs a `Rejection` field, for example a list of display fields, so it is a contract change (§14.8) across `:core:model`, `:data`, `:core:fakes` and `:feature:home`. The alternative is a docs decision that the summary plus opening the current row satisfies §13.3. Owner: an M12-style contract pass, or a docs agent. 3. **S-22 server version source (Partial).** `android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt:336-345` reads the `X-Kvitt-Server-Version` header of the last response (`data/.../api/KvittApi.kt:241`), so the value is empty until a request reaches the server after process start. §13.3 says "from `/health`". Smallest change: call E-1 once when S-22 opens, in `AboutRepositoryImpl` (`:data`, no contract change), or amend the §13.3 wording. Owner: `:data`, or a docs agent. 4. **AC-18.1 Nightly Drive backup (Partial).** The code is complete: 03:30 cron in `infra/backup/Dockerfile`, `rclone copy --immutable` in `infra/backup/backup.sh`. It has never run on the Pi. Smallest change: after the M2c-pi bundle is deployed, list the Drive folder the next morning and record the result in the restore-drill report. Owner: M2c-pi, run by the owner, then M10. 5. **AC-20.1 Health under 100 ms on the Pi (Partial).** Function and test are done (`tests/skeleton/routes.rs` health tests); the latency is not measured. Smallest change: a timing loop against `/health` on the Pi, with the aggregate written into `docs/validation/first-release.md`. Owner: M10. 6. **§9.2 Effect line (Partial: evidence).** `android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Snapshot.kt:124` `effectFor` reads correctly, but no `:data` test covers its four cases; the screenshots render the fakes. Smallest change: one `RepositoriesTest` case with four expenses asserting `Lent`, `Borrowed`, `PaidForYourself` and `NotInvolved`. Owner: `:data` (code-overhaul phase). 7. **AC-16.3 PackageInstaller and "Install unknown apps" on the device (device-only).** `android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt:177-209`; the size and SHA-256 checks are unit-tested. Smallest change: verify during M10 with a published signed APK, one owner yes/no. Owner: M10. 8. **AC-2.9 Notification permission after the first sync (device-only, with no unit test).** `android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt:60-66`. Smallest change: an `:app` unit test with a fake `SyncRepository`, plus an owner yes/no on the next fresh pairing. Owner: `:app`. 9. **AC-4.3 and AC-4.6 Photo picker and offline cached pictures (device-only).** Smallest change: two owner yes/no taps (pick a picture in Profile; in airplane mode, the pictures still show). Owner: orchestrator HUMAN steps. 10. **AC-2.6 Stale invite held while paired (note).** A link opened while paired is ignored on screen but kept in `AppViewModel.invite`, and it prefills S-1 if the phone later becomes unpaired or revoked. Smallest change: drop it in `AppViewModel.onLink` while the session is `Paired`, with a unit test. Owner: `:app`. 11. **AC-13.7 Push-token robustness (note).** A failed E-17 PUT is logged and not retried until Firebase calls `onRegistered` again, so a device could stay without pushes. Smallest change: re-send the stored FID at each paired start or after the first successful sync. Owner: `:app` push or `:adapter:fcm`. 12. **AC-19.1 Enforcement (note).** Nothing in the gate rejects a string literal in feature code; a grep finds none today. Smallest change: a literal check in `build-logic`'s `ArchitectureCheckTask`. Owner: build-logic. 13. **Missing tests on existing code (not AC gaps):** - `SyncEngineTest`: the 429 retry, and the D-040 whole-request refusal (400, 413, 409, 422 lead to the pull plus `last_error_code`). - `:data`: `CategoryRepositoryImpl.reorder` (one PATCH per changed `sort_order`), and the recurring `toWire` splits per mode. - The WorkManager unique names and policies. - The server CLI: `healthcheck`, `migrate`, and the 10 s SIGTERM drain of `serve`. Owners: `:data`, and a server plan (code-overhaul phase). 14. **AC-5.1 Home gate without a partner (note).** `KvittApp.kt:236-240` has no test; S-3 itself has screenshots and ViewModel tests. Low priority. For the code overhaul and security review, not AC items: - `android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettlementRepositoryImpl.kt:49-50` uses `checkNotNull`, which throws in main code when there is no session or no partner. The UI gate prevents it today. - HANDOFF's ops request for a partner-invite `kvitt-server` subcommand would need a spec change first, because §10.4's subcommand list is exhaustive. ## 4. TODO(spec), TODO and FIXME in sources `git grep -n -E 'TODO|FIXME'` over `android/`, `server/`, `infra/` and `tools/`: | file:line | text | note | |---|---|---| | `android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CategorySuggestion.kt:72` | `TODO(spec): not stated in category-suggestion.md` | CS-5 final tie-break (lower category id). SPEC §9.11 CS-5 now states it, so the marker is stale | | `tools/contracts-check.py:326` | `TODO(spec), category-suggestion.md does not say.` | Same tie rule, also stale | There is no other TODO or FIXME in android, server, infra or tools. The `XXX` hits are mktemp templates (`infra/cert-sync/sync.sh:36`, `infra/pi/lib.sh:22`, `infra/publish-apk.sh:59`) and a doc comment (`server/crates/kvitt-server/src/api/invites.rs:68`). ## 5. Coverage tables ### Part A: server side (kvitt-server, kvitt-domain, kvitt-import, infra) Read at `ed1b130`. Paths are relative to `server/crates/kvitt-server/` unless they start with `server/`, `infra/` or `docs/`. Test evidence is given as `file::test_fn`. No build was run; the evidence comes from reading the code and the tests. Every route of §14.3.4 is registered in `src/router.rs:31-102`, and `tests/skeleton/routes.rs::every_route_is_registered_and_implemented` checks this. #### Endpoints (§14.3.4) | id | requirement (one line) | status | evidence (file:line or test fn name) | note | |---|---|---|---|---| | E-1 | `GET /health`: status, versions, db check; 503 `degraded` | Done | src/router.rs:32, src/api/health.rs; skeleton/routes.rs::health_reports_ok_with_versions, ::health_is_503_degraded_when_the_database_is_gone | the under-100 ms timing on the Pi is not measured (AC-20.1) | | E-2 | `GET /setup/status` `{initialized}` | Done | src/router.rs:33, src/api/setup.rs; skeleton/routes.rs::setup_status_follows_the_admin_user; core_api/invites.rs::e3_admin_setup_creates_the_admin_and_e2_turns_initialized | | | E-3 | `POST /invites/accept` with token, invite_kind, user, device, household; errors | Done | src/router.rs:34, src/api/invites.rs:234-; core_api/invites.rs::e3_* (6 tests) | | | E-4 | `GET /i/{code}` landing HTML, sv/en, `from`, 404 | Done | src/router.rs:35-37, src/api/landing.rs:203-207; core_api/landing.rs::e4_* (4 tests); sync_api/serve.rs::empty_invite_code_is_the_html_404_page | | | E-5 | `GET /apk/latest`, `not_found` before publishing | Done | src/router.rs:38, src/api/apk.rs; core_api/apk.rs::e5_not_found_until_published_then_read_on_every_request | | | E-6 | `GET /apk/files/{file_name}` | Done | src/router.rs:39; core_api/apk.rs::e6_serves_existing_files_matching_the_pattern_only | | | E-7 | `GET /me` | Done | src/router.rs:43, src/api/me.rs; core_api/me.rs::e7_returns_user_device_household_and_public_url | | | E-8 | `PATCH /me` display name | Done | src/router.rs:43; core_api/me.rs::e8_changes_the_own_display_name_with_a_new_server_version, ::e8_invalid_request_and_validation_failed | | | E-9 | `PUT /me/picture` (5 MiB layer) | Done | src/router.rs:44-49, src/api/pictures.rs:39-43; core_api/pictures.rs::e9_* (4 tests) | | | E-10 | `DELETE /me/picture` with `-removed` marker | Done | src/api/pictures.rs:255-273; core_api/pictures.rs::e10_removes_idempotently_and_the_next_upload_continues_the_count | | | E-11 | `GET /users/{id}/picture?size=` | Done | src/router.rs:50, src/api/pictures.rs:281-289; core_api/pictures.rs::e11_not_found_and_invalid_request, ::e9_crops_resizes_and_reencodes_and_e11_serves_both_sizes | | | E-12 | `POST /invites` (admin), link with `?from=` | Done | src/router.rs:51-54, src/api/invites.rs:369-387; core_api/invites.rs::e12_partner_invite_matches_the_example, ::e12_device_invite_for_either_user_and_its_errors | | | E-13 | `GET /invites`, pending only | Done | src/api/invites.rs:429; core_api/invites.rs::e13_lists_pending_invites_only | | | E-14 | `DELETE /invites/{id}`, idempotent | Done | src/api/invites.rs:473; core_api/invites.rs::e14_cancel_is_idempotent_and_its_errors | | | E-15 | `GET /devices`, admin all / member own | Done | src/api/devices.rs:31; core_api/devices.rs::e15_admin_sees_all_devices_member_own_ordered_by_created_at | | | E-16 | `DELETE /devices/{id}`, `cannot_revoke_current_device` | Done | src/api/devices.rs:61-72; core_api/devices.rs::e16_* (3 tests) | | | E-17 | `PUT /devices/current/push-token` | Done | src/api/devices.rs:100-110; core_api/devices.rs::e17_sets_and_clears_the_fcm_token, ::e17_invalid_request | | | E-18 | `POST /accounts` | Done | src/router.rs:65, src/api/accounts.rs; core_api/accounts.rs::e18_* (3 tests) | | | E-19 | `PATCH /accounts/{id}` | Done | core_api/accounts.rs::e19_renames_and_changes_the_ratio_like_the_example, ::e19_errors | | | E-20/E-21 | archive and unarchive an account, idempotent | Done | src/router.rs:70-77; core_api/accounts.rs::e20_e21_archive_and_unarchive_are_idempotent | | | E-22 | `DELETE /accounts/{id}`, `account_not_empty` | Done | core_api/accounts.rs::e22_soft_deletes_an_empty_account, ::e22_account_not_empty_for_expense_settlement_or_template | | | E-23 | `POST /categories` | Done | core_api/categories.rs::e23_creates_with_sort_order_max_plus_10, ::e23_invalid_request_and_validation_failed | | | E-24 | `PATCH /categories/{id}` (rename, reset, reorder) | Done | core_api/categories.rs::e24_renames_a_default_and_reorders_like_the_example_then_resets_the_name, ::e24_errors | | | E-25/E-26 | archive and unarchive a category | Done | core_api/categories.rs::e25_e26_archive_and_unarchive | | | E-27 | `DELETE /categories/{id}` | Done | core_api/categories.rs::e27_deletes_unused_custom_categories_only, ::e27_category_in_use_by_expense_or_template | | | E-28 | `POST /recurring-templates`, server mints the id, due period generated before reply | Done | src/api/recurring.rs; core_api/recurring.rs::e28_* (4 tests); core_api/generation.rs::catch_up_generates_every_due_period_on_create_with_audit_and_push | | | E-29 | `PATCH /recurring-templates/{id}` | Done | core_api/recurring.rs::e29_pauses_like_the_example_and_edits_fields, ::e29_errors | | | E-30 | `DELETE /recurring-templates/{id}` | Done | core_api/recurring.rs::e30_soft_deletes_and_then_not_found | | | E-31 | `GET /sync` pull | Done | src/router.rs:100, src/sync/pull.rs; sync_api/pull.rs (7 tests) | | | E-32 | `POST /sync` push | Done | src/sync/push.rs; sync_api/push_rules.rs (25 tests) | | | E-33 | `POST /settlements/proposal` | Done | src/router.rs:101, src/api/proposal.rs; core_api/proposal.rs::e33_* (4 tests) | | | OpenAPI | the generated document equals `contracts/openapi.yaml` | Done | tests/openapi_contract.rs::generated_document_equals_contract | | #### CLI (§10.4) | id | requirement (one line) | status | evidence (file:line or test fn name) | note | |---|---|---|---|---| | CLI-serve | migrations, scheduler, push sender, HTTP; SIGTERM drain 10 s; exit 0 | Done | src/cli/mod.rs:95-131, :25 | no automated test of the SIGTERM drain; used on the dev stack | | CLI-bootstrap | FR-1; exit 0, or 2 when already initialized | Done | src/cli/bootstrap.rs:27-74; core_api/cli.rs::bootstrap_prints_an_admin_setup_invite_cancels_the_previous_one_then_refuses | | | CLI-pair-device | `--user`; exit 0, or 3 for an unknown user | Done | src/cli/pair_device.rs:19-64; same cli.rs test (lines 77-98) | | | CLI-rotate-epoch | new random epoch on stdout; exit 0 | Done | src/cli/rotate_epoch.rs; sync_api/serve.rs::rotate_epoch_sets_a_new_random_epoch_and_exits_0 | | | CLI-healthcheck | GET 127.0.0.1:/health; exit 0 on 200, else 1 | Done | src/cli/healthcheck.rs:16-59; infra/pi/compose.yaml:65-66, infra/dev/compose.yaml:52-53 | no unit or integration test of the subcommand itself | | CLI-migrate | runs migrations only; exit 0 | Done | src/cli/mod.rs:88-93 | the CLI path is untested; `db::migrate` is tested in skeleton/migration.rs | #### Acceptance criteria (server side) | id | requirement (one line) | status | evidence (file:line or test fn name) | note | |---|---|---|---|---| | AC-1.1 | bootstrap prints the code, link and expiry; exit 0 | Done | src/cli/bootstrap.rs:42-49; core_api/cli.rs::bootstrap_prints_… | | | AC-1.2 | users exist: `already initialized` on stderr, exit 2 | Done | src/cli/bootstrap.rs:65-67; cli.rs lines 67-75 | | | AC-1.3 | rerunning cancels the previous admin_setup invite | Done | src/cli/bootstrap.rs:35; cli.rs line 61 | | | AC-1.4 | accepting admin_setup creates the admin, the device and a token | Done | core_api/invites.rs::e3_admin_setup_creates_the_admin_and_e2_turns_initialized | | | AC-1.5 | `/setup/status` false, then true | Done | skeleton/routes.rs::setup_status_follows_the_admin_user | | | AC-1.6 | `pair-device --user` creates a device invite | Done | core_api/cli.rs lines 77-98 | | | AC-2.1 (server) | partner invite: `household_full`; a new one cancels the pending one | Done | src/api/invites.rs:385-387; core_api/invites.rs::e3_invite_invalid_for_a_cancelled_code_and_one_hour_before_expiry_it_works, ::e12_device_invite_for_either_user_and_its_errors | | | AC-2.3 | single use, 72 h, uniform `invite_invalid` | Done | src/api/invites.rs:33, :178; core_api/invites.rs::e3_invite_invalid_for_unknown_expired_used_cancelled_and_malformed_codes | | | AC-2.4 (server) | accept returns token, invite_kind, user, device, household; partner creates the member | Done | src/api/invites.rs:234-347; core_api/invites.rs::e3_partner_creates_the_member_and_device_pairs_an_existing_user | | | AC-2.5 | landing page sv/en, buttons, grouped code, `from` passthrough, no-referrer, no-store, 404 | Done | src/api/landing.rs:203-207; core_api/landing.rs (4 tests) | | | AC-2.12 | more than 10 failed accepts per IP per 60 min give `rate_limited` | Done | src/api/rate_limit.rs; core_api/rate_limit.rs::ten_failed_invite_accepts_lock_the_ip_for_the_rest_of_the_hour | | | AC-3.2 (server) | revoke rules: admin any, member own, never the current device | Done | src/api/devices.rs:61-72; core_api/devices.rs::e16_* | | | AC-3.3 (server) | revoked device gets 401 `device_revoked` | Done | src/auth.rs:127-136; core_api/devices.rs::e16_admin_revokes_another_device_which_then_gets_device_revoked | the phone side is out of this part | | AC-3.4 | a revoked device gets no pushes | Done | src/push/sender.rs:87,94 (`revoked_at IS NULL`); push_fcm/fcm.rs::recipients_are_the_other_users_live_devices_with_a_token_recurring_goes_to_both | | | AC-3.5 | never deleted; `revoked_at` set, `fcm_token` cleared | Done | src/api/devices.rs:72; devices.rs test lines 60-70 | | | AC-4.4 | image rules: types, 5 MiB, 8000 px, EXIF, crop, JPEG 85 at 512 and 128, strip, flatten, version | Done | src/api/pictures.rs:3-43,73; core_api/pictures.rs::e9_* | | | AC-5.2 | `household_incomplete` for accounts, templates, expense and settlement mutations | Done | src/api/rows.rs:105-110; accounts.rs::e18_household_incomplete_before_the_partner_joins; recurring.rs::e28_household_incomplete; push_rules.rs::step2_household_incomplete_fails_the_whole_request | | | AC-6.2 | names unique, case-insensitive after trimming, `name_taken` | Done | src/api/accounts.rs:20-31; accounts.rs::e18_invalid_request_validation_failed_and_name_taken | | | AC-6.5 (server) | delete only when empty (expense, allocation, template); tombstone | Done | core_api/accounts.rs::e22_account_not_empty_for_expense_settlement_or_template | | | AC-7.2 | fourteen seeded defaults | Done | migrations/0001, 0002; skeleton/migration.rs::a_fresh_database_holds_exactly_the_fourteen_defaults | | | AC-7.3 | `sort_order` = max + 10 | Done | core_api/categories.rs::e23_creates_with_sort_order_max_plus_10 | | | AC-7.6 (server) | `category_in_use`, `category_is_default`, soft delete | Done | core_api/categories.rs::e27_* | | | AC-9.2 | 06:00 Europe/Stockholm, days 29-31 clamp | Done | core_api/generation.rs::occurrence_is_at_0600_europe_stockholm_in_summer_and_winter, ::days_29_30_31_clamp_to_the_last_day_including_february_2027 | | | AC-9.3 / §9.5 | catch-up, idempotency, split resolution, pause and resume without backfill, clock jumps | Done | generation.rs::catch_up_…, ::an_existing_period_is_skipped_silently_by_the_unique_index, ::mode_default_resolves_the_account_ratio_at_generation_time, ::modes_ratio_full_and_exact_use_the_template_splits, ::paused_generates_nothing_and_resume_does_not_backfill, ::resume_before_start_period_keeps_the_start, ::end_period_stops_generation_and_next_occurrence_becomes_null; scheduler_daily/scheduler.rs::runs_at_start_then_at_06_00_and_survives_clock_jumps, ::next_06_00_is_local_time_across_both_dst_changes | | | AC-9.4 (server) | template edits affect only later expenses | Done | generation.rs::archived_account_continues_and_edits_affect_only_later_expenses | | | AC-9.5 | a deleted generated expense is not recreated | Done | generation.rs::a_deleted_generated_expense_is_not_recreated | | | AC-9.6 | audit `source` recurring; push to both users | Done | sync_api/audit.rs::generation_writes_recurring_audit_with_server_now_and_no_device; push_fcm/fcm.rs::recipients_…_recurring_goes_to_both | | | AC-9.7 (server) | `next_occurrence_date` computed by the server | Done | kvitt-domain/src/recurring.rs; generation.rs::end_period_stops_generation_and_next_occurrence_becomes_null | | | AC-11.8 | E-33 equals `propose_allocation`, shared vectors | Done | kvitt-domain/tests/vectors.rs::every_vector_file_passes; core_api/proposal.rs::e33_matches_the_example, ::e33_honours_exclude_settlement_id_and_settlements | | | AC-12.1 | audit for every accepted create, update, delete (app, scheduler, importer); append-only | Done | src/audit/mod.rs; sync_api/audit.rs::entries_are_append_only_create_update_delete; kvitt-import tests/import.rs::english_export_imports_expenses_settlements_and_audit | | | AC-12.2 | entry fields: actor device null for scheduler and importer, client_at, server_at, diff §14.4.7 | Done | src/audit/diff.rs (with unit tests); sync_api/audit.rs; push_rules.rs::step11_newer_update_writes_the_diff_and_keeps_server_owned_fields | | | AC-13.1 | push to the other user's live devices with a token | Done | src/push/sender.rs:87-94; push_fcm/fcm.rs::recipients_…; sync_api/e2e.rs::create_on_device_a_pull_on_device_b_row_audit_and_one_push | | | AC-13.2 | recurring generation goes to both users | Done | push_fcm/fcm.rs::recipients_…_recurring_goes_to_both | | | AC-13.3 | the importer sends no pushes | Done | server/crates/kvitt-import/src/db.rs:4 (no push queue in the crate) | true by construction; no explicit test | | AC-13.4 | more than 3 notifiable changes give one `sync_batch` | Done | src/sync/push.rs:36,713-715; push_rules.rs::more_than_3_notifiable_changes_send_one_sync_batch_3_send_each | | | AC-13.7 (server) | sends to `message.fid` | Done | src/push/fcm.rs:116-121; push_fcm/fcm.rs::payload_of_every_type_equals_the_contract_example | | | AC-13.8 | no personal content in the payload | Done | push_fcm/fcm.rs::payload_of_every_type_equals_the_contract_example; core_api/logging.rs::logs_hold_no_personal_content | | | §14.6 queue | bounded at 100, the oldest dropped | Done | src/push/queue.rs:12,62; push_fcm/fcm.rs::full_queue_drops_the_oldest | | | §14.6 auth | RS256 JWT, token cached until expiry minus 5 min, 401 drops the token | Done | src/push/fcm.rs:268,302; fcm.rs::oauth_jwt_and_send_request_follow_the_http_v1_flow, ::access_token_is_cached_until_5_minutes_before_expiry | | | §14.6 retries | 1 s, 5 s, 25 s on 429, 5xx, network | Done | src/push/sender.rs:26-28; fcm.rs::production_retry_delays_are_1_5_25_seconds, ::retries_429_and_5xx_…, ::network_errors_are_retried | | | §14.6 unregistered | `UNREGISTERED` or 404 clears the token if unchanged | Done | src/push/fcm.rs:93,135-142,296; fcm.rs::unregistered_or_404_clears_the_devices_token | | | §14.6 disabled | missing credentials: Noop, one warning | Done | fcm.rs::missing_or_unusable_credentials_disable_push | | | §14.4.2 pull | cursor_ahead, params, order, paging, tombstones, epoch | Done | src/sync/pull.rs; sync_api/pull.rs (7 tests) | | | §14.4.4 pre-checks | 0 or more than 100 is `batch_too_large`, 1 MiB, invalid envelope | Done | push_rules.rs::batch_of_101_is_batch_too_large_and_0_too_and_100_is_fine, ::body_over_1_mib_is_payload_too_large, ::malformed_body_or_envelope_is_invalid_request | | | §14.4.4 steps 1-4 | replay, household, shape, clock_skew | Done | push_rules.rs::step1_…, ::duplicate_mutation_ids_…, ::step2_…, ::step3_…, ::step4_… | | | §14.4.4 steps 5-8 | tombstone, delete wins, never-synced tombstone, validation and references | Done | push_rules.rs::step6_…, ::step7_… (2), ::step8_… (5) | | | §14.4.4 steps 9-12 | insert, stale, diff update, empty diff, record mutation | Done | push_rules.rs::step9_…, ::step10_…, ::step11_… (2), ::step12_…; sync_api/conflicts.rs (5 tests) | | | AC-15.6 (server) | 14 days offline with 200 changes syncs | Done | sync_api/offline.rs::fourteen_days_offline_200_changes_all_sync | the phone side is out of this part | | §14.1 pruning | `sync_mutations` older than 90 days pruned at 04:00 | Done | src/tasks/scheduler.rs:106-109; scheduler.rs::pruning_deletes_rows_older_than_90_days_and_keeps_the_rest, ::pruning_runs_at_04_00_local | | | §8.6 | 60/min per IP, 600/min per device, XFF from the trusted proxy only, body limits 1 MiB and 5 MiB | Done | src/router.rs:22-24,108-109; core_api/rate_limit.rs (5 tests); sync_api/serve.rs::two_client_addresses_get_separate_rate_limit_buckets_end_to_end | | | AC-17.1 | `kvitt-import` CLI, one file per group into a named account | Done | server/crates/kvitt-import/src/cli.rs:87-114; tests/import.rs::cli_end_to_end_exit_codes, ::cli_help_and_usage_errors | | | AC-17.2 | a rerun creates nothing (`import_key`) | Done | kvitt-import/src/db.rs:111,345; import.rs::rerun_creates_nothing_and_existing_account_is_reused | | | AC-17.3 | fixed category table; unknown names map to `other` and are listed | Done | kvitt-import/data/splitwise_categories.csv; import.rs::category_table_matches_the_spec, english_export… lines 198-262 | | | AC-17.4 | payments become settlements with one allocation | Done | import.rs lines 296-300 | | | AC-17.5 | `--dry-run` rolls back, same counts and exit status | Done | import.rs::dry_run_writes_nothing_and_reports_the_same_counts | | | AC-17.6 | audit `source` import, no pushes | Done | kvitt-import/src/db.rs:300; import.rs lines 335- | | | AC-18.1 | nightly 03:30 snapshot and pictures archive, gzip, rclone to Drive with `--immutable` | Partial | infra/backup/Dockerfile:22-27, infra/backup/backup.sh:27-69, infra/pi/compose.yaml (backup service) | implemented; never run on the Pi (M2c-pi is owner-run, D-100); no automated test | | AC-18.2 | restore drill documented in `docs/validation/restore-drill.md` | Missing | infra/RESTORE.md (88 lines, the procedure) | `docs/validation/` does not exist; the drill has not been run | | AC-20.1 | `/health` unauthenticated, in under 100 ms on the Pi | Partial | skeleton/routes.rs::health_* | function Done; the 100 ms target on the Pi is not measured (not verifiable without the Pi) | | AC-20.2 | the compose healthcheck of `server` uses it | Done | infra/pi/compose.yaml:65-66; infra/dev/compose.yaml:52-53 | | ### Part B: Android side, FR-2 to FR-12, §9.2/§9.8/§9.9/§9.11, screens S-1 to S-10 (main at ed1b130) Convention: Done = code present in the real `:data`/feature/`:app` path and a test (unit, screenshot or the M8 phone check) demonstrates it; Partial = code present but the §4 evidence rule is not met, or a sub-clause missing; Not verifiable without device = only an on-device behaviour (system UI) proves it. Test names are JUnit method names in the cited file. Paths are relative to `android/`. | id | requirement (one line) | status | evidence (file:line or test name) | note | |---|---|---|---|---| | AC-2.1 (phone) | Admin creates a partner invite from Settings; new one cancels pending | Done | feature/settings/.../invites/InvitesViewModel.kt:88 `invitePartner`; hub/SettingsHubViewModel.kt:47; StateHolderTest.invitesCreateAndRefuse | `household_full`, cancel-previous are server rules (fork A) | | AC-2.2 (phone) | Admin creates a device invite for either user | Done | InvitesViewModel.kt:91 `inviteDevice(user)`; InvitesScreen.kt:198 | | | AC-2.4 (phone) | Accept takes code, device name, display name; returns kind, user, household | Done | data/.../repo/SessionRepositoryImpl.kt; SessionRepositoryTest.pair_storesCredentials_writesTheHousehold_andStartsAFullSync, pair_reportsTheInviteKind_… | | | AC-2.6 | `kvitt://invite` and `https://…:8443/i/` open S-1 prefilled; `from` names the admin; ignored while paired | Done | app/src/main/AndroidManifest.xml:31-47 (`autoVerify="false"`, scheme kvitt host invite, https host/port/pathPrefix /i/); app/.../link/AppLinks.kt; nav/KvittApp.kt:210-221 (prefill), :130-137 (S-1 only when unpaired); AppLinksTest.appLinkFillsServerAndCode, landingLinkFillsTheServerFromTheLink, linksWithTheAdminNameCarryIt_andOldLinksStillParse; InviteLinkTest | "Ignored while paired" holds because S-1 is not in the paired graph; the parsed invite stays in `AppViewModel.invite` (AppViewModel.kt:47-49) and would prefill S-1 if the phone later becomes unpaired. No test of the paired case. Chooser behaviour is device-only | | AC-2.7 | Manual entry; URL prefilled in release; code normalised per §8.3 | Done | feature/onboarding/.../ServerAddress.kt:7; PairingViewModelTest.theCodeIsNormalisedWhileTyping, aFirstPairingSendsTheNormalisedCodeAndTheTrimmedName; ComponentLogicTest.inviteCodesAreNormalisedPerSpec83 | | | AC-2.8 | Release https only; debug also http for localhost/10.0.2.2/private LAN, in `normaliseServerUrl` | Done | SessionRepositoryTest.serverUrls_releaseHttpsOnly_debugAlsoLocalHttp; ServerAddressTest.debugAcceptsHttpOnlyForLocalAndPrivateAddresses; PairingViewModelTest.releaseRefusesHttpBeforeCallingTheServerAndDebugAllowsTheLan | | | AC-2.9 | Full sync after pairing, then ask POST_NOTIFICATIONS | Not verifiable without device | app/.../AppViewModel.kt:60-66 (waits for `lastSuccessAt != null && !running`); nav/KvittApp.kt:104-116 | No unit test of AppViewModel; the system prompt is device-only | | AC-2.10 | Partner pairing goes to S-2; every other pairing to Home | Done | nav/KvittApp.kt:213-217; PairingViewModelTest.aDeviceInviteGoesHomeEvenForTheMember_andTheLinkNamesTheAdmin, theAdminGoesHomeAndTheAgainVariantSendsNoName | | | AC-2.11 | Admin sees pending invites (kind, created, expiry) and cancels each | Done | InvitesScreen.kt:223-263; InvitesViewModel.kt:118 `cancel`; DevicesInvitesAboutScreenshotTest | | | AC-3.1 | Devices list: name, owner, paired, last seen, app version name else code, "This device" | Done | feature/settings/.../devices/DevicesScreen.kt; DevicesViewModel.kt:77; StateHolderTest.devicesLoadFailAndRevoke; DevicesInvitesAboutScreenshotTest | Revoked devices are filtered out of the list (DevicesViewModel.kt:77); the spec does not ask to show them | | AC-3.2 (phone) | Admin revokes any but current; member own but current | Done | DevicesViewModel.kt:39-40 `canRevoke`; PureRulesTest.revokeRules | Server enforces too (fork A) | | AC-3.3 | On 401 device_revoked: delete token, DB, cached pictures; show S-18 "Pair again" | Done | data/.../di/DataModule.kt:166-170 (clearAllTables, Coil memory and disk cache); auth/SessionManager.kt:35; SyncEngineTest.deviceRevoked_wipesTokenAndLocalData; CredentialStoreTest.revokedDeletesTokenAndKey_unrecognisedKeepsTheUser; KvittApp.kt:133, :226-231 | | | AC-4.1 | Display name and optional picture | Done | model User(displayName, pictureVersion) | | | AC-4.2 | Edit own display name in Settings, online only | Done | feature/settings/.../profile/ProfileViewModel.kt; StateHolderTest.profileNameRulesAndSave, profileOfflineDisablesEverything | | | AC-4.3 | Picture from the Android photo picker or removed; online only | Not verifiable without device | profile/ProfileScreen.kt:59-74 and onboarding ProfileStepScreen.kt:96-117 (`PickVisualMedia`); StateHolderTest.profileRefusedNameIsInlineAndPictureErrorsAreSnackbars; OtherViewModelsTest.offlineThePictureCannotChange | Picker itself is system UI | | AC-4.5 | Initials on avatar-1/avatar-2, slot a stable function of the user id | Done | core/design/.../component/People.kt:55; ComponentLogicTest.initialsAreTheFirstLettersOfTheFirstTwoWords, avatarSlotIsTheLowestIdFirstOnBothPhones | | | AC-4.6 | Pictures cached on disk (Coil) keyed by user id and picture_version | Not verifiable without device | data/.../repo/Pictures.kt:26-38 (disk cache, URL carries user id and `v=`) | Offline display not tested | | AC-5.1 | Without partner, Home is "Waiting for your partner" with "Invite partner"; no creation offered | Done | nav/KvittApp.kt:236-240; feature/onboarding WaitingScreen.kt; OtherViewModelsTest.waitingShowsThePendingInviteExpiryOnlyOnline; OnboardingScreenshotTest | Gate in `:app` not unit-tested | | AC-6.1 | Create account online, starts 50/50, percent with two decimals, other = 100 − first | Done | feature/account AccountEditorViewModel.kt; AccountViewModelTest.newAccountStartsAtFiftyFiftyAndNeedsAName, percentsAcceptTwoDecimalsAndTheOtherFollows, createSavesBasisPointsAndReportsTheNewId | | | AC-6.2 (phone) | Duplicate name shows `name_taken` | Done | AccountViewModelTest.nameTakenIsShownUnderTheName | Rule is server-side | | AC-6.3 | Rename or change ratio online; ratio change affects only later expenses | Done | AccountViewModelTest.editStartsFromTheAccount; default ratio copied into the expense at save (ExpenseRepositoryImpl.kt save; RepositoriesTest.preview_usesTheAccountDefault) | | | AC-6.4 | Archive/unarchive online; hidden from pickers; collapsed on Home; still in balance, analytics, settle | Done | data ExpenseRepositoryImpl.kt:57 (pickers active only); HouseholdRepositories.kt:100-101; feature/settle SettleViewModel.kt:81-82 (archived addable); AccountViewModelTest.archiveNavigatesBackWithTheName, unarchiveStaysWithASnackbar, archivedAccountHidesTheFab; HomeViewModelTest.archivedSectionToggles | Undo snackbar wired: KvittApp.kt:289-293 | | AC-6.5 (phone) | Delete only when no live expense, allocation or template references it | Done | data HouseholdRepositories.kt:151-154 `accountDeletable`; AccountViewModelTest.emptyAccountMayBeDeletedOnlyOnline, deleteOfANonEmptyAccountIsRefused, deleteAsksThenDeletes | | | AC-6.6 | Accounts by created_at ascending | Done | data Snapshot.kt:90 | | | AC-6.7 | Account create/edit disabled offline with "Needs connection" | Done | AccountEditorScreen.kt:171; AccountViewModelTest.offlineCannotSave, offlineActionSaysNeedsConnection | | | AC-7.1 | One global editable list: name, icon token, colour token | Done | model Category; ContractTest.categoryIconsAreTheThirtyThreeTokensOfSpec9_7InOrder | | | AC-7.4 | Rename, re-icon, recolour, reorder; "Reset name" for defaults | Done | StateHolderTest.categoryResetNameSavesNull, categoryReorderFailureRestoresTheOrder; CategoriesScreenshotTest | | | AC-7.5 | Archived categories not offered for new expenses/templates, kept on existing ones and analytics | Done | data ExpenseRepositoryImpl.kt:58; ComponentLogicTest.quickRowSkipsArchived; StateHolderTest.archiveReturnsWithASnackbarForTheList | | | AC-7.6 (phone) | Delete only if unused and not default; reason shown | Done | EditorCategoriesTest.deletable_falseForDefaults_unknownAndDeleted, deletable_followsExpensesAndTemplates_archivedStillCounts; StateHolderTest.categoryDeleteAffordanceFollowsDeletable | | | AC-7.7 | No uniqueness rule for category names | Done | no duplicate check in feature/settings categories (CategoryEditorViewModel.kt) | Absence of a rule | | AC-8.1 | Expense fields as listed | Done | model ExpenseDraft / ExpenseDetailState; data db/Entities.kt | | | AC-8.2 | Create/edit/delete any expense online or offline; UUIDv7 client id | Done | RepositoriesTest.expenseSave_writesAPendingRowWithAMutationIdAndTimestamps; ExpenseEditorViewModelTest.offlineSave_isAllowed_andReportsOffline; data util/Ids.kt | | | AC-8.3 | Payer either user, defaults to current user | Done | ExpenseEditorViewModelTest.newExpense_appliesTheDefaults | | | AC-8.4 | Any date within limits, default today in phone zone | Done | ExpenseEditorViewModelTest.dateTooFarAhead_comesBackAsValidationFailed, newExpense_appliesTheDefaults | | | AC-8.5 | Split modes default/ratio/exact/full, shares per §9.1 | Done | domain Split.kt; VectorTest (compute_split.json); ExpenseEditorViewModelTest.ratio_…, full_defaultsToThePersonWhoDidNotPay | | | AC-8.6 | Editor defaults: last-used account else oldest active; category suggestion then last-used else `other`; none if `other` archived | Done | data ExpenseRepositoryImpl.kt:54-74; RepositoriesTest.editorDefaults_lastUsedAccountAndCategory_elseOldestActiveAndOther; ExpenseEditorViewModelTest.noDefaultCategory_showsNoCategory_andBlocksSaving | | | AC-8.7 | Exact mode: other share read-only; typed > amount blocks saving | Done | ExpenseEditorViewModelTest.exactShareLargerThanTheAmount_blocksSaving | | | AC-8.8 | Account change in default mode re-copies ratio; other modes kept | Done | ExpenseEditorViewModelTest.accountChange_inDefaultMode_recopiesTheRatio | | | AC-8.9 | Delete asks for confirmation, soft delete, gone from lists/balances/analytics | Done | ExpenseDetailViewModelTest.delete_asksThenDeletes; RepositoriesTest.home_balancesIncludePendingRows_andExcludeTombstones, analytics_includePending_excludeDeleted; SyncEngineTest.delete_pushesTheFullEntityWithDeletedAt_andKeepsTheTombstone | | | AC-8.10 | Rows: icon, colour, description, date, amount, effect line; date desc then created_at desc | Done | core/design component/Ledger.kt, Money.kt:137-148; data HouseholdRepositories.kt:157-166 (ledgerOrder); AccountScreenshotTest | | | AC-8.11 | Generated expense is ordinary; detail shows "Recurring" with template name | Done | ExpenseDetailScreen.kt:217, :238-239; ExpenseDetailViewModelTest.recurring_namesTheTemplate | | | §9.2 | Effect line Lent / Borrowed / Not involved / Paid for yourself | Partial | data Snapshot.kt:124-132 `effectFor`; core/design Money.kt:137-148 | Logic is correct by reading, but no `:data` unit test covers the four cases (screenshots use the fakes) | | CS-1 | Suggestion only for a new expense, never on edit | Done | ExpenseEditorViewModel.kt:217-218, :493; ExpenseEditorViewModelTest.editMode_loadsTheExpense_keepsTheCategory_andDeletes | | | CS-2 | Picking by hand stops suggestions for the session | Done | ExpenseEditorViewModel.kt:339; ExpenseEditorViewModelTest.suggestion_stopsOncePickedByHand_andClearingReturnsTheDefault | | | CS-3 | Recompute on description and account change, local only | Done | ExpenseEditorViewModel.kt:326, :347, :491-502 | 150 ms debounce (not in spec, harmless) | | CS-4 | Normalisation (NFC, lowercase, delete ' ’ :, non-letter/digit/& to space) | Done | domain CategorySuggestion.kt:49; CategoryKeywordsTest.platformUnicodeText_nfc_letters_nd_digits_supplementary; VectorTest (suggest_category.json) | | | CS-5 | Order: exact history, first-token history, keywords, default; ties most recent then id | Done | domain CategorySuggestion.kt:74-122; CategorySuggestionTest.history_countsEveryAccountAndBothUsers_mostOftenWins_deletedLeftOut, history_tie_goesToTheMostRecentlyUsed, fallback_isTheCategoryLastUsedInTheAccount_elseOther | | | CS-6 | Keyword table token/prefix/phrase, longest wins, archived skipped | Done | CategorySuggestion.kt:134-143; CategorySuggestionTest.keywords_applyWithoutHistory_andNeedTheKeysCategory, archivedCategories_areSkipped_inHistoryAndKeywords; data CategoryKeywordsTest.bundledTableIsByteIdenticalToTheContractFile | | | CS-7 | No marker; clearing returns to step-4 default | Done | ExpenseEditorViewModelTest.suggestion_stopsOncePickedByHand_andClearingReturnsTheDefault; ExpenseScreenshotTest | | | AC-10.1 | Home global balance wording from the viewer's side | Done | HomeViewModelTest.householdShowsBalanceAccountsAndFab, settledHasNoSettleButton; HomeScreenshotTest | | | AC-10.2 | Active accounts with balance; archived collapsed | Done | HouseholdRepositories.kt:100-101; HomeViewModelTest.archivedSectionToggles | | | AC-10.3 | Account screen: balance and expenses and settlements interleaved by date | Done | HouseholdRepositories.kt:157-166; RepositoriesTest.accountLedger_settlementRowCarriesTheAllocationToThatAccount; AccountViewModelTest.loadedAccountShowsBalanceMonthsAndFab | | | AC-10.4 | Balances on the phone, include pending, exclude deleted, update after local write | Done | RepositoriesTest.home_balancesIncludePendingRows_andExcludeTombstones; domain Balances.kt; VectorTest (compute_balances.json) | | | AC-10.5 | Global = sum of per-account balances, archived included | Done | RepositoriesTest.home_balancesIncludePendingRows_andExcludeTombstones (assert "AC-10.5") | | | AC-11.1 | Settlement: amount, date, optional note, ≥1 allocations | Done | model SettleRequest; SettleViewModelTest.invalidNoteIsReportedAndClearedOnEdit | | | AC-11.2 | Allocations sum to amount, each positive, account at most once | Done | domain Allocation.kt; VectorTest (validate_settlement.json); SettleViewModelTest.removeAndAddAccountsUpdateTheRows | | | AC-11.3 | Global settle-up defaults, proposal rows, "Add account" (archived too), confirm only when sums match, live remaining | Done | data SettlementRepositoryImpl.kt:57-61; SettleViewModel.kt:81-84; RepositoriesTest.settleStart_theUserWhoOwesPays_amountIsTheBalance; SettleViewModelTest.globalDefaultsAreTheProposal, zeroBalanceGivesEmptyAmountAndNoRows | | | AC-11.4 | Per-account settle-up: one allocation, defaults to account balance and debtor | Done | SettlementRepositoryImpl.kt:59; SettleViewModelTest.perAccountHasOneFixedRowThatFollowsTheAmount | | | AC-11.5 | Over-settle allowed, warned per row, confirm dialog repeats | Done | SettleViewModel.kt:102, :194; SettleViewModelTest.overSettlingWarnsAndConfirmOpensTheDialog, saveWithoutWarningsIsDirect | | | AC-11.6 | Create/edit/delete offline; editing excludes itself; saved rows count as edited; "Suggest again" | Done | SettleViewModel.kt:157, :184; SettleViewModelTest.editingStartsFromTheSavedSettlement, offlineWorksUnchanged; RepositoriesTest.settleState_listsTheAccountsTheSettlementLeavesUntouched | | | AC-11.7 | Settlement row " paid X", account count when > 1; allocation in S-5 | Done | core/design Ledger.kt:115-128; data Snapshot.kt:141; RepositoriesTest.accountLedger_settlementRowCarriesTheAllocationToThatAccount | | | AC-12.3 (phone) | Audit entries reach the phone through pull | Done | SyncEngineTest.pull_auditEntriesAreInsertOrIgnore | | | AC-12.4 | Detail timeline, newest last, field names in UI language, ids resolved to names | Done | data repo/Timeline.kt:31-69; core/design component/Timeline.kt:50-64, :76-93; RepositoriesTest.expenseDetail_timelineResolvesNames_andEndsWithThePendingLine | `splits`/`allocations` rendered as "name amount; …" (accepted in §14.2.4 v0.5) | | AC-12.5 | Pending local change ends the timeline with "Your change is waiting to sync" | Done | Timeline.kt (data):38-44; ExpenseDetailViewModelTest.loaded_withThePendingLineLast; SettlementDetailViewModelTest.pendingChangeEndsTheTimeline | | | §9.8 money | `1 234,50 kr` / `SEK 1,234.50`, no sign | Done | core/design format/MoneyFormatter.kt; MoneyFormatterTest.swedishUsesNoBreakSpacesAndCommaDecimal, otherLocalesPutSekFirstWithCommaThousands, neverShowsASign | | | §9.8 dates | List, detail, timeline, axis, day ranges | Done | DateFormatterTest.listDatesWithoutYearInTheCurrentYear, listDatesWithYearInOtherYears, detailDates, timesAre24HourInTheLocalZone, monthNamesAndLongDayRanges | | | §9.8 relative | "just now", "N min ago", "N h ago", else date and time | Done | DateFormatterTest.relativeLastSynced | | | §9.8 file sizes | SI MB one decimal half up | Done | MoneyFormatterTest.megabytesHaveOneDecimal | | | §9.9 | Online-only gate: validated network and 30 s after a network error; REST failure changes nothing locally | Done | data connectivity/Connectivity.kt; RepositoriesTest.onlineOnly_withoutValidatedNetwork_isOffline_andSendsNothing, onlineOnly_withinThirtySecondsOfANetworkError_isOffline, rest_networkFailure_changesNothingLocally; KvittApiTest.networkFailureIsNetworkError_andOpensTheThirtySecondWindow | Per-screen gating of S-13 to S-22 is fork C's | | S-1 Pair | Pair with URL, code, name; progress; errors invite_invalid, network, rate_limited | Done | PairingViewModelTest.errorsMapToTheirFields, whilePairingTheButtonIsBusyAndEditingClearsTheFieldError, offlinePairingGivesTheNetworkErrorUnderTheExpandedServer; OnboardingScreenshotTest | | | S-1 Paste code | Paste fills code (and server from a pasted link) | Done | PairingViewModelTest.pasteFillsCodeAndServerOrSaysTheCodeHasTwelveCharacters | | | S-2 Continue | Name prefilled, blank shows error, "Add picture", "You are joining " | Done | KvittApp.kt:223-225; OtherViewModelsTest.theProfileStepPrefillsTheNameAndNamesTheOwner, continueSavesAChangedNameAndStaysOnFailure, anUploadDisablesContinueUntilItEndsAndFailuresGiveTheirReason | | | S-3 Invite partner | Goes to S-21 | Done | KvittApp.kt:239 | | | S-3 Settings | Bottom bar to Settings | Done | WaitingScreen.kt:100 (`KvittNavigationBar`), KvittApp.kt:239 `onNavigate = shell::top` | | | S-3 must show | Expiry of pending invite; error snackbar with retry | Done | WaitingScreen.kt:91-93, :150-168; OtherViewModelsTest.aFailedInviteListIsAnErrorAndComingOnlineLoadsAgain | | | S-4 Add expense (FAB) | FAB opens S-7 | Done | KvittApp.kt:242; HomeViewModelTest.emptyAndOnlyArchivedHideTheFab | | | S-4 Settle up | Button when non-zero opens S-9 | Done | KvittApp.kt:243; HomeViewModel.kt:53 | | | S-4 open account | Row opens S-5 | Done | KvittApp.kt:244 | | | S-4 New account | Opens S-6, then S-5 of the new account | Done | KvittApp.kt:245, :301-304 | | | S-4 must show | Sync line (tap S-16), update banner with Later, expenses this month, empty state | Done | KvittApp.kt:246; HomeViewModel.kt:96-124; HouseholdRepositories.kt:93; HomeViewModelTest.offlineAndPendingAreReported, laterHidesTheBanner, firstUpdateOpensTheDialogAndContinueInstalls; HomeScreenshotTest | | | S-5 Add expense | FAB (not on archived) opens S-7 in this account | Done | KvittApp.kt:286; AccountViewModelTest.archivedAccountHidesTheFab; ExpenseEditorViewModelTest.openedFromAnAccount_startsInThatAccount | | | S-5 Settle this account | Opens S-9 per-account | Done | KvittApp.kt:287 | | | S-5 Edit account | Opens S-6 | Done | KvittApp.kt:288 | | | S-5 Archive / Unarchive | With undo snackbar on Home after archive | Done | KvittApp.kt:289-293; AppViewModel.kt:73-75; AccountViewModelTest.archiveNavigatesBackWithTheName, unarchiveStaysWithASnackbar | | | S-5 Delete (only if allowed) | Shown only when deletable, confirmation | Done | AccountDetailScreen.kt:160; KvittApp.kt:294; AccountViewModelTest.emptyAccountMayBeDeletedOnlyOnline, deleteAsksThenDeletes | | | S-6 Save | Name, split control, preview "Of 100,00 kr …", needs connection | Done | AccountEditorScreen.kt:171, :273-282; KvittApp.kt:301-304; AccountViewModelTest.createSavesBasisPointsAndReportsTheNewId | | | S-6 Cancel | Discard dialog title only | Done | AccountEditorScreen.kt:94, :132-137; AccountViewModelTest.closingWithChangesAsksFirst | | | S-7 Save | Saves and shows "Utgiften sparades" on the screen returned to | Done | KvittApp.kt:311-314; ExpenseEditorViewModelTest.tenSecondPath_amountDescriptionSuggestionSave, editSave_reportsAnEdit | | | S-7 Cancel | Discard dialog only with changes | Done | KvittApp.kt:310; ExpenseEditorViewModelTest.close_asksFirstOnlyWithChanges_andBackHidesTheKeypadFirst | | | S-7 Delete (edit mode) | Delete, closes detail too, snackbar | Done | KvittApp.kt:315-319; ExpenseEditorViewModelTest.editMode_loadsTheExpense_keepsTheCategory_andDeletes | | | S-7 must show | Four fixed rows, quick row, notes collapsed, live preview | Done | EditorOptionRowsLayoutTest.rowsStayPutWhenValuesChange; ExpenseEditorViewModelTest.quickRow_…; ExpenseEditorScreen.kt:265-266; ExpenseScreenshotTest | | | S-8 Edit | Opens S-7 in edit mode | Done | ExpenseDetailScreen.kt:130-131; KvittApp.kt:325 | | | S-8 Delete | Confirmation, back, snackbar | Done | ExpenseDetailScreen.kt:132; KvittApp.kt:326-329; ExpenseDetailViewModelTest.delete_asksThenDeletes | | | S-8 must show | Deleted state; missing state with "Synka nu" online only | Done | ExpenseDetailScreen.kt:143-170, :244-257; ExpenseDetailViewModelTest.missing_isNullAfterLoading, deleted_staysVisibleAsDeleted | | | S-9 Confirm | Saves; with warnings via dialog; snackbar on return | Done | KvittApp.kt:336-339; SettleViewModelTest.overSettlingWarnsAndConfirmOpensTheDialog, saveWithoutWarningsIsDirect | | | S-9 Cancel | Discard dialog only with changes | Done | KvittApp.kt:335; SettleViewModelTest.closeAsksOnlyWithChanges | | | S-9 must show | Direction swap, remaining, add account, date, note, untouched info line | Done | SettleViewModel.kt:163, :175; SettleViewModelTest.swapReproposesForTheOtherPayer; SettleScreenshotTest | | | S-10 Edit | Opens S-9 editing | Done | KvittApp.kt:345 | | | S-10 Delete | Confirmation, back, snackbar | Done | SettlementDetailScreen.kt:110, :139-147; KvittApp.kt:346-349; SettlementDetailViewModelTest.deleteAsksThenDeletes | | | S-10 must show | Missing state with "Synka nu" online only; deleted state | Done | SettlementDetailViewModel.kt:46-49, :97; SettlementDetailViewModelTest.missingOfflineCarriesTheConnectivityForSyncNow, deletedHasTheDeletionLineAndNoActions | | ### Part C: Android recurring, push, analytics, sync, update, localisation; S-11 to S-22; UI contract; TODO scan Audited at `ed1b130`, read-only. Paths are relative to `android/` unless they start with `server/`. "fork A" means the server-side rows are in part A. #### (1) Table | id | requirement (one line) | status | evidence (file:line or test name) | note | |---|---|---|---|---| | AC-9.1 | Create, edit, pause, resume and delete a template (online), with every field | Done | `data/.../repo/SettingsRepositories.kt:135-185` (E-28/E-29/E-30 via `OnlineGate`); `feature/settings/.../recurring/RecurringEditorViewModel.kt:187,221,250`; StateHolderTest `newTemplateDefaultsAndSave`, `editTemplateLoadsSplitAndMarksServerErrors` | Pause and resume are the editor's pause switch, saved with E-29 | | AC-9.4 | Editing a template changes only expenses generated later | Done | server test `generation.rs::archived_account_continues_and_edits_affect_only_later_expenses` | Server-side rule; the phone only sends E-29 | | AC-9.7 | List shows description, amount, account, day of month, paused state, next occurrence (server `next_occurrence_date`) | Done | `feature/settings/.../recurring/RecurringListScreen.kt:119-153`; `SettingsRepositories.kt:245` (`nextOccurrence = row.nextOccurrenceDate`); RecurringScreenshotTest | | | AC-9.8 | Default `start_period` (current month if the day is today or later, else next); new day = today; backfill up to 12 months | Done | `feature/settings/.../recurring/Periods.kt:24-33`; `RecurringForm.kt:136,200`; PureRulesTest `periods`, `templateValidation` | | | AC-13.5 | Notification only after its sync; text from local data in the phone's language; generic text when the sync fails within 20 s | Done | `app/.../push/PushWork.kt:25-30`; `data/.../sync/SyncTriggers.kt:70-75` (20 000 ms); `app/.../push/Notifications.kt:50-57`; NotificationTextsTest (`failedSyncGivesTheGenericTextWithoutContent`, `missingEntityGivesTheGenericText`); SyncTriggersTest `pushWaitGivesUpAfterItsTimeout` | Real delivery was seen in the M8 phone test (HANDOFF) | | AC-13.6 | A tap opens S-8 or S-10; Home for deletions, `sync_batch` and the generic text | Done | `app/.../push/NotificationTexts.kt` (targets); `nav/KvittApp.kt:136-144`; NotificationTextsTest `expenseDeletedOpensHome`, `settlementDeletedOpensHome`, `syncBatchCountsAndOpensHome`; AppLinksTest | | | AC-13.7 | Register by FID when paired and at each start; send every FID with E-17; unregister on revoke | Done | `app/.../push/PushHandler.kt:37,52-62,68-76`; `adapter/fcm/.../KvittMessagingService.kt:22-23` | The M8 phone test confirmed `fcm_token` was registered. The FID is sent only while PAIRED, and is not retried after a network failure until Firebase calls `onRegistered` again | | §14.6-1 | Phone step 1: `syncForPush`, waiting up to 20 s, in expedited work | Done | `PushWork.kt:40-54` (`setExpedited`); `SyncTriggers.kt:70-75` | | | §14.6-2 | Channel `activity` ("Activity"/"Aktivitet", importance default); id = hash of `entity_id`; `sync_batch` uses id 1 | Done | `Notifications.kt:27-35`; `KvittApplication.kt:34`; `NotificationTexts.kt` `NotificationIds` (SYNC_BATCH = 1, `entityId.hashCode()`); NotificationTextsTest `updateReplacesCreate`, `syncBatchIsFixed` | | | §14.6-3 | Tap targets as in AC-13.6 | Done | see AC-13.6 | | | §14.6-4 | An unknown `type` or a `v` other than 1 syncs only, with no notification | Done | `adapter/fcm/.../PushMapping.kt:19-21`; `PushHandler.kt:32-35`; PushMappingTest `otherPayloadVersionsAreSyncOnly`, `unknownTypeIsKeptForTheAppToIgnore`; NotificationTextsTest `unknownTypeShowsNothing` | | | AC-14.1 | Analytics are computed on the phone from Room, offline, with pending changes | Done | `data/.../repo/AnalyticsRepositoryImpl.kt:33-55`; RepositoriesTest `analytics_includePending_excludeDeleted`; AnalyticsViewModelTest `offlineWorksAndCountsPendingChanges` | | | AC-14.2 | Range presets and custom; "last N months" is whole months; default `last_6_months` | Done | `domain/.../Analytics.kt:82-104`; vectors `contracts/test-vectors/resolve_range.json` (VectorTest); ResolveRangeTest; AnalyticsRulesTest `defaultsAreThoseOfAc14` | | | AC-14.3 | Granularity month or ISO week (Monday start); default month | Done | `Analytics.kt:36-47,108-113`; spend_series vectors | | | AC-14.4 | Measure household or my share; default household | Done | `Analytics.kt:53-56`; `AnalyticsRepositoryImpl.kt:95-96` | | | AC-14.5 | Group by category or account; default category | Done | `Analytics.kt:57-60`; AnalyticsRulesTest `defaultsAreThoseOfAc14` | | | AC-14.6 | Chart 1 stacked bars; chart 2 by category; chart 3 by account (sorted descending, with amount and percent); total above; settlements never counted | Done | `feature/analytics/.../AnalyticsScreen.kt:172,188-191,217-225`; `AnalyticsRepositoryImpl.kt:43-44` (sorted descending); `Analytics.kt` reads expenses only | | | AC-14.7 | Every bucket appears, empty ones as zero; edge buckets clipped to the range | Done | `Analytics.kt:35-48,65-69`; spend_series vectors (13 cases) | | | AC-14.8 | Tapping a bar or a row opens the expenses behind it | Done | `AnalyticsScreen.kt:111`; `KvittApp.kt` `Routes.DRILL_DOWN`; `AnalyticsRepositoryImpl.kt:57-86`; DrillDownViewModelTest | | | AC-14.9 | Deleted expenses excluded; archived accounts and categories included | Done | `AnalyticsRepositoryImpl.kt:91-93` (`liveExpenses`); RepositoriesTest `analytics_includePending_excludeDeleted` | | | AC-14.10 | Percent rounded half-up to one decimal | Done | `core/design/.../chart/Charts.kt:255-267` (`MoneyFormatter.percent`); MoneyFormatterTest `percentIsHalfUpToOneDecimal` | | | AC-15.1 | Full local copy of users, accounts, categories, templates, expenses, settlements, audit | Done | `data/.../db/Entities.kt`, `KvittDatabase.kt`; SyncEngineTest `pull_*` | | | AC-15.2 | Expenses and settlements work offline; other actions are disabled offline with "Needs connection" | Done | `data/.../connectivity/Connectivity.kt` (`OnlineGate`); RepositoriesTest `onlineOnly_withoutValidatedNetwork_isOffline_andSendsNothing`, `onlineOnly_withinThirtySecondsOfANetworkError_isOffline`; `NeedsConnectionHint` in CategoryListScreen, SyncStatusScreen; StateHolderTest `profileOfflineDisablesEverything` | | | AC-15.3 | Sync on start, on foreground, 2 s after a write, every 15 min (network constraint), and on FCM (expedited); one at a time; names `kvitt-sync` APPEND_OR_REPLACE and `kvitt-sync-periodic` KEEP | Done | `SyncTriggers.kt:49-64,90-115`; `app/.../AppLifecycle.kt:37-50`; `SyncEngine.kt:55` (Mutex); SyncTriggersTest (4 tests), SyncEngineTest `oneCycleAtATime` | The WorkManager wiring itself is not unit-tested (a fake `SyncWork` is used) | | AC-15.4 | Home shows "Last synced" and the pending count; sync status screen | Done | `core/design/.../component/Sync.kt` (`SyncStatusPill`); `feature/home/.../SyncStatusScreen.kt`; HomeScreenshotTest | | | AC-15.5 | LWW per row, deletes final, rejected changes shown | Done | `SyncEngine.kt` (applyResults); SyncEngineTest `push_rejectedStale_*`, `push_rejectedWithoutCurrent_*`; RepositoriesTest `syncStatus_listsPendingRowsAndRejections_andDismisses` | | | AC-15.6 | 14 days offline with 200 changes syncs everything (scripted clock) | Done | `data/src/test/.../sync/OfflineFourteenDaysTest.kt:44` `ac15_6_fourteenDaysOffline_twoHundredChanges_allSyncOnReconnect` | | | §14.4.5-1 | Pending rows by `local_updated_at`, up to 100, `mutation_id = pending_mutation_id` | Done | `SyncEngine.kt:163-175,390`; SyncEngineTest `push_batchesOf100_oldestLocalChangeFirst_withExactlyTheClientWritableFields`, `push_mutationIdIsThePendingMutationId` | | | §14.4.5-2 | Network error or 5xx: retry with backoff; 401 revoked/unauthorized; 429 retry; 426 stop; whole-request refusal goes on to pull and records `last_error_code` | Done | `SyncEngine.kt:125-160`; SyncEngineTest `push_networkError_*`, `push_serverError_asksForRetry`, `deviceRevoked_wipesTokenAndLocalData`, `unauthorized_keepsData_*`, `protocolUnsupported_stopsSyncingUntilUpdated` | No test covers the 429 path or the D-040 whole-request refusal (400/413/409/422) | | §14.4.5-3 | Apply accepted results, rejections with equal content, other rejections, and `clock_skew` with the D-094 re-stamp | Done | SyncEngineTest `push_accepted_*` (2), `push_rejectedWithEqualContent_isTreatedAsAccepted`, `push_rejected_whileEditedAgain_*`, `push_clockSkew_*`, `push_afterClockSkew_restampsRowsStampedAhead_*`, `push_withoutAClockSkewFlag_neverRestamps` | | | §14.4.8 | Epoch change and `cursor_ahead` lead to recovery; on 401 Unrecognised the data is kept; a re-pair by the same user keeps the data and re-pushes, another user wipes | Done | `SyncEngine.kt:314`; `data/.../repo/SessionRepositoryImpl.kt:66-72`; SyncEngineTest `epochChange_*`, `cursorAhead_recoversLikeANewEpoch`; SessionRepositoryTest | | | AC-16.1 | Server hosts the latest APK and metadata | Done | `server/crates/kvitt-server/src/api/apk.rs`; server test `core_api/apk.rs` | Server side, see fork A | | AC-16.2 | Release builds check E-5 on cold start and at most every 6 h while resumed; banner with version name | Done | `AppLifecycle.kt:44-47`; `data/.../update/Updater.kt:84-113,166`; UpdaterTest `checksAtMostEverySixHours`, `newerVersionIsAvailable_sameIsNone`; `feature/home/.../HomeScreen.kt:173,240` | | | AC-16.3 | Download to cache, verify size and SHA-256, install via PackageInstaller; `NeedsInstallPermission` emitted once | Done | `Updater.kt:39-57,117-144,177-196`; UpdaterTest `verifiedDownloadIsInstalled`, `theInstallPermissionIsExplainedOnceBeforeTheFirstInstall`; `HomeScreen.kt:276-287` | The installer is behind a fake in tests | | AC-16.3-device | The real PackageInstaller session and the system "Install unknown apps" prompt | Not verifiable without device | `Updater.kt:177-209` | Needs a signed release APK published on a server (M10) | | AC-16.4 | A verification failure deletes the file and shows "Download failed, try again" | Done | `Updater.kt:137-141`; UpdaterTest `shaMismatchDeletesTheFile_andFails`, `sizeMismatchDeletesTheFile_andFails`; golden S-17 failed | | | AC-16.5 | Debug builds never check | Done | `Updater.kt:85`; `AppLifecycle.kt:46`; UpdaterTest `debugBuildsNeverCheck` | | | AC-16.6 | "Check for update" gives UpToDate, Failed or Available, with copy; debug gives UpToDate without a request | Done | `feature/settings/.../about/AboutViewModel.kt:43-59`; UpdaterTest `checkResult_tellsUpToDateFromAFailure` | In debug builds the button is hidden (`MainActivity.kt:35` `updateCheckAvailable = !BuildConfig.DEBUG`) rather than reporting UpToDate. This is consistent in effect | | AC-19.1 | Every string is a resource in en and sv; no literal in feature code; the gate fails on a missing key | Done | `build-logic/.../AndroidCommon.kt:14-16` (`MissingTranslation` as error, `abortOnError`); StringsCatalogueTest; OnboardingStringsTest | Nothing in the gate stops a Kotlin string literal in a feature. A grep for `Text("`, `text = "` and `contentDescription = "` in `feature/*/src/main` found none | | AC-19.2 | Money and dates formatted per §9.8 | Done | MoneyFormatterTest, DateFormatterTest (`core/design/src/test/...`) | | | AC-19.3 | Default category names and user-visible enums exist in both languages | Done | StringsCatalogueTest `theFourteenDefaultCategoryNamesAndTheNotificationCopyArePresent`; lint `MissingTranslation` | | | S-11 | Change filters (range, granularity, measure, group by, custom) | Done | `AnalyticsViewModel.kt`; AnalyticsViewModelTest `togglesReQueryAtOnce`, `customOpensThePickerAndAppliesOnlyWithBothDates` | | | S-11 | Tap a bar or a row to open S-12 | Done | `AnalyticsScreen.kt:111`; `KvittApp.kt` ANALYTICS to DRILL_DOWN | | | S-11 must-show | Total card, legend, empty state, skeleton loading, no error state | Done | `AnalyticsScreen.kt:172-225`; AnalyticsViewModelTest `firstSyncIsLoading`; AnalyticsRulesTest `loadingOnlyBeforeTheFirstSync` | | | S-12 | Open expense | Done | `DrillDownScreen.kt:61-68`; `KvittApp.kt` DRILL_DOWN `onOpenExpense` to Routes.expense | | | S-13 | Navigate (Profile, Categories, Recurring, Devices, Invite or Pair (admin), Sync status, About) | Done | `feature/settings/.../hub/SettingsHubScreen.kt:98-118`; `SettingsHubViewModel.kt:45-49`; `KvittApp.kt` SETTINGS block; StateHolderTest `hubCountsAndRows` | | | S-14 | Save (name), change or remove the picture (photo picker), offline state | Done | `profile/ProfileScreen.kt:59-75,117`; `SettingsRepositories.kt:77-83`; StateHolderTest `profileNameRulesAndSave`, `profileOfflineDisablesEverything` | | | S-15 | Add | Done | `KvittApp.kt` CATEGORIES `onNewCategory`; StateHolderTest `newCategoryNeedsAName` | | | S-15 | Edit (name with "Reset name", 33 icons, 14 swatches) | Done | `categories/CategoryEditorScreen.kt`; StateHolderTest `categoryResetNameSavesNull`; `SettingsRepositories.kt:105-106` | | | S-15 | Reorder (drag) | Done | `categories/CategoryListScreen.kt:98-101,164-179` (long press and handle drag, plus move up/down); `SettingsRepositories.kt:108-119` (one PATCH per changed `sort_order`); StateHolderTest `categoryReorderFailureRestoresTheOrder` | No `:data` test covers `CategoryRepositoryImpl.reorder` | | S-15 | Archive | Done | `CategoryEditorViewModel.kt`; StateHolderTest `archiveReturnsWithASnackbarForTheList` | | | S-15 | Delete, with the reason when not allowed | Done | `CategoryEditorViewModel.kt:29-42`; StateHolderTest `categoryDeleteAffordanceFollowsDeletable` | | | S-16 | Sync now (online only) | Done | `feature/home/.../SyncStatusViewModel.kt:35-36,51-52`; `SettingsRepositories.kt:305-307` | | | S-16 | Open a rejected change (read-only) | Partial | `SyncStatusScreen.kt` RejectionCard (around line 274): opens S-8/S-10 for `rejection.entityId` | Opens the normal editable detail of the current local row, which holds the server's winning version (or "missing" when `current` was null). The lost payload in `sync_rejections.rejected_payload` is never shown, so "what was lost" is limited to the summary line | | S-16 | Dismiss it | Done | `SyncStatusScreen.kt` (around line 298) dismiss button; `SyncStatusViewModel.kt:56-57`; RepositoriesTest `syncStatus_listsPendingRowsAndRejections_andDismisses` | | | S-16 must-show | Clock-skew warning, "Sync failed", stale copy naming `Rejection.actor` | Done | `SyncStatusScreen.kt:141`; `SyncStatusViewModel.kt:29`; RepositoriesTest `syncStatus_aRejectionNamesWhoMadeTheServersVersion` | | | S-17 | Update | Done | `HomeScreen.kt:115-119,240-241`; `AboutViewModel.kt:61` | | | S-17 | Later (hides the banner for the ViewModel's lifetime) | Done | `HomeViewModel.kt:68,88` | | | S-17 must-show | Version name, "Install unknown apps" dialog once, progress, failure | Done | `HomeScreen.kt:154,276-287`; goldens S-17 update, progress and failed | | | S-18 | Pair again (goes to S-1); back closes the app | Done | `KvittApp.kt` REMOVED (`BackHandler { activity?.finish() }`, `replaceAll(Routes.PAIRING)`) | | | S-19 | Add | Done | `KvittApp.kt` RECURRING `onNewTemplate`; StateHolderTest `newTemplateDefaultsAndSave` | | | S-19 | Edit | Done | `KvittApp.kt` TEMPLATE_EDITOR; StateHolderTest `editTemplateLoadsSplitAndMarksServerErrors` | | | S-19 | Pause/Resume | Done | `RecurringEditorViewModel.kt:70,187` (switch plus resume hint) | Only from inside the editor; the list has no direct pause action. The spec does not require one | | S-19 | Delete | Done | `RecurringEditorViewModel.kt:221,250-254` | | | S-20 | Revoke (with confirmation) | Done | `devices/DevicesViewModel.kt:89-103`; `canRevoke` (AC-3.2); StateHolderTest `devicesLoadFailAndRevoke`; PureRulesTest `revokeRules` | Revoked devices are filtered out of the list (`DevicesViewModel.kt:76`) | | S-21 | Create (partner before the join; device for either user) | Done | `invites/InvitesViewModel.kt` `invitePartner`, `inviteDevice`; `InvitesScreen.kt:185-186`; StateHolderTest `invitesCreateAndRefuse` | Admin only (`SettingsHubViewModel.kt:45-49`), as E-12 requires | | S-21 | Share (Android share sheet) | Done | `InvitesScreen.kt:80-91` (`ACTION_SEND`, `createChooser`) | | | S-21 | Cancel a pending invite | Done | `InvitesViewModel.kt` `askCancel`, `cancel` | | | S-21 must-show | Code in groups of 4, link, expiry | Done | `InvitesScreen.kt:192-214` | | | S-22 | Check for update | Done | `about/AboutScreen.kt:106-117`; `AboutViewModel.kt:43-59` | Hidden in debug builds | | S-22 must-show | Version name and code, server URL, server version "(from /health)" | Partial | `data/.../repo/SettingsRepositories.kt:336-345`; `data/.../api/KvittApi.kt:241` (`X-Kvitt-Server-Version`) | The server version comes from the `X-Kvitt-Server-Version` response header of the last request, not from E-1 `/health` as §13.3 states. It is null until a request reaches the server. The value is the same crate version | | §14.2.4 | UI contract types and interfaces in `:core:model` match the spec block | Done | `core/model/src/main/kotlin/se/hagstedt/kvitt/model/*.kt` (Ids, Enums, Records, Session, Home, Ledger, Accounts, Expenses, Settlements, Analytics, Settings, Sync, Push); `core/model/src/test/.../ContractTest.kt` | Every type, member, parameter and enum order checked matches, including the v0.13 additions (Pairing, lastAttemptFailed, Rejection.actor, appVersionName, NewRecurringTemplate, UpdateCheck, untouched, PushEvent). No differences found |