Files
kvitt/docs/validation/ac-coverage.md
T
jacobh2 48fe119ae1
Build Kvitt images / build (push) Failing after 1m24s
SPEC v0.18: D-109 to D-117; validation reports; HANDOFF, prompts
- D-109 S-16 lost version read-only (Rejection.lost: RejectedVersion), D-110 S-22 versions from /health
  (AboutState.schemaVersion), D-111 SettlementRepository nullable start/recompute, D-112 E-17 retry,
  D-113 invite-partner and the push sent line, D-114 picture 16 MP / 64 MB and fcm_token 512 limits,
  D-115 rate-limit token acceptance (revises D-032), D-116 private notifications (AC-13.9) and L-6 as
  built (OQ-6), D-117 infra security (Caddy log redaction and headers, non-root Caddy, dev secret,
  digest record, key form).
- §10.5.2 quotes infra/pi/Caddyfile at 7fb86f5; §10.4 invite-partner row; §11.2 M12f row; §11.3 server
  gap pass, server overhaul, infra notes; new §11.4 validation reports; §14.7 server tree; strings.csv
  562 rows; localeFilters, work-testing, harness fix, APK 10.0 MB.
- docs/validation/ac-coverage.md and security-review.md committed verbatim from the reports.
- Commit hashes in SPEC, decisions, HANDOFF and prompts/README remapped to the rewritten history (18c527f).
- design-spec: S-16 lost-version dialog, S-22 "(schema <n>)", the S-1 rationale mention dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 22:51:42 +02:00

409 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> 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:<port>/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/<CODE>` 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 "<Payer> paid <Recipient> 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 <owner>" | 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 |