- 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
66 KiB
Taken at
ed1b130(main, 2026-10-18, SPEC v0.16;956908aafter 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:modelmatches 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":
- AC-18.2 restore drill report.
- S-16 read-only rejected-change view: implement it, or have a docs decision accept the summary line.
- 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)
-
AC-18.2 Restore drill (Missing).
docs/validation/does not exist, and no drill has been run; the procedure is ininfra/RESTORE.md. Smallest change: once the Pi stack runs, or earlier on the dev stack as a rehearsal, followinfra/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.mdwith a "Results (owner's phone)" section.
Owner: M10 (first release), as an ops and docs agent plus the owner's taps.
-
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 insync_rejections.rejected_payloadis 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 aRejectionfield, for example a list of display fields, so it is a contract change (§14.8) across:core:model,:data,:core:fakesand: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. -
S-22 server version source (Partial).
android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt:336-345reads theX-Kvitt-Server-Versionheader 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, inAboutRepositoryImpl(:data, no contract change), or amend the §13.3 wording. Owner::data, or a docs agent. -
AC-18.1 Nightly Drive backup (Partial). The code is complete: 03:30 cron in
infra/backup/Dockerfile,rclone copy --immutableininfra/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. -
AC-20.1 Health under 100 ms on the Pi (Partial). Function and test are done (
tests/skeleton/routes.rshealth tests); the latency is not measured. Smallest change: a timing loop against/healthon the Pi, with the aggregate written intodocs/validation/first-release.md. Owner: M10. -
§9.2 Effect line (Partial: evidence).
android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Snapshot.kt:124effectForreads correctly, but no:datatest covers its four cases; the screenshots render the fakes. Smallest change: oneRepositoriesTestcase with four expenses assertingLent,Borrowed,PaidForYourselfandNotInvolved. Owner::data(code-overhaul phase). -
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. -
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:appunit test with a fakeSyncRepository, plus an owner yes/no on the next fresh pairing. Owner::app. -
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.
-
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 inAppViewModel.onLinkwhile the session isPaired, with a unit test. Owner::app. -
AC-13.7 Push-token robustness (note). A failed E-17 PUT is logged and not retried until Firebase calls
onRegisteredagain, 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::apppush or:adapter:fcm. -
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'sArchitectureCheckTask. Owner: build-logic. -
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 pluslast_error_code).:data:CategoryRepositoryImpl.reorder(one PATCH per changedsort_order), and the recurringtoWiresplits per mode.- The WorkManager unique names and policies.
- The server CLI:
healthcheck,migrate, and the 10 s SIGTERM drain ofserve.
Owners:
:data, and a server plan (code-overhaul phase). -
AC-5.1 Home gate without a partner (note).
KvittApp.kt:236-240has 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-50usescheckNotNull, 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-serversubcommand 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/<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 " 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 |