SPEC v0.39: K2's report recorded (D-191 to D-200), card design-spec S-23 to S-27
- SPEC: §11.2 K2 row landed a8f91ee with its gate, §11.3, §12 OQ-9, §13.2 the destination enum as
built, §13.3 S-5, S-13, S-23 to S-27 as built and K2's four added keys, §14.2.1 (D-192, D-193),
§14.2.2 (card_providers, D-194), §14.2.3 (Room 2 landed), §14.2.4 readings fixed in v0.39
(D-191 to D-196, OQ-9), change log v0.39.
- docs/decisions.md: D-191 to D-200.
- docs/ux/design-spec.md: S-23 to S-27 from K2's goldens, §3.11 four destinations and the
label shrink-to-fit (D-197), §3.12 card artwork, credit_card and card icons, the card states of
S-5, S-7, S-8, S-13, cards_ prefix.
- docs/HANDOFF.md: K2 and K3 landed, Android gate green, PLAN-K2 §8 steps pending, numbering.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Kvitt: specification
|
||||
|
||||
Version **0.38** (2026-10-24). Schema version **4**. Sync protocol version **1**. Push payload version **1**.
|
||||
Version **0.39** (2026-10-24). Schema version **4**. Sync protocol version **1**. Push payload version **1**.
|
||||
|
||||
This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it.
|
||||
|
||||
@@ -1477,7 +1477,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
|
||||
| W6 | Android: Insights | Landed `1c9856b` (2026-10-21, D-141 to D-143, SPEC v0.31; Android gate green, 40 `s11_insights_*` goldens). An Insights section on S-11 from the D-133 endpoints, online only (§9.9): a UI-contract change (§14.8) in `:core:model`, `:data`, `:core:fakes` and `:feature:analytics`, with goldens | Android gate | Look at S-11's Insights on the phone (yes/no); done 2026-10-24 against `v1.0.3` (D-211) | W7 landed, its image deployed |
|
||||
| K0 | Card purchases spike: local test app (D-176) | The test app landed `2ce317f`: `docs/spikes/k0/app.py` and its `README.md`, one command (`python3 docs/spikes/k0/app.py`, the standard library only, the RS256 JWT signed with `openssl`), a page on `http://localhost:8765/` that runs the real Enable Banking flow (bank list, BankID, paste-back of the redirect address, accounts, "Uppdatera" with PSU headers, "Bakgrundshämtning" without, balances, the purchases, an aggregate "Analys", "Koppla från"), key and state in the git-ignored `docs/spikes/k0/.local/`. Done 2026-10-24: run with the owner on his card; report `docs/spikes/K0-enable-banking.md` (aggregates only), its answers recorded in SPEC v0.36 (D-177 to D-180); the partner's card and the next-day checks were not run (owner's decision, D-177) and move to K1's owner steps | The flow worked end to end with the owner's card (owner's yes); the report answers every K0 item; `git status` shows nothing from `.local/`; no gate (not part of any gate) | Done 2026-10-24: Enable Banking account and Terms of Service, the restricted production application, the key in `.local/`, the owner's card linked with BankID, the test app run (PLAN-K0 §8) | SPEC v0.35 |
|
||||
| K1 | Card purchases: server (D-158 to D-175) | Landed `671c00d` (2026-10-24; D-181 to D-188, SPEC v0.38). Migration 0004 with its contract text and migration test, server schema 4; `src/cards/` (registry, Enable Banking adapter, the windowed fetch and de-duplication of §9.12 CP-7 and CP-8, poller, callback page); E-36 to E-44 in the server and `contracts/openapi.yaml` with examples; new error codes; the link clearing in §14.4.4 step 7; `contracts/test-vectors/match_card_purchase.json` and its Python reference in `tools/contracts-check.py`; config (§10.4), `infra/pi/compose.yaml`, `.env.example`, `infra/dev/**`, `infra/npm/kvitt.conf`, `infra/README.md` (step 4 block, card runbook); `web/src/api/schema.d.ts` regenerated | Server gate; `python3 tools/contracts-check.py`; the web gate (the regenerated types); an integration test suite against a local Enable Banking stub. At landing: server 356 tests (34 in `tests/cards_api/`), contracts-check 157 (17 `match_card_purchase` cases), web 609 (`match_card_purchase` listed as not ported until K3), the nginx check and both compose configs ok; the Android gate red by design until K2 lands (`VectorFilesTest`, `ExampleRoundTripTest` pin the contract's vector and example sets), so the commit bypassed the pre-commit hook (contract-first landing) | Copy the key (already on the Mac from K0) to `~/kvitt/secrets/cards/`, set the application id in `.env`, paste the new NPM block, deploy; once a client with the card screens runs: the partner's card, the next-day fetch without BankID and pending→booked (D-177) (PLAN-K1 §8) | SPEC v0.36 (K0's answers) |
|
||||
| K2 | Card purchases: Android (D-169, D-170, D-172, D-173) | `:feature:cards` (S-23 to S-27 and the sheet), Room version 2 with its migration test, the `CardRepository` UI contract in `:core:model`, `matchCardPurchase` in `:domain` passing its vectors, `:data` (DTOs, card sync, queued link changes, Custom Tab, deep link), fakes and scenarios, the fourth bottom destination, S-5's "Från kortköp", S-7's prefill and link, S-8's card line, the committed card artwork mapped (D-180), goldens; strings in `docs/ux/strings.csv` | Android gate | Connect the card on the phone with BankID; create an expense from a purchase; link one; disconnect and reconnect (PLAN-K2 §8) | SPEC v0.36; may start then on the spec and fakes; lands after K1 |
|
||||
| K2 | Card purchases: Android (D-169, D-170, D-172, D-173) | Landed `a8f91ee` (2026-10-24; D-191 to D-200, SPEC v0.39). `:feature:cards` (S-23 to S-27 and the sheet), Room version 2 with its migration test, the `CardRepository` UI contract in `:core:model`, `matchCardPurchase` in `:domain` passing its vectors, `:data` (DTOs, card sync, queued link changes, Custom Tab, deep link), fakes and scenarios, the fourth bottom destination, S-5's "Från kortköp", S-7's prefill and link, S-8's card line, the committed card artwork mapped (D-180), goldens; strings in `docs/ux/strings.csv` (77 rows); out-of-path edits ratified by the orchestrator: `ArchitectureRules.kt` knows `cards`, `:core:design` gains `ic_credit_card` / `KvittIcon.CreditCard`, the card measures in `KvittDimens` (D-200), the nav-label shrink-to-fit (D-197), `StringsCatalogueTest` takes `cards_` as a feature prefix | Android gate. At landing: `BUILD SUCCESSFUL`, 791 actionable tasks, 1154 tests, 0 failures; the 11 card examples (E-36 to E-44) round-trip, the 17 `match_card_purchase` cases pass, `VectorFilesTest` and `ExampleRoundTripTest` green again on `main`; 150 goldens added (`:feature:cards` 135 for every state of S-23 to S-27 with `_sv_light_200` for the long ones; `S-5-account-menu-card`, `s7_from_card`, `s7_from_card_foreign`, `s8_from_card`, `s13_hub_card_connected`) and 147 re-recorded for the fourth bar destination (S-3, S-4, S-11, S-13, S-17 and the navigation-bar component; only the bar changed) | Connect the card on the phone with BankID; create an expense from a purchase; link one; disconnect and reconnect (PLAN-K2 §8 steps 1 to 7, pending: the phone was not attached; the release APK with the card screens is installed by the orchestrator once K1's image runs on the Pi) | SPEC v0.36; may start then on the spec and fakes; lands after K1 |
|
||||
| K3 | Card purchases: web (D-174) | W-21 to W-23 in `web/src/features/cards/`, the sidebar entry, the S-7-style prefill in W-5 and the card line in W-6, the committed card artwork mapped (D-180), `matchCardPurchase` in TypeScript passing its vectors | Web gate | Connect the card from the laptop browser with BankID QR; create and link from W-21 (PLAN-K3 §8) | SPEC v0.36; lands after K1 and K2 (it takes the card strings K2 adds to `docs/ux/strings.csv`) |
|
||||
| W7 | Server: web serving and analytics endpoints (D-132, D-133) | Landed `50a0d67` (2026-10-21, D-135 to D-138, SPEC v0.29; 299 server tests, contracts-check 136). `/app/` from `KVITT_WEB_DIR` with the SPA fallback, cache headers and the `/app/` CSP; the `/analytics/*` endpoints (D-135 to D-138, SPEC v0.29) in `contracts/openapi.yaml` and the server; the Node stage in `server/Dockerfile` with the repository root as build context (workflow, `infra/build-images.sh`, `infra/dev/**`) | Server gate; `python3 tools/contracts-check.py`; the web gate when `web/` exists and the contract changed (`web/src/api/schema.d.ts` regenerated) | Paste the v0.28 NPM block (`infra/README.md` step 4); deploy the new image (§10.10); deployed 2026-10-24 as `v1.0.3` (D-211) | SPEC v0.28 |
|
||||
|
||||
@@ -1511,7 +1511,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
|
||||
* **M11** runs the import per §10.8 "Production run" (D-025). The rehearsal on 2026-10-18 ran both real files against a throwaway database and reconciled on both (Vardag 1746 rows, Hemma 37 rows); 21 rows were unmapped before D-044. The dry run of 2026-10-18 with M12b's mapping confirmed it: Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled.
|
||||
* **W0 to W7** (D-134, §15.9): the web client programme. W0 and W7 run first in parallel (disjoint paths); W1 to W5 run in parallel once W0 has landed; W4's W-11 and W6 wait for W7. W0 owns `web/**` except the feature folders, registers every route with a placeholder page per feature folder (as M2a registered every route, D-004), and lists its module paths in its report; W1 to W5 build only inside their feature folders and take everything shared from W0's modules (a missing shared piece is a request to the orchestrator, as M7x's requests went to M6b). A second agent to land that changed `web/package.json` resolves `web/package-lock.json` with `npm install` on rebase (as `Cargo.lock`, §14.7). A plan that changes `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` (§15.3). Web agents do not use the phone or `tools/gradle-serial.sh`; a manual check runs against a throwaway server, never the owner's development or Pi data (§15.4). The orchestrator adds the web gate to `CLAUDE.md` "Build and verify" (D-131). W1 to W5 landed 2026-10-21 (`934eee7`, `a65ab71`, `f9a9bfb`, `1aecf51`, `3ebe57d`; D-144 to D-154, D-150 unused) with the web gate green on `main` (59 test files, 532 tests at `1aecf51`); a web fix (`83f5293`) followed (598 tests). Pieces built twice or three times in feature folders and other loose ends are listed for a W0b plan in `docs/HANDOFF.md` "Web follow-ups". W0b landed `5305f37` (v0.34). All of W0 to W7 are landed and verified: `v1.0.3` (built on the Mac from `8fa9198`, D-211) runs on the Kvitt Pi and the owner checked the web client and the phone's Insights against it on 2026-10-24 (§11.4).
|
||||
|
||||
* **K0 to K3** (D-176, §11.1): the card-purchase programme. K0 runs first, on the Mac only, with the owner (a local test app for fast feedback before any Android work, owner's refinement 2026-10-24; the app landed `2ce317f`); K0 is done (run 2026-10-24, report `docs/spikes/K0-enable-banking.md`) and its answers are in SPEC v0.36 (D-177 to D-180), so K1 to K3 can start. The reports of K1 to K3 propose spec text and decisions that a docs agent records: K1's in SPEC v0.38 (landed `671c00d`, D-181 to D-188), K2's in v0.39, K3's in v0.40 (decisions from D-191 to D-210, disjoint ranges per plan). Then K1, K2 and K3 run in parallel in their own worktrees (disjoint paths, §14.7): K1 owns the contract (`contracts/**`, the migration text, the vectors) and lands first; K2 and K3 build against the spec and fakes, and rebase on K1 before their gates (K2's DTO tests and K3's generated types need K1's contract); K3 lands after K2, whose `docs/ux/strings.csv` rows it imports (`npm run i18n:import`). K2 uses the phone only when its brief names it; K1 and K3 never use the phone. No plan connects to the owner's real card except through the owner's own BankID step; no plan stores, prints or commits a real purchase.
|
||||
* **K0 to K3** (D-176, §11.1): the card-purchase programme. K0 runs first, on the Mac only, with the owner (a local test app for fast feedback before any Android work, owner's refinement 2026-10-24; the app landed `2ce317f`); K0 is done (run 2026-10-24, report `docs/spikes/K0-enable-banking.md`) and its answers are in SPEC v0.36 (D-177 to D-180), so K1 to K3 can start. The reports of K1 to K3 propose spec text and decisions that a docs agent records: K1's in SPEC v0.38 (landed `671c00d`, D-181 to D-188), K2's in v0.39 (landed `a8f91ee`, D-191 to D-200), K3's in v0.40 (landed `faeb752`) (decisions from D-191 to D-210, disjoint ranges per plan). Then K1, K2 and K3 run in parallel in their own worktrees (disjoint paths, §14.7): K1 owns the contract (`contracts/**`, the migration text, the vectors) and lands first; K2 and K3 build against the spec and fakes, and rebase on K1 before their gates (K2's DTO tests and K3's generated types need K1's contract); K3 lands after K2, whose `docs/ux/strings.csv` rows it imports (`npm run i18n:import`). K2 uses the phone only when its brief names it; K1 and K3 never use the phone. No plan connects to the owner's real card except through the owner's own BankID step; no plan stores, prints or commits a real purchase.
|
||||
|
||||
### 11.4 Validation reports
|
||||
|
||||
@@ -1536,6 +1536,7 @@ Only things that cannot be decided without the owner. Everything else in this sp
|
||||
| OQ-6 | Answered 2026-10-24 (D-156): no confirmation step on S-1 for an invite link that prefills a server other than the release default (`RELEASE_SERVER_URL`, AC-2.6) (security review L-6); the default stands: S-1 opens its server row expanded for a non-default server so the address is visible (AC-2.6, D-116). | M10 | answered |
|
||||
| OQ-7 | Should a new card purchase raise a notification on the holder's phone (a local one, built from the holder's own data; never an FCM payload with content)? | K2 | Not in this version: purchases appear on the next sync without a notification (D-171) |
|
||||
| OQ-8 | Should several purchases be turned into expenses at once ("Lägg till i konto…", one expense each with the account's default split)? | after K2 | Not in this version (D-171); one purchase at a time through S-7 |
|
||||
| OQ-9 | `CardPurchase.linked` (§14.2.4) is null both when a purchase is unlinked and while its linked expense is not yet on the phone (for example a link made in the web client to an expense the phone has not pulled yet), so the purchase is listed under "Kopplade" without its "Kopplad till" line and its sheet briefly offers "Skapa utgift" and the matches. Add `linkedExpenseId` to `CardPurchase` so the sheet can say "Kopplad" without the expense's details (a UI-contract change, §14.8)? | next Android card pass | Not now: the window lasts until the next household sync brings the expense |
|
||||
|
||||
---
|
||||
|
||||
@@ -1562,7 +1563,7 @@ A brief for one UX agent (Opus) who has read this spec and nothing else. It runs
|
||||
* A floating action button "Add expense" (Lägg till utgift) on Home and on Account detail.
|
||||
* Account detail, expense editor, expense detail, settle up, settlement detail and all settings sub-screens are pushed screens with a top app bar and back.
|
||||
* The expense editor is a full-screen screen, not a bottom sheet (the numeric keyboard needs the room).
|
||||
* Wiring (D-071, as built by M7a to M7f and connected by M8): the top-level screens S-3, S-4, S-11 and S-13 render their own `KvittNavigationBar` and take `onNavigate: (KvittDestination) -> Unit`; the `:app` scaffold draws no second bar, and the `NavHost` switches top-level destinations from those lambdas. Since D-172 S-23 is the fourth top-level screen that draws the bar, and `KvittDestination` (`:core:design`) gains `Cards` between `Home` and `Analytics`. Navigation-argument keys are constants in each feature (`ARG_EXPENSE_ID` and `ARG_ACCOUNT_ID`; `SettleArgs.SETTLEMENT_ID` and `SettleArgs.ACCOUNT_ID`; `DrillDownArgs.KEYS`; settings `"categoryId"` and `"templateId"`), read through `SavedStateHandle`.
|
||||
* Wiring (D-071, as built by M7a to M7f and connected by M8): the top-level screens S-3, S-4, S-11 and S-13 render their own `KvittNavigationBar` and take `onNavigate: (KvittDestination) -> Unit`; the `:app` scaffold draws no second bar, and the `NavHost` switches top-level destinations from those lambdas. Since D-172 S-23 is the fourth top-level screen that draws the bar, and `KvittDestination` (`:core:design`) gains `Cards` between `Home` and `Analytics` (as built by K2, `a8f91ee`: `enum class KvittDestination { Home, Cards, Analytics, Settings }`, icon `credit_card`, label `nav_cards`; a label that does not fit its slot at a large font scale shrinks to fit, down to 70 %, D-197). Navigation-argument keys are constants in each feature (`ARG_EXPENSE_ID` and `ARG_ACCOUNT_ID`; `SettleArgs.SETTLEMENT_ID` and `SettleArgs.ACCOUNT_ID`; `DrillDownArgs.KEYS`; settings `"categoryId"` and `"templateId"`), read through `SavedStateHandle`.
|
||||
* Result snackbars (D-071): the result of S-7 ("Utgiften sparades"), of S-9 and of archiving an account is shown by the screen returned to; the account-archived snackbar has an undo action (`common_undo`, "Ångra"). As built by M12e (D-095): six screens host `:app`'s shared `snackbarHostState` in their own scaffold (Home, AccountDetail, ExpenseDetail, SettlementDetail, DrillDown, SyncStatus), so a snackbar never overlaps a FAB or the bottom bar; `:app` has no overlay host. Features hand `:app` string resource ids, never resolved strings: `ExpenseEditorScreen.onSaved(ExpenseId, @StringRes Int?)`, and `onDeleted()` on S-7 and S-8 takes no argument, `:app` then shows `snackbar_expense_deleted`.
|
||||
* Session gate (as built by M8): S-1 for an unpaired or unrecognised session, S-2 for the profile step after a `partner` pairing only (AC-2.10, D-086; a member pairing a second phone with a `device` invite goes to Home), S-18 for a revoked device (back on S-18 closes the app). Home shows S-3 while the household has no partner (`partner == null`).
|
||||
|
||||
@@ -1576,7 +1577,7 @@ Each screen must be designed in these states where they apply: **loaded**, **emp
|
||||
| S-2 | Partner profile step | Display name field (prefilled; a blank name shows `onboarding_error_name_required` "Skriv ditt namn"), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob"). Shown only after a `partner` pairing (`Pairing.inviteKind`, D-086) | Continue |
|
||||
| S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any; error state when fetching the invite failed (snackbar with "Försök igen") | Invite partner (goes to S-21); Settings |
|
||||
| S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16 ("Sync failed" when `SyncSummary.lastAttemptFailed`; `SyncStatusPill` takes no `failed` parameter, D-088); update banner (S-17) when available ("Later" hides it for the ViewModel's lifetime only, so it returns on the next start); list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") | Add expense (FAB); Settle up; open account; New account; Pull to refresh (sync now, D-127) |
|
||||
| S-5 | Account detail | Account name, default split with names (ratios written "60/40", `common_ratio`), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend; month headers are not sticky), each expense row per AC-8.10, each settlement row per AC-11.7 (its title may wrap to 2 lines, 3 at 200 % font scale, and its second line is the type label `ledger_settlement_label` "Betalning" from `:core:design`, D-068); archived badge if archived (an archived account hides the add-expense FAB, AC-6.4, and keeps "Settle this account", D-052); pending-sync marker per row | Add expense (not on an archived account); Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed); Pull to refresh (sync now, D-127); "Från kortköp" (overflow menu, only while the viewer has a card connection, opens S-27, AC-22.9) |
|
||||
| S-5 | Account detail | Account name, default split with names (ratios written "60/40", `common_ratio`), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend; month headers are not sticky), each expense row per AC-8.10, each settlement row per AC-11.7 (its title may wrap to 2 lines, 3 at 200 % font scale, and its second line is the type label `ledger_settlement_label` "Betalning" from `:core:design`, D-068); archived badge if archived (an archived account hides the add-expense FAB, AC-6.4, and keeps "Settle this account", D-052); pending-sync marker per row | Add expense (not on an archived account); Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed); Pull to refresh (sync now, D-127); "Från kortköp" (overflow menu, first item, only while the viewer has a card connection and not on an archived account, D-199; opens S-27, AC-22.9) |
|
||||
| S-6 | Account editor | Name field; default split control (one slider or stepper plus two percent fields that always sum to 100, two decimals allowed); preview "Of 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr"; "Needs connection" state; the discard dialog has a title and no body text (D-080) | Save; Cancel |
|
||||
| S-7 | Expense editor | Amount entry (large, numeric keyboard, `kr`); description; account picker (active accounts); category picker (grid of icon + colour + name, active categories; for a new expense the category is selected from the description per §9.11, with no marker); date (today default, date picker); payer toggle (two avatars: "Paid by"); account, date, payer and split shown as four fixed full-width rows in that order, which do not move when a value changes (D-102); split section: mode chooser (`default` "Account default 60/40", `ratio` "Custom ratio", `exact` "Exact amounts", `full` "All on one person") and its controls; live preview of each person's share; notes (collapsed by default); validation errors inline; delete in edit mode; opened from a card purchase (S-23's sheet or S-27, CP-9, D-169) it starts as a new expense prefilled from the purchase, and "Spara" also links the purchase (a foreign-currency purchase leaves the amount empty); after saving, the snackbar "Utgiften sparades" without "Ångra" (an undo would be an audited create and delete that also notifies the partner, D-052), shown by the screen returned to (D-071), `onSaved` handing `:app` the string resource id (D-095); the quick row is filled by `quickRowCategories` (D-075) and category labels take up to two centred lines, a longer custom name ending the second line in "…" (D-076); the discard dialog has no body text (D-080) | Save; Cancel; Delete (edit mode) |
|
||||
| S-8 | Expense detail | Amount, description, category, account, date, payer, each user's share and the split mode, notes, "Recurring: <template>" when generated; for the card holder only, when a purchase of theirs is linked to it, the line "Från kortköp: <merchant>, <date>" (`expense_detail_from_card`) with "Ta bort koppling" (AC-22.10, D-169); timeline (AC-12.4, AC-12.5); "deleted" state when opened from a notification of a deletion; "missing" state (not yet on this phone) with "Synka nu" (`expense_detail_sync_now`), online only (§9.9, D-080); after a delete `:app` shows `snackbar_expense_deleted` on the screen returned to (D-095) | Edit; Delete |
|
||||
@@ -1584,7 +1585,7 @@ Each screen must be designed in these states where they apply: **loaded**, **emp
|
||||
| S-10 | Settlement detail | Payer, recipient, amount, date, note, allocations per account; timeline; "missing" state with "Synka nu" (`settle_detail_sync_now`), online only (§9.9, D-080) | Edit; Delete |
|
||||
| S-11 | Analytics | Range selector (presets of AC-14.2 plus custom); granularity toggle month/week; measure toggle household / my share; group-by toggle category/account; total for range; chart 1 stacked bars over time with legend; chart 2 category breakdown; chart 3 account breakdown; empty state ("No expenses in this period"); a chart whose range has no expenses shows only the 0 baseline, with no gridline values (D-068). Total card (D-069): caption "<range> · Hushållet" for the household measure and "<range> · N veckor" in week granularity; for my share the label is "Totalt · Min del" so the caption does not repeat it; month presets show whole-month ranges ("maj–okt 2026"), custom ranges and week granularity show days (§9.8, D-077). Legend and stacking order: categories by `sortOrder`, accounts in `AccountRepository.all()` order (`AnalyticsState.groups` order is unspecified, §14.2.4). Loading means `SyncSummary.lastSuccessAt == null` and shows skeleton cards; offline looks like loaded; no error state (analytics are local, AC-14.1). Insights section (D-142, D-143), online only. | Change filters; tap a bar or a row to open S-12; pick a category in Insights; "Försök igen" |
|
||||
| S-12 | Analytics drill-down | Title naming the filter ("Mat & hushåll, oktober 2026"; a week as in `docs/ux/strings.csv`, "Vardag, vecka 41"; a category or account over the whole range "<name>, <range caption>") (D-070); total; expense rows as in S-5. A category or account over the range adds, above the total, its own range selector (AC-14.2 presets plus custom) and month/week toggle, both starting from S-11's values and changing S-12 only; below the total, the line chart of AC-14.11; then the rows. A range with no expenses reads "No expenses in this period" (D-128). No loading and no error state (D-070) | Open expense; change range or granularity |
|
||||
| S-13 | Settings hub | Own avatar and name; partner's avatar and name; rows: Profile, Categories, Recurring expenses, Anslut kort ("Connect card", subtitle "Ansluten · giltig till <date>" or "Läs in dina kortköp", D-172), Devices, Invite partner or Pair a device (admin), Sync status, About | Navigate |
|
||||
| S-13 | Settings hub | Own avatar and name; partner's avatar and name; rows: Profile, Categories, Recurring expenses, Anslut kort ("Connect card", fourth row of the first group, subtitle "Ansluten · giltig till <date>", "Har upphört" for an expired connection (D-199) or "Läs in dina kortköp", D-172), Devices, Invite partner or Pair a device (admin), Sync status, About | Navigate |
|
||||
| S-14 | Profile | Avatar with change/remove; display name; "Needs connection" state | Save |
|
||||
| S-15 | Categories | List: icon, colour, name, archived section; editor: name (with "Reset name" for defaults), icon grid (33 icons of §9.7), colour swatches (14 tokens); delete or archive with the reason when delete is not allowed | Add; Edit; Reorder (drag); Archive; Delete |
|
||||
| S-16 | Sync status | Last successful sync time; pending changes count with a list (the shared ledger rows of `:core:design`, D-019, not a separate 36 dp row); "Changes not synced" list from `sync_rejections` with what was lost and why (copy per §14.4.6 reasons); clock-skew warning; "Sync failed" when online and `SyncSummary.lastAttemptFailed` (D-088); the stale-change copy names `Rejection.actor`, the partner when it is null (D-089); "Sync now", online only (§9.9, D-080); "Öppna" on a rejection shows the phone's lost version read-only (D-109): a dialog "Din version" (`home_sync_lost_title`, body `home_sync_lost_body`) with the ledger row it would have been (read-only: the row's `onClick` is null), the account, payer, split and notes (`Rejection.lost`, §14.2.4), the badge "Du tog bort den här" (`home_sync_lost_deleted`) when the refused change was a deletion, and "Visa nuvarande version" (`home_sync_lost_show_current`), which opens S-8 or S-10; when `lost` is null (unreadable payload, or its category or a user is not on the phone) "Öppna" opens the current version directly | Sync now; open a rejected change (read-only); dismiss it |
|
||||
@@ -1594,11 +1595,11 @@ Each screen must be designed in these states where they apply: **loaded**, **emp
|
||||
| S-20 | Devices | Rows per AC-3.1; the app version shows `Device.appVersionName` ("Kvitt 1.1"), else the version code (D-090) | Revoke (with confirmation) |
|
||||
| S-21 | Invites | For the admin: "Invite partner" (only before the partner joins) and "Pair another device" for either user; a created invite shows the code in groups of 4, the link, expiry, and a share action (Android share sheet); list of pending invites with cancel | Create; Share; Cancel |
|
||||
| S-22 | About | App version name and code, server URL (the paired server; before pairing the build's release server, `AppBuildInfo.releaseServerUrl`, D-122), server version from E-1 `/health`, fetched when S-22 opens while online, shown as "<version> (schema <n>)" (`settings_about_server_version_schema`, "1.1.0 (schema 3)"); offline or when the call fails, the `X-Kvitt-Server-Version` header of the last response without the schema (`AboutState`, §14.2.4, D-110); no open-source licences row (OQ-5, D-099); after "Check for update": `UpdateCheck.UpToDate` shows "Du har den senaste versionen" (`settings_about_up_to_date`), `UpdateCheck.Failed` shows "Kunde inte nå servern" (`common_could_not_reach_server`) (AC-16.6, D-092); the button calls `UpdateRepository.checkNow()`, which always asks the server (D-140) | Check for update |
|
||||
| S-23 | Card purchases (Kortköp; `:feature:cards`, top-level, D-172) | Top app bar "Kortköp"; a header card with the card's artwork (small, `CardArt`), its name, "Bara du ser dina kortköp" and "Uppdaterad <relative time>" (§9.8 relative form) with an "Uppdatera" button (a spinner and "Uppdaterar…" while E-42 runs); filter chips "Att hantera" (default), "Kopplade", "Ignorerade", "Alla", each with its count; the purchases of the filter, newest first, grouped by day (day headers in the §9.8 list form), each row: merchant (one line, ellipsis), amount (`MoneyText`, or "<amount> <currency>" with "Utländsk valuta"), "Preliminär" chip when pending, "Insättning" for a credit (only under "Alla"), and under "Att hantera" the match chip of CP-10 ("Koppla till <description>" or "<n> möjliga utgifter"); under "Kopplade" the line "Kopplad till <description> · <account>". States: no connection (empty: large artwork, "Anslut ditt kort", "Se dina köp här och gör om dem till utgifter med några tryck. Bara du ser dem.", "Anslut kort" → S-24); first fetch ("Hämtar dina köp…" with a progress indicator, while connected and no fetch has finished); loaded; empty filter ("Inget att hantera" / "Nya kortköp visas här." for "Att hantera", "Inga köp här" for the others); offline (the stored list, "Uppdatera" disabled with "Kräver anslutning"; linking and ignoring still work, CP-9); expiring (warning banner "Kortanslutningen upphör <date>" with "Förnya med BankID", CP-6); expired (banner "Kortanslutningen har upphört" with "Förnya med BankID"; list kept); fetch failed (banner "Kunde inte hämta köp. Senast uppdaterad <time>" with "Försök igen", CP-14); long content (long merchant texts, 7-digit amounts). Purchase sheet (bottom sheet on tapping a row): merchant, the purchase date in the detail form, amount, status ("Preliminär" / "Bokförd"); for a debit purchase to handle: "Skapa utgift" (primary) → S-7 prefilled (CP-9); "Koppla till befintlig utgift" with up to 3 matches (each: description, account, payer, date, amount; one tap links and closes the sheet with the snackbar "Kopplad" and "Ångra"), or "Ingen utgift med samma belopp runt det här datumet"; "Välj en annan utgift" (the CP-10 list in a second sheet); "Ignorera" (snackbar "Ignorerad" with "Ångra"). Linked: "Öppna utgift" (S-8), "Ta bort koppling". Ignored: "Sluta ignorera". Credit: the note "Insättningar och återbetalningar är inga utgifter. Ändra den ursprungliga utgiften för hand vid behov." and no action. Foreign currency: the note "Beloppet är i <currency>: skriv beloppet i kronor." above "Skapa utgift" | Uppdatera; Pull to refresh; filter; open a purchase; Skapa utgift; link a match (also from the row's chip); Välj en annan utgift; Ignorera / Sluta ignorera; Ta bort koppling; Öppna utgift; Förnya med BankID; Anslut kort |
|
||||
| S-24 | Connect card (Anslut kort; `:feature:cards`, pushed from S-13, D-172) | App bar "Anslut kort"; intro "Välj ditt kort. Kvitt läser dina köp men kan aldrig betala eller flytta pengar."; one large tile per catalogue entry (E-36): the card's artwork (`CardArt`, full width, card aspect ratio 1.586:1, corner radius 16), name, issuer; a connected card's tile adds "Ansluten · giltig till <date>" and opens S-26; a provider not available shows "Inte konfigurerat på servern" and is disabled (AC-22.1); loading (skeleton tile), offline ("Kräver anslutning", tiles disabled; the catalogue comes from the server), error ("Kunde inte nå servern" with "Försök igen") | Open a card (S-25 or S-26) |
|
||||
| S-25 | Connect a card (`:feature:cards`, pushed from S-24, or from "Förnya med BankID") | App bar with the card's name; the artwork (large); "Hämta köp från de senaste" with a segmented choice "7 dagar", "30 dagar" (default), "90 dagar", "180 dagar" (CP-3); the note "Du kan korta perioden senare. En längre period kräver BankID igen."; the button "Anslut med BankID" (renewal: "Förnya med BankID", the history preset to the connection's). After the tap: waiting state "Väntar på BankID…" with "Slutför i webbläsaren och i BankID och kom sedan tillbaka hit." and "Avbryt" (stops polling; the attempt expires by itself); connected: "Kortet är anslutet" then S-23; failed: "Det gick inte att ansluta kortet" with "Försök igen"; timed out: "Anslutningsförsöket tog för lång tid" with "Försök igen"; offline: the button disabled with "Kräver anslutning" | Anslut med BankID; Avbryt; Försök igen |
|
||||
| S-26 | Card connection (`:feature:cards`, pushed from S-24, or from S-23's header) | App bar with the card's name; artwork; status line ("Ansluten", "Har upphört"); "Giltig till <date>" (detail form); "Senast uppdaterad <time>"; "Historik vid anslutning" with the current value, shorter values selectable (E-41), a longer one only through "Förnya med BankID" with the note "En längre period kräver BankID igen" (CP-3); "Förnya med BankID" (always offered; highlighted from 14 days before `valid_until`); "Koppla från" (error colour) → dialog "Koppla från <card>?" / "Köp som du inte har kopplat eller ignorerat raderas. Utgifter du har skapat finns kvar." with the checkbox "Radera även kopplade och ignorerade köp" and "Koppla från" / "Avbryt" (CP-12); offline: every action disabled with "Kräver anslutning" | Change history; Förnya med BankID; Koppla från |
|
||||
| S-27 | From card purchases (Från kortköp; `:feature:cards`, pushed from S-5) | App bar "Från kortköp" with the account's name as subtitle; the viewer's purchases to handle (debit, SEK or foreign, neither linked nor ignored), newest first, as S-23's rows without chips; empty: "Inga kortköp att hantera"; works offline | Tap a purchase: S-7 prefilled with this account (CP-9) |
|
||||
| S-23 | Card purchases (Kortköp; `:feature:cards`, top-level, D-172) | Top app bar "Kortköp"; a header card with the card's artwork (small, `CardArt`), its name, "Bara du ser dina kortköp" and "Uppdaterad <relative time>" (§9.8 relative form) with an "Uppdatera" button (a spinner and "Uppdaterar…" while E-42 runs); filter chips "Att hantera" (default), "Kopplade", "Ignorerade", "Alla", each with its count; the purchases of the filter, newest first, grouped by day (day headers in the §9.8 list form), each row: merchant (one line, ellipsis), amount (`MoneyText`, or "<amount> <currency>" with "Utländsk valuta"), "Preliminär" chip when pending, "Insättning" for a credit (only under "Alla"), and under "Att hantera" the match chip of CP-10 ("Koppla till <description>" or "<n> möjliga utgifter"); under "Kopplade" the line "Kopplad till <description> · <account>". States: no connection (empty: large artwork, "Anslut ditt kort", "Se dina köp här och gör om dem till utgifter med några tryck. Bara du ser dem.", "Anslut kort" → S-24); first fetch ("Hämtar dina köp…" with a progress indicator, while connected and no fetch has finished); loaded; empty filter ("Inget att hantera" / "Nya kortköp visas här." for "Att hantera", "Inga köp här" for the others); offline (the stored list, "Uppdatera" disabled with "Kräver anslutning"; linking and ignoring still work, CP-9); expiring (warning banner "Kortanslutningen upphör <date>" with "Förnya med BankID", CP-6); expired (banner "Kortanslutningen har upphört" with "Förnya med BankID"; list kept); fetch failed (banner "Kunde inte hämta köp. Senast uppdaterad <time>" with "Försök igen", CP-14); long content (long merchant texts, 7-digit amounts). Purchase sheet (bottom sheet on tapping a row): merchant, the purchase date in the detail form, amount, status ("Preliminär" / "Bokförd"); for a debit purchase to handle: "Skapa utgift" (primary) → S-7 prefilled (CP-9); "Koppla till befintlig utgift" with up to 3 matches (each: description, account, payer, date, amount; one tap links and closes the sheet with the snackbar "Kopplad" and "Ångra"), or "Ingen utgift med samma belopp runt det här datumet"; "Välj en annan utgift" (the CP-10 list in a second sheet); "Ignorera" (snackbar "Ignorerad" with "Ångra"). Linked: "Öppna utgift" (S-8), "Ta bort koppling". Ignored: "Sluta ignorera". Credit: the note "Insättningar och återbetalningar är inga utgifter. Ändra den ursprungliga utgiften för hand vid behov." and no action. Foreign currency: the note "Beloppet är i <currency>: skriv beloppet i kronor." above "Skapa utgift". As built by K2 (`a8f91ee`, design-spec S-23): at most one banner, above the header card, expired before expiring before fetch failed; expired also disables "Uppdatera"; the header card opens S-26 (TalkBack click label "Kortanslutning", `cards_connection_settings`); during the first fetch the header has no "Uppdaterad" line and no filters show; the four filter chips wrap to a second row (D-198); a row shows "Utländsk valuta" as a badge next to "Preliminär" or "Insättning"; the one-match chip links in one tap (snackbar "Kopplad" with "Ångra"), the "<n> möjliga utgifter" chip opens the sheet; back from "Välj en annan utgift" returns to the purchase sheet; a failed "Uppdatera" on the network shows the snackbar "Kunde inte nå servern"; a purchase linked to an expense not yet on the phone is listed under "Kopplade" without the "Kopplad till" line and its sheet shows the to-handle actions until the expense arrives (`CardPurchase.linked` is null then, OQ-9) | Uppdatera; Pull to refresh; filter; open a purchase; Skapa utgift; link a match (also from the row's chip); Välj en annan utgift; Ignorera / Sluta ignorera; Ta bort koppling; Öppna utgift; Förnya med BankID; Anslut kort |
|
||||
| S-24 | Connect card (Anslut kort; `:feature:cards`, pushed from S-13, D-172) | App bar "Anslut kort"; intro "Välj ditt kort. Kvitt läser dina köp men kan aldrig betala eller flytta pengar."; one large tile per catalogue entry (E-36): the card's artwork (`CardArt`, full width, card aspect ratio 1.586:1, corner radius 16), name, issuer; a connected card's tile adds "Ansluten · giltig till <date>" and opens S-26; a provider not available shows "Inte konfigurerat på servern" and is disabled (AC-22.1); loading (skeleton tile), offline ("Kräver anslutning", tiles disabled; the catalogue comes from the server), error ("Kunde inte nå servern" with "Försök igen"). As built by K2: offline shows the `NeedsConnectionBanner` "Listan över kort hämtas från servern. Anslut telefonen till internet." (`cards_connect_offline_body`, D-200) and the cached catalogue entry's tile disabled (D-194); E-36 is asked again when the phone comes online; the connected line's date is in the §9.8 list form | Open a card (S-25 or S-26) |
|
||||
| S-25 | Connect a card (`:feature:cards`, pushed from S-24, or from "Förnya med BankID") | App bar with the card's name; the artwork (large); "Hämta köp från de senaste" with ~~a segmented choice~~ wrapping filter chips (D-198) "7 dagar", "30 dagar" (default), "90 dagar", "180 dagar" (CP-3); the note "Du kan korta perioden senare. En längre period kräver BankID igen."; the button "Anslut med BankID" (renewal: "Förnya med BankID", the history preset to the connection's). After the tap: waiting state "Väntar på BankID…" with "Slutför i webbläsaren och i BankID och kom sedan tillbaka hit." and "Avbryt" (stops polling; the attempt expires by itself); connected: "Kortet är anslutet" then S-23; failed: "Det gick inte att ansluta kortet" with "Försök igen"; timed out: "Anslutningsförsöket tog för lång tid" with "Försök igen"; offline: the button disabled with "Kräver anslutning". As built by K2: E-38 is polled every 2 s only while S-25 is open and resumed (the Custom Tab on top pauses it) and once at once when the return link arrives (D-196); "Kortet är anslutet" shows for 1.5 s, then S-23; "Avbryt" returns to the choice; E-37 failing on the network shows "Kunde inte nå servern" above the button, any other failure, or no browser to open the bank page, the failed state; timed out is shown when E-38 answers `expired` (the server's 15 minutes), with no clock check on the phone | Anslut med BankID; Avbryt; Försök igen |
|
||||
| S-26 | Card connection (`:feature:cards`, pushed from S-24, or from S-23's header) | App bar with the card's name; artwork; status line ("Ansluten", "Har upphört"); "Giltig till <date>" (detail form); "Senast uppdaterad <time>"; "Historik vid anslutning" with the current value, shorter values selectable (E-41), a longer one only through "Förnya med BankID" with the note "En längre period kräver BankID igen" (CP-3); "Förnya med BankID" (always offered; highlighted from 14 days before `valid_until`, and when expired: filled instead of tonal); "Koppla från" (error colour) → dialog "Koppla från <card>?" / "Köp som du inte har kopplat eller ignorerat raderas. Utgifter du har skapat finns kvar." with the checkbox "Radera även kopplade och ignorerade köp" and "Koppla från" / "Avbryt" (CP-12); offline: every action disabled with "Kräver anslutning". As built by K2: artwork, then a facts card (status line with a filled `check_circle` in `owed` or a filled `error` in `error`, "Giltig till", "Senast uppdaterad"), then the history as wrapping chips listing only the current value and shorter ones (D-198), then the two buttons; a network failure shows the snackbar "Kunde inte nå servern"; after "Koppla från" the screen closes | Change history; Förnya med BankID; Koppla från |
|
||||
| S-27 | From card purchases (Från kortköp; `:feature:cards`, pushed from S-5) | App bar "Från kortköp" with the account's name as subtitle; the viewer's purchases to handle (debit, SEK or foreign, neither linked nor ignored), newest first, as S-23's rows without chips, grouped by day under S-23's day headers (as built by K2); empty: "Inga kortköp att hantera"; works offline; S-7's "Utgiften sparades" shows here on return | Tap a purchase: S-7 prefilled with this account (CP-9) |
|
||||
|
||||
System notifications (copy needed in both languages, built per AC-13.5):
|
||||
|
||||
@@ -1718,6 +1719,8 @@ Card purchase strings (K2 adds them to `docs/ux/strings.csv` with these keys; th
|
||||
| `cards_pick_title` | From card purchases | Från kortköp |
|
||||
| `cards_pick_empty` | No card purchases to handle | Inga kortköp att hantera |
|
||||
|
||||
K2 added these keys to `docs/ux/strings.csv` (`a8f91ee`, 77 rows with `nav_cards` and the plural forms) and four more of its own (D-200): `cards_foreign_amount` ("%1$s %2$s", the bank's decimal amount and the currency, a decimal comma in Swedish), `cards_connection_settings` (TalkBack click label of S-23's header card: "Card connection" / "Kortanslutning"), `cards_connect_offline_body` (S-24's offline banner: "The list of cards comes from the server. Connect the phone to the internet." / "Listan över kort hämtas från servern. Anslut telefonen till internet.") and `settings_cards_expired` (S-13's subtitle of an expired connection: "Ended" / "Har upphört", D-199). Every card key is in both `strings.xml` files of its module.
|
||||
|
||||
### 13.4 Deliverables
|
||||
|
||||
**Phase 1 (U1): directions.** In `docs/ux/directions/`:
|
||||
@@ -2225,7 +2228,7 @@ Local-only semantics on `expenses` and `settlements`:
|
||||
* `pending = true` means the row has a local change not yet accepted by the server. Every local create, edit or delete sets `pending = true`, sets a new UUIDv7 `pending_mutation_id`, sets `updated_at` to the phone's current time in the fixed RFC 3339 form (and `created_at` on create), and sets `local_updated_at`.
|
||||
* A local delete sets `deleted_at` = `updated_at` = now. The row stays as a tombstone.
|
||||
* The pending-changes count (AC-15.4) is `count(expenses where pending) + count(settlements where pending)`; pending card rows are not counted (they are the holder's own and need no attention, CP-9).
|
||||
* Card purchases follow the same pattern for the holder's link and ignore changes (CP-9): a local `link`, `unlink` or `setIgnored` writes `expense_id` and `ignored`, sets `pending = true` and `local_updated_at`; the card cycle sends `PUT /cards/purchases/{id}` (E-44) for each pending row and, on 200, overwrites the row with the answer and clears `pending`; on `not_found` with `field` `expense_id` (the expense is not on the server yet) it stays pending for the next cycle; on `not_found` for the purchase it deletes the local row; on `expense_already_linked` or `validation_failed` it takes the server's state at the next pull and clears `pending`. A card pull skips a pending local row, as §14.4.2 step 3 does. Deleted purchases (tombstones) are deleted locally.
|
||||
* Card purchases follow the same pattern for the holder's link and ignore changes (CP-9): a local `link`, `unlink` or `setIgnored` writes `expense_id` and `ignored`, sets `pending = true` and `local_updated_at`; the card cycle sends `PUT /cards/purchases/{id}` (E-44) for each pending row and, on 200, overwrites the row with the answer and clears `pending`; on `not_found` with `field` `expense_id` (the expense is not on the server yet) it stays pending for the next cycle; on `not_found` for the purchase it deletes the local row; on `expense_already_linked` or `validation_failed` it takes the server's state at the next pull and clears `pending`: the row's `card_version` is reset to 0 and `card_sync_state.cursor` moved back to just before the row's version when it is ahead, so the unchanged server row is pulled again (D-192). A link whose expense is not on the phone or is deleted there is cleared before E-44, and every link to an expense is cleared when the phone deletes it locally or removes it after a rejection (D-193). A card pull skips a pending local row, as §14.4.2 step 3 does. Deleted purchases (tombstones) are deleted locally.
|
||||
|
||||
#### 14.2.2 Local-only tables
|
||||
|
||||
@@ -2235,11 +2238,11 @@ Local-only semantics on `expenses` and `settlements`:
|
||||
| `CardSyncStateEntity` (v2, D-173) | `card_sync_state` | `id` (always 1), `cursor` Long (last applied `next_since` of E-40, 0 initially), `last_success_at` String? | `id` |
|
||||
| `SyncRejectionEntity` | `sync_rejections` | `mutation_id`, `entity_type`, `entity_id`, `reason` (§14.4.6), `message`, `rejected_payload` (JSON of the entity as pushed), `server_current` (JSON or null), `rejected_at` String, `dismissed` Boolean | `mutation_id` |
|
||||
|
||||
Per-phone preferences (last used account, last used category per account, "install unknown apps" explanation shown, and the installed version code that received a 426 `protocol_unsupported`, D-043) live in DataStore `prefs.preferences_pb`, not Room. The pairing status lives in `auth.preferences_pb` with the token (§8.2, D-043).
|
||||
Per-phone preferences (last used account, last used category per account, "install unknown apps" explanation shown, the installed version code that received a 426 `protocol_unsupported`, D-043, and the last card catalogue of E-36 as JSON under `card_providers`, D-194) live in DataStore `prefs.preferences_pb`, not Room. The pairing status lives in `auth.preferences_pb` with the token (§8.2, D-043).
|
||||
|
||||
#### 14.2.3 Migrations
|
||||
|
||||
Version 1 has no migrations. Every later Room version adds a `Migration` and a migration test in `:data` (`CLAUDE.md`), and is a contract change (§14.8). Version 2 (D-173, K2): `MIGRATION_1_2` creates `card_connections`, `card_purchases` and `card_sync_state` (with the row `id` 1, `cursor` 0) and changes no existing table; its test opens a version 1 database built from the exported `1.json` holding users, accounts, categories, expenses, settlements, audit entries, a pending expense and a rejection, migrates it, and asserts every row is kept and the new tables exist as in the exported `2.json`. The revoke wipe (AC-3.3) deletes the card tables with the rest. Room 3 migrations operate on an `SQLiteConnection` (the driver API), not on a `SupportSQLiteDatabase` (D-012).
|
||||
Version 1 has no migrations. Every later Room version adds a `Migration` and a migration test in `:data` (`CLAUDE.md`), and is a contract change (§14.8). Version 2 (D-173, K2): `MIGRATION_1_2` creates `card_connections`, `card_purchases` and `card_sync_state` (with the row `id` 1, `cursor` 0) and changes no existing table; its test opens a version 1 database built from the exported `1.json` holding users, accounts, categories, expenses, settlements, audit entries, a pending expense and a rejection, migrates it, and asserts every row is kept and the new tables exist as in the exported `2.json`. The revoke wipe (AC-3.3) deletes the card tables with the rest. Landed by K2 (`a8f91ee`): `KvittDatabase` version 2 with `MIGRATION_1_2`, the exported `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/2.json` committed (a contract file, §14.8) and the migration test in `:data`'s `MigrationTest`. Room 3 migrations operate on an `SQLiteConnection` (the driver API), not on a `SupportSQLiteDatabase` (D-012).
|
||||
|
||||
#### 14.2.4 UI contract: types and repository interfaces in `:core:model`
|
||||
|
||||
@@ -2527,11 +2530,20 @@ interface CardRepository {
|
||||
}
|
||||
```
|
||||
|
||||
* **Where it is used.** `:feature:cards` (S-23 to S-27); `:feature:expense` (S-7 takes the optional navigation argument `cardPurchaseId`, starts from `prefill` and calls `link` after a successful save; S-8 reads `purchaseForExpense` and calls `unlink`); `:feature:account` (S-5 reads `hasConnection` for "Från kortköp"). `:app` binds `CardRepository` to `:data`'s `CardRepositoryImpl` in `DataBindingsModule`, routes `kvitt://cards/connected?attempt=<id>` to S-25 (or S-23 when S-25 is not open), and adds the destination `KvittDestination.Cards` (§13.2).
|
||||
* **Where it is used.** `:feature:cards` (S-23 to S-27); `:feature:expense` (S-7 takes the optional navigation argument `cardPurchaseId`, starts from `prefill` and calls `link` after a successful save; S-8 reads `purchaseForExpense` and calls `unlink`); `:feature:account` (S-5 reads `hasConnection` for "Från kortköp"); `:feature:settings` (S-13 reads `cards(CardFilter.ALL).connection` for the "Anslut kort" subtitle, as built). `:app` binds `CardRepository` to `:data`'s `CardRepositoryImpl` in `DataBindingsModule`, routes `kvitt://cards/connected?attempt=<id>` to S-25 (or S-23 when S-25 is not open), and adds the destination `KvittDestination.Cards` (§13.2).
|
||||
* **`:data`.** DTOs for E-36 to E-44 with round-trip tests over K1's examples; `CardRepositoryImpl` on the Room tables of §14.2.1 and `card_sync_state`; the card cycle (push pending card rows with E-44, then pull E-40 until `has_more` is false) runs after every household sync cycle (§14.4.1) and after `refresh`; a 404 `not_found` from E-40 (a server before schema 4) is treated as "no card support" and leaves the tables empty; `cursor_ahead` or a household epoch change drops the card cache (pending rows kept) and pulls from 0. `matches` and `others` come from `:domain`'s `matchCardPurchase` (§14.5.8) over Room's expenses. `prefill` applies CP-9 and the AC-8.6 default account; the editor runs the §9.11 suggestion itself as for any new expense. A local expense delete clears a local link to it (CP-9). The bank URL is opened by `:feature:cards` through a Custom Tab (`androidx.browser`, a catalog entry K2 adds).
|
||||
* **`:core:fakes`.** `FakeCardRepository` with scenarios in `fakes/cards/`: no connection, first fetch, loaded (to handle, linked, ignored, credit, pending, foreign), empty filter, offline, expiring, expired, fetch failed, long content, attempt pending / connected / failed / expired; `matches` computed with `:domain`'s function, as every fake computes derived values (§6.2).
|
||||
* **`ExpenseDraft`** is unchanged: a prefilled draft is a draft with `id` null.
|
||||
|
||||
Readings fixed in v0.39 (D-191 to D-196, as landed by K2 `a8f91ee`; no type changes: `model/Cards.kt` declares exactly the block above):
|
||||
|
||||
* **The card cycle (D-191).** `:data`'s `CardSync` runs after each successful household cycle (`SyncEngine`) and after `refresh`, one at a time; its outcome never changes the household outcome or `SyncSummary`. A 404 from E-40 is "no card support": nothing is stored and the next cycle asks again.
|
||||
* **E-44 refusals and dead links (D-192, D-193).** See §14.2.1: a refused change resets the row's `card_version` and moves the card cursor back before it; links to expenses no longer on the phone are cleared before E-44 and at every local delete or rejection that removes an expense.
|
||||
* **Catalogue cache (D-194).** `CardsState.provider` comes from the last E-36 answer, cached in DataStore (`card_providers`, §14.2.2), so S-23, S-25 and S-26 name the card and draw its artwork offline; `providers()` stays online only and refreshes the cache; after a connect or an "Uppdatera" the phone asks E-36 when the cache is empty or lacks the connected provider.
|
||||
* **Following a fetch (D-195).** After `attempt` returns `Connected` or E-42 answers `fetching` true, `:data` pulls E-40 every 3 s, at most 40 times, until the connection's `last_fetch_at` or `last_fetch_error` changes. `refresh` while this phone's E-42 call runs returns `Ok` without a request; `CardsState.fetching` is true for the duration of the call.
|
||||
* **Return link (D-196).** `:app` parses `kvitt://cards/connected?attempt=<id>` (`AppLink.CardConnected`, the id 1 to 64 ASCII letters, digits or `-`); S-25, when open, polls that attempt at once; otherwise `AppViewModel` calls `attempt` once and opens S-23.
|
||||
* **`CardPurchase.linked` while the expense is missing (OQ-9).** `linked` is null both when the purchase is unlinked and when it is linked to an expense not yet on the phone; the filters read the stored link, so such a purchase is listed under "Kopplade", without its "Kopplad till" line, and its sheet shows the to-handle actions until the expense arrives. A `linkedExpenseId` field would tell the two apart; it is a contract change (§14.8) and waits for the owner (§12 OQ-9).
|
||||
|
||||
Readings fixed in v0.4 (no type changes):
|
||||
|
||||
* **`FieldError.error`.** Allowed values: a domain token of §14.5.3, `required` (a required value is missing or blank), or `validation_failed` (a §9.6 rule with no domain token: lengths, dates, `day_of_month`, periods).
|
||||
@@ -3600,3 +3612,4 @@ Plans with disjoint paths (§14.7), one Opus agent each in its own worktree. W0
|
||||
| 0.36 | 2026-10-24 | K0's results (the spike run on the owner's SJ Prio Mastercard on 2026-10-24 with `docs/spikes/k0/app.py`, report `docs/spikes/K0-enable-banking.md`), D-177 to D-180; no contract file changes, server schema 4 and Room 2 as specified in v0.35. D-177: K0's outcome; every K0 `TODO(spec)` of §9.12 answered: ASPSP `SJ Prio Mastercard`, `SE` (CP-1); one `CARD` account per session (CP-2); at least 150 days of history right after BankID, so 7/30/90/180 stay (CP-3, §9.6); a background fetch without PSU headers accepted (CP-4); `required_psu_headers` [`psu-ip-address`], compared case-insensitively (CP-5); `maximum_consent_validity` 180 days, `valid_until` 2027-04-22 (CP-6); foreign purchases arrive in SEK (CP-7); credits were card-bill payments (CP-8); and the owner's assumptions of 2026-10-24 for what was not measured: the partner's card links to the owner's application (CP-13, the D-175 fallback stays), a pending transaction keeps its `entry_reference` when booked (CP-8, the amount-and-date rule becomes the secondary rule), the session is reused on later days without BankID (CP-6), all three verified by K1's owner steps. D-178: the card returns at most 100 transactions per response and never a `continuation_key`, so history is fetched in windows of at most 30 days, newest first, a window at 100 rows split in half down to one day, de-duplicated across windows by `provider_ref`; `strategy=longest` dropped; `WRONG_TRANSACTIONS_PERIOD` ends the walk back; only a complete fetch deletes vanished pending purchases (CP-7, CP-8); `card_fetch` counts requests, splits, truncated windows, `booked_by_ref` and `booked_by_match` (§8.7). D-179: transaction mapping from K0's field survey: merchant = `remittance_information`, else `creditor.name`, else "Kortköp"; `purchase_date` = `booking_date`, else `value_date`, else `transaction_date`; `bank_transaction_code` unused, so a debit `FEE` is a purchase to handle; the holder's name, the masked card number (`CPAN`) and the merchant's address are never stored, logged or shown (CP-7). D-180: the owner's SJ Prio Mastercard artwork cropped to the card (1313 × 829) and committed for Android and the web, mapped from the first commit of K2 and K3, the goldens use it, the owner's artwork steps removed (revises D-173, D-174). The K plans' proposed spec text goes into v0.37 (§11.1, §11.3). |
|
||||
| 0.37 | 2026-10-24 | D-211: image builds on the Mac with Podman (`infra/build-images.sh v1.0.N`) are the normal path, the Gitea workflow on the Pi 3 runner the fallback (revises D-156 point 5); tag rule (the next `v1.0.N` after the highest tag on `origin`, pushed as a git tag too, `v1.0.3` = `8fa9198`); the registry from the Mac is `gitea.lan` on port 80, the Pi's `.env` keeps `gitea.lan:3001`; Podman's insecure-registry files on the Mac and in the Podman machine; the script fixes `f15fa23` and `8fa9198` (§10.3, §10.10). `v1.0.3` deployed on the Kvitt Pi and the web client and S-11's Insights verified by the owner (§11.2 W1 to W7, §11.3, §11.4, §15). No contract file changes. |
|
||||
| 0.38 | 2026-10-24 | K1's report (card purchases on the server, landed `671c00d`: migration 0004 byte-identical to §14.1.3, server schema 4 in code, E-36 to E-44 with four new error codes and a 502 `BadGateway` response in `contracts/openapi.yaml`, examples, `match_card_purchase.json` with its Python reference; gates at landing: server 356 tests, contracts-check 157, web 609, nginx check and compose configs ok, the Android gate red until K2 lands), D-181 to D-188 (D-189 and D-190 unused). D-181: the history counts today, `date_from` = today − (`history_days` − 1), 6 windows for 180 days (revises CP-3 and CP-7). D-182: the server's CS-4 first token for CP-8 uses Rust's standard Unicode classes without NFC. D-183: Enable Banking's errors mapped to CP-14 (`EXPIRED_SESSION`, `CLOSED_SESSION`, `REVOKED_SESSION`, `SESSION_DOES_NOT_EXIST` → `consent_expired` and `expired`; `ASPSP_RATE_LIMIT_EXCEEDED` or 429 → `rate_limited`; `ASPSP_ERROR`, `ASPSP_TIMEOUT`, 408, 5xx or no answer → `bank_unavailable`; anything else `failed`); E-37's `card_bank_unavailable` when `/aspsps` or `/auth` fails. D-184: the callback (an expired `state` stores `expired`, revising CP-2's `failed`; a used one changes nothing; neither `code` nor `error` → `failed`/`bank_error`; a session without accounts is revoked; the atomic claim). D-185: the poller's start-up slot, background starts counted whatever their outcome, deletion after `WRONG_TRANSACTIONS_PERIOD` only over the windows that answered, a split window's cut rows discarded. D-186: PSU headers only with a value, `/aspsps` cached 24 hours, the two fixed headers when it fails. D-187: E-41 with the current value and E-43 on a disconnected connection make no new version (`erase_handled=true` still erases), E-44's `field` for a credit purchase, `CardAttempt.failure` a plain nullable string. D-188: `src/wire/card.rs`, row helpers in `src/cards/mod.rs`, the client IP in a request extension, and the Pi's `compose.yaml` refusing to start until `secrets/cards/` exists. Changed: §9.12 CP-2, CP-3, CP-4, CP-5, CP-7, CP-8, CP-14; §10.2; §11.1, §11.2 K1 row, §11.3 (K2's text goes into v0.39, K3's into v0.40); §14.1.3; §14.3.3; §14.3.4 E-1 (`schema_version` 4), E-37, E-39, E-41, E-43, E-44; §14.3.5; §14.7. |
|
||||
| 0.39 | 2026-10-24 | K2's report (card purchases on Android, landed `a8f91ee`: Room version 2 with `MIGRATION_1_2`, its test and the exported `2.json`; the `CardRepository` UI contract of §14.2.4 unchanged in its names; the card cycle; `:feature:cards` S-23 to S-27; S-5, S-7, S-8, S-13 and the fourth bottom destination; Android gate at landing `BUILD SUCCESSFUL`, 791 actionable tasks, 1154 tests, 0 failures, `VectorFilesTest` and `ExampleRoundTripTest` green again; 150 goldens added, 147 re-recorded for the bar), D-191 to D-200. D-191: the card cycle after each successful household cycle and after `refresh`, never changing the household outcome; E-40's 404 is "no card support", retried every cycle. D-192: E-44's `expense_already_linked` or `validation_failed` resets the row's `card_version` to 0 and moves the card cursor back before it (§14.2.1). D-193: links to expenses no longer on the phone cleared before E-44 and at every local delete or rejection that removes an expense. D-194: the E-36 catalogue cached in DataStore (`card_providers`, §14.2.2). D-195: after a connect or E-42's `fetching`, E-40 every 3 s, at most 40 times, until `last_fetch_at` or `last_fetch_error` changes. D-196: the return link held by `AppViewModel`: S-25 polls it at once, else one E-38 call and S-23. D-197: bottom-bar labels shrink to fit down to 70 % (design-spec §3.11). D-198: S-23's filter chips wrap; S-25's and S-26's history as wrapping chips (revises S-25's segmented choice). D-199: S-13 "Har upphört" for an expired connection; S-5's "Från kortköp" hidden on archived accounts. D-200: four added string keys and the card artwork measures in `KvittDimens`. §13.2 (the destination enum as built), §13.3 S-5, S-13, S-23 to S-27 as built and the added keys, §14.2.1 to §14.2.4 (Room 2 landed; readings fixed in v0.39), §11.2 K2 row, §11.3, new OQ-9 (`CardPurchase.linked` while the expense is missing, §12). `docs/ux/design-spec.md`: S-23 to S-27 sections from the goldens, §3.11 four destinations and shrink-to-fit, §3.12 card artwork, the `credit_card` icon, S-5, S-7, S-8, S-13 card states. No contract file changes in this pass (Room 2 is K2's). contracts-check 157. |
|
||||
|
||||
Reference in New Issue
Block a user