# Kvitt: specification Version **0.40** (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. Conventions used throughout: * "Owner" is Jacob, the admin user. "Partner" is the other user. "User" is either of the two. * "Öre" is 1/100 SEK. All money is an integer number of öre. * "bp" is basis points. 10000 bp is 100 %. * Timestamps are RFC 3339 UTC strings in exactly this form: `2026-10-17T06:00:00.000Z` (millisecond precision, literal `Z`). This form sorts correctly as a string. * Dates without time are ISO 8601 `YYYY-MM-DD`. Months ("periods") are `YYYY-MM`. ISO weeks are `YYYY-Www` with the ISO week-numbering year (week starts Monday). * Ids are UUIDv7, lowercase, hyphenated, 36 characters, stored and sent as strings. * JSON keys are `snake_case`. Enum values are lowercase `snake_case` strings. * "Must", "must not" and "may" are binding. --- ## 1. Overview & goals Kvitt is a shared-expense app for one household of exactly two people living in Sweden: the owner and his spouse. It replaces Splitwise for them. It tracks who paid for what, who owes whom, and how the household spends money over time. It consists of: * A native Android app (Kotlin, Jetpack Compose) on two Samsung phones (Galaxy S26 and Galaxy S26+, Android 16). The app keeps a full copy of the household's data in a local Room database and works fully offline for expenses and settlements. * A small Rust server (axum, SQLite) in Docker on a Raspberry Pi 3 at the owner's home, reachable at `https://` (the default HTTPS port 443, D-124), where `KVITT_PUBLIC_HOST` is the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), through the owner's Nginx Proxy Manager on the Home Assistant Pi (D-120). The server stores the authoritative copy, syncs the phones, creates recurring expenses, sends push notifications, keeps an audit log and hosts the APK for in-app updates. * A web client (D-129, §15): a React single-page app built into the server image and served by the server under `/app/`, for a browser on the owner's or the partner's computer. It has the app's features except the phone-only ones (pairing flow, push, offline use, in-app update), plus search, CSV export and keyboard shortcuts. It is online only. * Card purchases (D-158, §9.12, FR-22): each user may connect their own payment card (at first the SJ Prio Mastercard) through the account-information service Enable Banking; the server reads that card's purchases and the card holder, and only the card holder, turns them into ordinary household expenses with a few taps. Read only: Kvitt never pays or moves money. Goals for v1: 1. **G-1 Correct money.** Balances are exact to the öre and computed identically on phone and server, guaranteed by shared test vectors. 2. **G-2 Offline first.** Creating, editing and deleting expenses and settlements never waits for the network. Two weeks offline followed by a sync loses nothing. 3. **G-3 Flexible splitting.** Per-account default ratios (50/50 to start, configurable such as 60/40), custom ratios, exact amounts, or 100 % on one person. 4. **G-4 Clear settling.** One global balance, a per-account breakdown, and a settle-up flow that proposes how a payment is spread over accounts. 5. **G-5 Insight.** Monthly and weekly spending by category and by account, computed on the phone; averages, trends and a two-month forecast computed on the server (D-133). 6. **G-6 Trust.** Every change to an expense or settlement is visible as a timeline of who did what. 7. **G-7 Low-maintenance hosting.** One docker-compose stack on a 1 GB Raspberry Pi, nightly off-site backups, no cloud services except Firebase Cloud Messaging, Google Drive for backups and, for card purchases only, the account-information service Enable Banking (D-159). 8. **G-8 Bilingual.** Every user-visible string exists in Swedish and English. The phone's locale decides: Swedish when the device language is Swedish, English otherwise. 9. **G-9 History.** Six years of Splitwise history can be imported once, repeatably without duplicates. --- ## 2. Non-goals Not in v1. Agents must not build any of these, even partially: * Receipt photos or receipt scanning. Profile pictures are the only file upload. * ~~Any card, bank, Spendwise or other financial-institution integration.~~ Revised by D-158: reading the purchases of the users' own payment cards through Enable Banking (account information only, §9.12, FR-22) is in scope. Still out: payments or transfers of any kind, bank accounts other than the connected cards, Spendwise, and any other provider than those in the card catalogue (E-36). * Multiple currencies or exchange rates. All amounts are SEK. (The importer has a one-off conversion option, §4 FR-17.) * Joint accounts, shared cards or "paid from the shared pot" as a payer. The payer is always one of the two users. * More than two users, more than one household, user deletion. * Passwords, password reset, email, SMS, OAuth sign-in. * Play Store distribution. * CSV or any other export, except the web client's CSV export of a filtered expense list (D-129, §15.7). * ~~A web client. The only HTML the server serves is the invite landing page (§4 FR-2).~~ Revised by D-129: the web client of §15 is in scope; it is not a progressive web app and has no offline use. The server serves HTML for the invite landing page (§4 FR-2) and the web client under `/app/` (D-132). * Server-side reporting beyond the advanced analytics of D-133 (§15.8): S-11 and S-12 stay computed on the phone. * Debt simplification (meaningless with two users) and netting several accounts in opposite directions inside one settlement. * Editing accounts, categories, recurring templates, profiles or invites while offline. * Tablet, foldable or landscape-specific layouts of the Android app. Portrait phone only; landscape must not crash. (The web client is designed for the desktop, §15.1.) * Widgets, Wear OS, iOS. * In-app language picker in the Android app. The phone locale decides. (The web client has a language choice, §15.3.) * Comments or chat on expenses. --- ## 3. Users & core user stories ### 3.1 Roles | Role | Who | Count | Extra rights | |---|---|---|---| | `admin` | The owner | exactly 1 | Creates invites (partner and device), cancels invites, revokes any device | | `member` | The partner | 0 or 1 | Revokes own devices other than the one in use | Everything else (expenses, settlements, accounts, categories, recurring templates) is open to both users with identical rights. Either user may edit or delete anything the other created. Each user edits only their own display name and picture. A user has one or more devices. v1 expects one phone each, but the model allows any number of devices per user without change. ### 3.2 User stories * **US-1** As the owner I set up the server once from the command line and pair my phone with a one-time code. * **US-2** As the owner I invite my partner with a link; my partner installs the app from the link, picks a name and a picture, and is paired. * **US-3** As a user I add an expense in a few taps: amount, description, category, account; the split defaults to the account's ratio and the payer defaults to me. * **US-4** As a user I record an expense my partner paid, so they do not have to. * **US-5** As a user I split an expense differently: a custom ratio, exact amounts, or the whole amount on one of us. * **US-6** As a user I see one number saying who owes whom overall, and the same per account. * **US-7** As a user I settle up: I enter what I paid my partner, the app proposes which accounts it settles (oldest first), I adjust and confirm. * **US-8** As a user I settle one account from that account's screen. * **US-9** As a user I create expense accounts ("Vardag", "Huset", "Japan 2027"), give each a default ratio, and archive them when done. * **US-10** As a user I set up a recurring expense (rent, streaming) that appears automatically each month. * **US-11** As a user I see how much we spent per month or week, by category or by account, as a household total or as my share. * **US-12** As a user I add and edit expenses on a plane or abroad without a connection, and everything syncs when I am back. * **US-13** As a user I get a notification when my partner adds, changes or deletes something, and I can see in the expense who changed what. * **US-14** As a user I am told when a new app version exists and install it from inside the app. * **US-15** As the owner I import our Splitwise history once. * **US-16** As the owner I can restore everything from a backup if the Pi dies. * **US-17** As the owner I can remove a lost phone's access. * **US-18** As a user I connect my own payment card with BankID once and then see my card purchases in Kvitt, updated by themselves, without typing them (D-158). * **US-19** As a user I turn a card purchase into a household expense, or link it to the expense my partner already entered, in one or two taps, from the Kortköp tab or from an account (D-158). --- ## 4. Functional requirements (v1) Each requirement has acceptance criteria (AC). An AC is satisfied only when a test (unit, integration, screenshot or instrumented) or a documented manual check in the milestone's gate demonstrates it. Limits and rules referenced as §9.x are stated only there. ### FR-1 Server bootstrap The server starts with an empty database. The household is created from the command line on the Pi. * AC-1.1 `kvitt-server bootstrap` on a database with no users creates an invite of kind `admin_setup`, prints its code (format §8.3), its landing link `https:///i/` (the base URL from `KVITT_PUBLIC_URL`), its expiry, and exits 0. * AC-1.2 `kvitt-server bootstrap` on a database that has users prints `already initialized` to stderr and exits 2. It creates nothing. * AC-1.3 Running `kvitt-server bootstrap` again before the first code is used cancels the previous `admin_setup` invite and prints a new one. * AC-1.4 Accepting the `admin_setup` invite (FR-2) creates the `admin` user with the given display name and the first device, and returns a device token. * AC-1.5 `GET /setup/status` returns `{"initialized": false}` before AC-1.4 and `{"initialized": true}` after. * AC-1.6 `kvitt-server pair-device --user ` creates a `device` invite for an existing user and prints it like AC-1.1. This is the recovery path when the owner has lost every paired phone. ### FR-2 Invites and pairing * AC-2.1 The admin creates a `partner` invite from Settings. It is refused with `household_full` when two users exist. Creating a new `partner` invite cancels any pending one. * AC-2.2 The admin creates a `device` invite for either user from Settings (to pair a second phone). * AC-2.3 An invite is single-use and expires 72 hours after creation. An expired, cancelled, used or unknown code is refused with `invite_invalid`; the response does not reveal which. * AC-2.4 Accepting an invite takes the code, a device name and (for `admin_setup` and `partner`) a display name. It returns a bearer token, the kind of the accepted invite (`invite_kind`, D-086), the user, the device and the household's users. For a `partner` invite it creates the `member` user. On a first pairing the app asks for the display name on S-1 (§13.3); a partner confirms it on S-2 (AC-2.10) (D-050). * AC-2.5 `GET /i/` serves an HTML landing page in Swedish if the request's `Accept-Language` prefers `sv`, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to `kvitt://invite?server=&code=` (followed by `&from=` when the request carries a valid `from` query parameter, D-087), the code in groups of 4, and one sentence of instructions (copy in §13.3, "Invite landing page"). It does not reveal whether the code is valid and contains no personal data of its own: the `from` name is only passed on into the `kvitt://invite` link and never shown on the page; an invalid `from` (not UTF-8 after percent-decoding, a control character, or more than 40 code points after trimming) is dropped and the page still opens (D-087). While no APK is published (E-5 would answer `not_found`) the page omits the download button. The page is sent with `Referrer-Policy: no-referrer` and `Cache-Control: no-store`. The 404 page (E-4 with a code outside 1 to 32 characters) shows only "Kvitt" and "404". Since the app gate (§8.5, D-126) the page opens in a browser only on the home LAN; from outside, a browser gets a closed connection, while the app opens the link (AC-2.6, `autoVerify="false"`, so no App Links check depends on the page). * AC-2.6 The app opens from `kvitt://invite?server=…&code=…[&from=…]` and from `https:///i/[?from=…]` (the manifest placeholder `publicHost`, D-121; unverified link, the user may get a chooser; the intent filter names no port, so it matches every port of the host and an older `:8443` link still opens S-1 with the server it names; invite-link parsing drops an explicit default port, `:443` for `https` and `:80` for `http`, D-124) directly into the pairing screen with both fields filled. When the link names the inviting admin (`from`, E-12, D-087), S-1's help line names them (`onboarding_code_help`); links without a name (older links) still open, with the generic help line. The `https` link is declared with `android:autoVerify="false"`. An invite link opened while the phone is paired is ignored: the app stays where it is and never re-pairs over live data (D-084). A link may name any server: as built, S-1 opens its server row expanded when the link's server is not the release default (`RELEASE_SERVER_URL` = `https://`, D-121, D-124), so the address is visible and editable before pairing; v1 adds no confirmation step for an unusual server (security review L-6, D-116; a confirm step would need a design state, OQ-6). * AC-2.7 The pairing screen also accepts manual entry: a server URL (prefilled with `RELEASE_SERVER_URL` in release builds, D-121) and the code. Codes are normalised per §8.3 before sending. * AC-2.8 Release builds accept only `https://` server URLs. Debug builds also accept `http://` for `localhost`, `10.0.2.2` and private LAN addresses (`10.*`, `172.16.*`-`172.31.*`, `192.168.*`). The limit is enforced in code by `SessionRepositoryImpl.normaliseServerUrl`, not by the network security config, which cannot express IP ranges (§8.5, D-082). A release build therefore pairs with any `https://` server a link or the user names; what S-1 shows then is in AC-2.6 (D-116). * AC-2.9 After pairing, the app runs a full sync (§14.4) and then asks for the notification permission (Android `POST_NOTIFICATIONS`). As built (M8), the request is made after the first successful sync after pairing. * AC-2.10 A `partner` pairing (E-3 `invite_kind` = `partner`, D-086) continues to a profile step (display name prefilled from the accept call, optional picture) before the Home screen. Every other pairing goes to Home, including a member pairing a second phone with a `device` invite. * AC-2.11 The admin sees pending invites (kind, created, expiry) in Settings and can cancel each. * AC-2.12 More than 10 failed invite accepts from one client IP within 60 minutes yield `rate_limited` for the rest of that window. A failed accept is any 4xx answer of E-3 other than 429. After the tenth failure, further accepts from that IP are refused with `rate_limited` until 60 minutes after the first counted failure; the window is fixed from the first counted request, not sliding (D-031). ### FR-3 Devices and revocation * AC-3.1 Settings lists devices: the admin sees all devices of both users; a member sees their own. Each row shows device name, owner, paired date, last seen date, app version (the version name, "Kvitt 1.1", once the device has reported one, else the version code; D-090), and "This device" for the current one. * AC-3.2 The admin can revoke any device except the one in use (`cannot_revoke_current_device`). A member can revoke their own devices except the one in use. A member who names a device of the other user gets `not_found`, the same answer as for an unknown id, so the answer does not tell whose the device is (D-156, security review L-10; implemented in `07c36d2`). * AC-3.3 A revoked device's next request gets HTTP 401 `device_revoked`. The app then deletes its token, its local database and its cached pictures, and shows the "Device removed" screen with a "Pair again" button. * AC-3.4 A revoked device never receives pushes again. * AC-3.5 Devices are never deleted from the database; revocation sets `revoked_at` and clears the device's `fcm_token` (D-034). ### FR-4 Profiles * AC-4.1 Each user has a display name (limits §9.6) and an optional profile picture. * AC-4.2 A user edits their own display name in Settings (online only). The change reaches the other phone through sync. * AC-4.3 A user sets their own picture from the Android photo picker (no camera capture in v1) or removes it. Upload is online only. * AC-4.4 The server accepts JPEG, PNG and WebP up to 5 MiB and at most 8000 × 8000 pixels, and also at most 16 000 000 pixels (width × height, for example 4000 × 4000) decoding to at most 64 MB (16 MP × 4 bytes, so a 16-bit image is refused above 8 MP), checked from the image header before decoding with the decoder's limits set as well, else `image_invalid` (D-114); it applies EXIF orientation, centre-crops to a square, re-encodes as JPEG quality 85 at 512 × 512 and 128 × 128, strips all metadata, and increments the user's `picture_version` (the first upload is version 1; after a removal the next version follows the highest one ever used, §10.4, D-030). PNG and WebP transparency is flattened onto white (D-034). Other input is refused with `image_invalid`, `unsupported_media_type` or `payload_too_large`. * AC-4.5 Where no picture exists, the app shows the user's initials (first letter of the first two words of the display name, uppercase) on one of two colour tokens, `avatar-1` and `avatar-2` (values in U2's `docs/ux/design-spec.md`). A user's slot is a stable function of the user id, so the same user has the same colour on both phones (D-052). * AC-4.6 The app caches pictures on disk (Coil) keyed by user id and `picture_version`, so they show offline. ### FR-5 Household gate * AC-5.1 Until the partner has joined, the admin's Home screen shows "Waiting for your partner" with the "Invite partner" action, and creating accounts, expenses, settlements and recurring templates is not offered. In that state `SettlementRepository.start` and `recompute` return null and `save` returns `SaveResult.Invalid` with a `payer` `validation_failed` error; nothing throws (§14.2.4, D-111). * AC-5.2 The server refuses account creation, recurring template creation and any expense or settlement mutation with `household_incomplete` while fewer than two users exist. (The rule exists because every split refers to both users.) ### FR-6 Expense accounts * AC-6.1 Either user creates an account (online) with a name and a default split ratio. The editor starts at 50/50. Ratios are entered as percent with up to two decimals per user, the second user's value always being 100 minus the first; stored as bp (§9.1). * AC-6.2 Account names are unique among non-deleted accounts, compared case-insensitively after trimming (Unicode lowercase). A duplicate is refused with `name_taken`. * AC-6.3 Either user renames an account or changes its default ratio (online). A ratio change affects only expenses created afterwards; existing expenses keep the shares they were saved with. * AC-6.4 Either user archives or unarchives an account (online), even with a non-zero balance. Archived accounts are hidden from the account pickers for new expenses and recurring templates, shown in a collapsed "Archived" section on Home, still count toward the global balance, still appear in analytics and can still be settled. Hiding is a picker rule only: the server accepts archived accounts and categories in E-28 and E-29 (as it does in sync pushes, §9.10). * AC-6.5 Either user deletes an account (online) only when no non-deleted expense, no allocation of a non-deleted settlement and no non-deleted recurring template references it. Otherwise `account_not_empty`. Deletion is a soft delete (tombstone) so phones learn of it through sync. * AC-6.6 Accounts are listed by `created_at` ascending, the order used by the settlement proposal (§9.4). * AC-6.7 The account create and edit actions are disabled with a "Needs connection" hint when the phone is offline (§9.9). ### FR-7 Categories * AC-7.1 One global, editable category list. A category has a name, an icon token (§9.7) and a colour token (§9.7). * AC-7.2 The database is seeded with exactly these fourteen default categories (fixed ids, §14.1; the four with `sort_order` 25, 45, 85 and 95 were added by migration 0002, §14.1.1, D-054), each shown with its localised default name until a user renames it: | `default_key` | English name | Swedish name | `icon` | `color` | `sort_order` | |---|---|---|---|---|---| | `groceries` | Groceries | Mat & hushåll | `shopping_cart` | `green` | 10 | | `restaurants` | Dining (D-067) | Restaurang & café | `restaurant` | `orange` | 20 | | `housing` | Housing | Boende | `key` | `indigo` | 25 | | `home` | Home | Hem | `home` | `brown` | 30 | | `utilities` | Utilities | Räkningar | `bolt` | `yellow` | 40 | | `insurance` | Insurance | Försäkringar | `shield` | `slate` | 45 | | `transport` | Transport | Transport | `directions_car` | `blue` | 50 | | `travel` | Travel | Resor | `flight` | `teal` | 60 | | `entertainment` | Entertainment | Nöje | `movie` | `purple` | 70 | | `health` | Health | Hälsa | `medical_services` | `red` | 80 | | `children` | Children | Barn | `child_care` | `lime` | 85 | | `shopping` | Shopping | Shopping | `shopping_bag` | `pink` | 90 | | `fees` | Fees & taxes | Avgifter & skatter | `account_balance` | `amber` | 95 | | `other` | Other | Övrigt | `category` | `grey` | 100 | `home` keeps furniture, supplies, maintenance, electronics and services; rent and mortgage belong to `housing` (D-054). * AC-7.3 Either user creates a category (online) with a name, icon and colour. New categories get `sort_order` = the maximum `sort_order` over non-deleted categories + 10 (D-034). * AC-7.4 Either user renames, re-icons, recolours or reorders a category (online). Renaming a default category stores the custom name; "Reset name" (only for default categories) clears it so the localised name shows again. * AC-7.5 Either user archives or unarchives a category. Archived categories are not offered for new expenses or templates but stay on existing ones and in analytics. * AC-7.6 A category is deletable only if no non-deleted expense or non-deleted recurring template uses it (`category_in_use` otherwise). Default categories (with a `default_key`) cannot be deleted (`category_is_default`); they can be archived. Deletion is a soft delete. * AC-7.7 Category names have no uniqueness rule. ### FR-8 Expenses * AC-8.1 An expense has: account, amount (öre), description, category, date, payer, split, notes, created-by, last-modified-by, timestamps. Limits in §9.6. * AC-8.2 Either user creates, edits or deletes any expense, online or offline. The id is a client-generated UUIDv7. * AC-8.3 The payer is either user. The editor defaults the payer to the current user. * AC-8.4 The date is any date within §9.6 limits, past or future. It defaults to today in the phone's time zone. This date is the only date used in analytics and lists. * AC-8.5 Split modes (enum §9.1): `default` (the account's default ratio, copied into the expense when saved), `ratio` (a custom ratio), `exact` (exact öre per user), `full` (the whole amount borne by one chosen user, who may be the payer or not). Share computation per §9.1. * AC-8.6 The editor defaults: account = the account last used on this phone if still active, else the oldest active account; category = the category suggestion of §9.11 for a new expense, whose last step is the category last used in that account on this phone if still active, else `other` (D-049); when `other` is archived too and no active last-used category exists, the editor shows no category until the user picks one (v0.9); editing an existing expense keeps its category; split mode `default`. On the web "this phone" means this browser (`localStorage`, D-147). * AC-8.7 Exact mode: the user types one user's share; the other user's share is the amount minus it and is shown read-only. A typed share larger than the amount blocks saving with an inline error. * AC-8.8 Changing the account of an expense in mode `default` re-copies the new account's ratio. In other modes the split is kept. * AC-8.9 Deleting asks for confirmation and is a soft delete. Deleted expenses disappear from lists, balances and analytics on both phones after sync. * AC-8.10 Expense lists show, per row: category icon and colour, description, date, amount, and a one-line effect from the viewer's side ("You lent 205,00 kr", "You borrowed 205,00 kr", "Not involved" for `full` on the payer, matching §13 copy; Swedish "Du lånade ut 205,00 kr", "Du lånade 205,00 kr", "Inte inblandad", D-052). Rows are ordered by date descending, then `created_at` descending. * AC-8.11 An expense created from a recurring template is an ordinary expense in every way (editable, deletable) and shows "Recurring" with the template name in the detail view. ### FR-9 Recurring expenses * AC-9.1 Either user creates, edits, pauses, resumes or deletes a recurring template (online). A template holds the expense fields (account, amount, description, category, payer, split mode and shares, notes) plus `day_of_month` (1-31), `start_period` and an optional `end_period`. * AC-9.2 The server creates one expense per template per period at 06:00 Europe/Stockholm on the template's day. Days 29, 30 and 31 clamp to the last day of shorter months (day 31 in April creates on 30 April; day 29 in February 2027 creates on 28 February). * AC-9.3 Generation rules (catch-up after downtime, idempotency, split resolution) are in §9.5. * AC-9.4 Editing a template changes only expenses generated afterwards. * AC-9.5 A generated expense that a user deletes is not recreated. * AC-9.6 Each generated expense writes an audit entry with `source` = `recurring` and sends `recurring_expense_created` to every device of both users. * AC-9.7 The template list shows each template's description, amount, account, day of month, paused state and next occurrence date (computed by the server, `next_occurrence_date`). * AC-9.8 The editor's default `start_period` is the current month if `day_of_month` is today or later in the phone's time zone, else the next month. A new template's `day_of_month` defaults to today's day of the month, so the default `start_period` is the current month until the user picks an earlier day (D-072). The user may choose a `start_period` up to 12 months in the past (backfill) and any month in the future. ### FR-10 Balances * AC-10.1 Home shows one global balance between the two users from the viewer's side: " owes you X", "You owe X" or "All settled up". * AC-10.2 Home lists every active account with its balance in the same wording, and archived accounts in a collapsed section. * AC-10.3 The account screen shows that account's balance and its expenses and settlements interleaved by date. * AC-10.4 Balances are computed on the phone from the local database with the domain function of §14.5, include pending (unsynced) local changes, exclude deleted rows, and update immediately after a local write. * AC-10.5 The global balance equals the sum of the per-account balances, archived accounts included. ### FR-11 Settlements * AC-11.1 A settlement is a payment from one user to the other with an amount, a date, an optional note and one or more per-account allocations. Limits in §9.6. * AC-11.2 The allocations' sum must equal the settlement amount. Each allocation is positive. An account appears at most once. * AC-11.3 Global settle-up (from Home): the direction defaults to "the user who owes pays", the amount defaults to the absolute global balance (or empty when it is zero). The app computes the proposal with `propose_allocation` (§9.4, §14.5) and shows one editable row per proposed account plus "Add account" to add any other account (active or archived). The confirm button is enabled only when the allocations sum to the amount; the remaining difference is shown live. * AC-11.4 Per-account settle-up (from the account screen) creates a settlement with exactly one allocation for that account; the amount defaults to the absolute account balance and the direction to "the user who owes in this account pays". * AC-11.5 An allocation larger than the payer's current debt in that account is allowed (over-settling flips the account's sign) and is marked with a warning per row ("This settles more than is owed in "). The confirm dialog repeats the warning. * AC-11.6 Settlements can be created, edited and deleted (soft delete) offline, by either user, like expenses. When editing, the proposal and warnings are computed with the edited settlement excluded from the balances. An edited settlement opens with its saved allocation rows counted as edited by the user, so changing the amount keeps them; "Suggest again" (`settle_propose_again`, "Föreslå igen") re-proposes (D-074). * AC-11.7 A settlement row in lists shows " paid X" and the account count when more than one allocation exists. In an account's list (S-5) X is the settlement's allocation to that account, not the settlement total; everywhere else X is the total. * AC-11.8 The server's `POST /settlements/proposal` returns exactly what the phone's `propose_allocation` returns for the same server state (verified by shared vectors). The phone always computes locally; the endpoint exists for parity tests and server-side tooling. ### FR-12 Audit log and timeline * AC-12.1 The server appends an audit entry for every accepted create, update and delete of an expense or settlement, from the app, the scheduler or the importer. Entries are never updated or deleted. * AC-12.2 An entry records entity type, entity id, action, source, actor user, actor device (null for scheduler and importer), client time of the change, server time, and a field diff (§14.4.7). * AC-12.3 Audit entries reach both phones through sync pull. * AC-12.4 The expense and settlement detail views show a timeline, newest last, one line per entry, such as "Anna created this · 3 Oct 14:02", "You changed the amount from 400,00 kr to 412,00 kr · 3 Oct 18:30", "Anna deleted this". Diffs show field names in the UI language; ids are resolved to names (accounts, categories, users). * AC-12.5 A pending local change not yet synced shows as a last timeline line "Your change is waiting to sync". ### FR-13 Push notifications * AC-13.1 When the server accepts a create, update or delete of an expense or settlement from a device of user U, it sends a push to every non-revoked device of the other user that has an FCM token. Types and payload in §14.6. * AC-13.2 Recurring generation sends to every non-revoked device of both users. * AC-13.3 The importer sends no pushes. * AC-13.4 One `POST /sync` that produces more than 3 notifiable changes sends one `sync_batch` push instead of one per change. * AC-13.5 The app shows the notification only after it has synced the change, built from local data in the phone's language (title and body copy in §13). If that sync fails within 20 seconds, it shows a generic text without amounts or descriptions. * AC-13.6 Tapping a notification opens the detail view of the changed expense or settlement (S-8 or S-10). It opens Home for deletions, for `sync_batch` and for the generic notification (shown when the sync failed or the entity or its actor is missing locally) (D-085). * AC-13.7 The app registers for push by Firebase Installation ID (D-083): it calls `register()` whenever it is paired and at each app start while paired, and every id Firebase hands to `onRegistered(fid)` is sent as `fcm_token` with `PUT /devices/current/push-token` (E-17). The server sends pushes to that id in the FCM HTTP v1 `message.fid` field (§14.6, D-096). When the device is revoked (AC-3.3) the app calls `unregister()`. A failed E-17 send (network error, 429, 5xx) is kept and sent again after each successful sync until the server accepts it; a newer id from `onRegistered` replaces the kept one (`PushTokenRetry` in `:app`, D-112). * AC-13.8 No personal content (names, amounts, descriptions, notes) is placed in the FCM payload. * AC-13.9 Notifications are posted with `VISIBILITY_PRIVATE` and a public version carrying the generic text "Kvitt" / "Open Kvitt to see it" (`notif_generic_title`, `notif_generic_body`), so a secure lock screen shows no description, amount, account or name (security review L-7, D-116). ### FR-14 Analytics * AC-14.1 Analytics (S-11, S-12) are computed on the phone from Room with the pure function of §14.5.4. They work offline and include pending local changes. Revised by D-133: the advanced analytics (per-category averages and trends per month, the two-month forecast per account) come from the server's `/analytics/*` endpoints (E-34 trends, E-35 forecast, rules §14.5.7, D-138), online only (§9.9), shown on the web (W-11) and in the app's Insights section on S-11 (W6, as built D-141 to D-143) (§15.8); the web client computes S-11 and S-12's equivalents in the browser with the same functions (§15.5). * AC-14.2 Range presets: `this_month`, `last_3_months`, `last_6_months`, `last_12_months`, `this_year`, `all_time`, `custom` (start and end date pickers). "Last N months" means the current month and the N-1 months before it, whole months. Default `last_6_months`. * AC-14.3 Granularity: `month` or `week` (ISO, Monday start). Default `month`. * AC-14.4 Measure: `household` (sum of full expense amounts, whoever paid) or `my_share` (sum of the viewer's shares). Default `household`. * AC-14.5 Group by: `category` or `account`. Default `category`. * AC-14.6 Charts: (1) spend over time, a stacked bar per bucket coloured by group; (2) category breakdown for the range, horizontal bars sorted by amount descending with amount and percent; (3) account breakdown for the range, same form. A total for the range is shown above the charts. Settlements are never counted as spend. * AC-14.7 Every bucket in the range appears, empty ones as zero. Buckets at the edges of a custom range count only dates inside the range. * AC-14.8 Tapping a bar or a breakdown row opens a list of the expenses behind that number. * AC-14.9 Deleted expenses are excluded; archived accounts and categories are included. * AC-14.10 Percentages are rounded half-up to one decimal and may not sum to exactly 100. * AC-14.11 A drill-down for one category or account over the range shows its spend per bucket as a line chart (every bucket, empty ones as zero), the range average as a dashed reference line, a trailing moving average over 3 buckets, and a caption comparing the latest whole bucket with the average in whole percent; its range and granularity can be changed on the drill-down without changing S-11 (D-128). ### FR-15 Offline and sync * AC-15.1 The phone holds a full local copy of: users, accounts, categories, recurring templates, expenses, settlements and audit entries (Room, §14.2). * AC-15.2 Expenses and settlements can be created, edited and deleted offline. Accounts, categories, recurring templates, profile, pictures, invites and devices need the server; their actions are disabled offline with "Needs connection". * AC-15.3 Sync runs on app start and every return to the foreground, 2 seconds after the last local write (debounced), every 15 minutes in the background through WorkManager (network constraint), and on every FCM data message (expedited one-off work, waited for up to 20 seconds, AC-13.5). A pull-to-refresh on Home (S-4) or account detail (S-5) also runs a sync, the same call as S-16 "Synka nu" (D-127). Only one sync runs at a time. WorkManager names in §14.4.1 (D-028). * AC-15.4 The app shows a "Last synced" relative time and a pending-changes count on Home, and a sync status screen (§13). * AC-15.5 Conflicts resolve by last-write-wins per row on the client `updated_at`, deletes are final, and rejected changes are shown to the user (§14.4). * AC-15.6 A phone offline for 14 days with 200 local changes syncs all of them on reconnect (integration test with a scripted clock). ### FR-16 In-app update * AC-16.1 The server hosts the latest signed release APK and its metadata (§10.6). * AC-16.2 Release builds call `GET /apk/latest` on every cold start and at most once per 6 hours while resumed (the manual check of AC-16.6 skips this throttle, D-140). When `version_code` is greater than the installed one, Home shows an "Update available" banner with the version name. * AC-16.3 "Update" downloads the APK to the app's cache, verifies the size and SHA-256 from the metadata, and installs through `PackageInstaller`. The first time, Android asks the user to allow "Install unknown apps" for Kvitt; the app explains this beforehand: `UpdateRepository` emits `UpdateState.NeedsInstallPermission` once (the explanation-shown flag in `prefs.preferences_pb`, §14.2.2), and the next `install()` proceeds to the system prompt. On Samsung phones Auto Blocker must be off for the install: with it on, the system installer refuses the APK (observed on both phones, 2026-10-24: turning it off let the update install; D-156). The owner turns it off for an update and back on afterwards; the D-155 blocked text (`update_install_blocked`) names Auto Blocker (AC-16.7). * AC-16.4 A verification failure deletes the file and shows "Download failed, try again". * AC-16.5 Debug builds never check for updates. * AC-16.6 "Check for update" on S-22 calls `UpdateRepository.checkNow()` (D-140; `check()` before v0.30), which returns an `UpdateCheck` (D-092, revising D-073): `UpToDate` (nothing newer published; a 404 from E-5 counts as up to date) reports "You have the latest version" (`settings_about_up_to_date`, "Du har den senaste versionen"); `Failed` (no answer or another error) reports "Could not reach the server" (`common_could_not_reach_server`); `Available` shows the update banner. Debug builds report `UpToDate` without a request (AC-16.5). S-22 "Check for update" always calls `GET /apk/latest`, whatever the 6-hour throttle of AC-16.2 says, and restarts that window; the automatic checks on cold start and resume keep the throttle (D-140). * AC-16.7 If the system installer refuses or fails the install, Home and S-22 show the Error banner "Kunde inte installera uppdateringen" (`update_install_failed`) with the reason (blocked by the phone's security settings, `update_install_blocked`, or the installer's own message) and "Försök igen", which downloads again; declining the system dialog shows "Uppdatera" again without an error (D-155). The cached APK is deleted once the installer answers. ### FR-17 Splitwise import * AC-17.1 A one-off CLI `kvitt-import` (crate `kvitt-import`) imports Splitwise CSV exports, one file per Splitwise group, each into a Kvitt account named on the command line. Details §10.8. * AC-17.2 Re-running the same import creates nothing new (stable import key, §10.8). * AC-17.3 Splitwise categories map to Kvitt categories by the fixed table in §10.8; unknown names map to `other` and are listed in the summary. * AC-17.4 Splitwise payments become settlements with one allocation. * AC-17.5 `--dry-run` prints the summary (rows read, expenses, settlements, skipped duplicates, unknown categories, non-SEK rows, the reconciliation of §10.8) and writes nothing; it runs the whole import transaction and rolls it back, with the same exit status a real run would have (D-046). * AC-17.6 Imported rows produce audit entries with `source` = `import` and no pushes. ### FR-18 Backups and restore * AC-18.1 Every night at 03:30 Europe/Stockholm the backup sidecar writes a consistent SQLite snapshot and a pictures archive, gzips them and uploads them to the owner's Google Drive folder with rclone. Nothing on Drive is ever deleted or overwritten. Verified by the owner on 2026-10-24: the first backup on the Kvitt Pi ran and its snapshot pair is in the Drive folder (D-156, §11.4). * AC-18.2 A restore drill following §10.9 restores a backup on the Pi and both phones then sync correctly, documented in `docs/validation/restore-drill.md`. ### FR-19 Localisation and formatting * AC-19.1 Every user-visible string is a resource in `values/strings.xml` (English, the fallback) and `values-sv/strings.xml` (Swedish). No string literal appears in feature code. A lint or test in the Android gate fails on a key missing from either file. The APK carries only these two languages: `android/app/build.gradle.kts` sets `androidResources.localeFilters` to `en`, `sv` (G-8; `2bd6b58`), so libraries' translations into other languages are not packaged. * AC-19.2 Money formatting per §9.8, dates per §9.8. * AC-19.3 Category default names and every enum shown to users have strings in both languages. ### FR-20 Health * AC-20.1 `GET /health` returns status, server version, schema version and a database check, unauthenticated, in under 100 ms on the Pi. * AC-20.2 The docker healthcheck of the `server` service uses it. ### FR-21 Web client The web client of §15 (D-129 to D-134). Its acceptance criteria: * AC-21.1 `https:///app/` serves the web client from the server image; every path under `/app/` that is not a file serves `index.html`, except a missing `assets/*` file, which is 404 `not_found` (D-135); without the web files (`KVITT_WEB_DIR` missing) `/app/*` is 404 `not_found` (§15.4). * AC-21.2 A browser logs in with an invite code through E-3 like a phone pairs (any of the three kinds), as device `Web · `; the token is kept in `localStorage` and sent as a bearer token; the browser appears on Devices and can be revoked from another device (§15.2). * AC-21.3 A 401 forgets the token and shows the login page; `device_revoked` adds the S-18 message. Log out forgets the token and says that the browser stays on Devices until revoked. * AC-21.4 Every page of §15.6 shows the server's current data: the household snapshot is pulled from `GET /sync` on load, on window focus and after every write; nothing but the token, the language choice and per-viewer conveniences survives a reload (§15.5). * AC-21.5 Writes go to the server at once (`POST /sync` for expenses and settlements, REST for the rest); a failed write changes nothing and keeps the form's values; nothing is queued. * AC-21.6 The web's money functions pass the vector files of §14.5.2's functions; its analytics functions pass `resolve_range.json` and `spend_series.json`; its category suggestion passes `suggest_category.json` (§15.3). * AC-21.7 Swedish when the browser prefers Swedish, English otherwise; a choice on W-13 overrides it in that browser; every string exists in both languages. * AC-21.8 From 720 px wide the layout has a sidebar; under 720 px one column with a bottom bar, usable at 360 px; light and dark follow `prefers-color-scheme`. * AC-21.9 `N` opens a new expense, `/` the search, `?` the shortcut help, `Esc` closes the open dialog or editor; `N`, `/` and `?` do nothing while typing in a field. * AC-21.10 Search finds non-deleted expenses by text in the description or notes, filtered by amount range, date range, account, category and payer; the filtered list exports as a CSV file (§15.7). * AC-21.11 Accessibility as §13.1, for the web: every action reachable by keyboard with a visible focus, a label for every icon-only control, text contrast at least 4.5:1, nothing conveyed by colour alone, semantic headings and landmarks. * AC-21.12 No service worker and no web app manifest; nothing is loaded from another origin at run time (§15.2). ### FR-22 Card purchases Each user may connect their own payment card; the server reads its purchases through Enable Banking and the card holder turns them into expenses (D-158 to D-176). Rules in §9.12 (CP-1 to CP-14); screens S-23 to S-27 (§13.3) and W-21 to W-23 (§15.6); endpoints E-36 to E-44 (§14.3.4). Its acceptance criteria: * AC-22.1 Settings (S-13) has the row "Anslut kort" ("Connect card"), which opens S-24: the server's card catalogue (E-36), each card with its artwork. v0.35 has exactly one entry, `sj_prio_mastercard` (SJ Prio Mastercard, SEB Kort). A provider the server has no Enable Banking application for (CP-13) is listed as "Inte konfigurerat på servern" and cannot be tapped. * AC-22.2 Tapping a card that is not connected opens S-25: the history choice (CP-3) and "Anslut med BankID". The app creates an authorisation attempt (E-37), opens the returned bank URL in a Custom Tab, and polls the attempt (E-38) while S-25 is open; the bank (BankID: app-to-app on the phone, QR on a computer) redirects to `/cards/callback` (E-39), whose page says the card is connected and links back to the app. Within 5 seconds of returning, S-25 shows "Kortet är anslutet" and opens S-23 (CP-2). * AC-22.3 Only the card holder sees their connection and purchases. A device of the other user that names a connection or purchase id gets `not_found` (CP-11). Nothing about a card purchase is in `GET /sync`, the audit log, a push, S-11, S-12, E-34 or E-35 until it is turned into an expense, which is then an ordinary household expense in all of them. * AC-22.4 The Kortköp tab (S-23, the second bottom destination, §13.2) lists the holder's purchases newest first, grouped by day, with the filters "Att hantera" (default), "Kopplade", "Ignorerade", "Alla"; pending purchases carry "Preliminär"; deposits and refunds (`credit`) appear only under "Alla" (CP-8). The list works offline from the phone's copy (§14.2.1). * AC-22.5 The list updates by itself: the server fetches each active connection in the background at most 4 times per 24 hours (CP-4); "Uppdatera" and pull to refresh on S-23 ask the server for a fetch with the user present (CP-5); S-23 shows "Uppdaterad " of the last successful fetch. * AC-22.6 "Skapa utgift" on a purchase opens S-7 prefilled (CP-9): amount, description from the merchant text, the purchase date, the card holder as payer, the account, the category of §9.11 and the account's default split; "Spara" saves the expense and links the purchase to it. From the Kortköp list that is: tap the purchase, tap "Skapa utgift", tap "Spara". * AC-22.7 The purchase sheet lists the household's existing expenses that match the purchase (CP-10, same amount, within ±3 days, not linked yet, entered by either user); one tap links. A row in "Att hantera" with exactly one match shows the chip "Koppla till ", which links in one tap. * AC-22.8 "Ignorera" moves a purchase to "Ignorerade" (private, undoable with "Ångra" and "Sluta ignorera"); "Ta bort koppling" moves a linked purchase back to "Att hantera"; deleting a linked expense, by either user, moves its purchase back to "Att hantera" (CP-9). * AC-22.9 Account detail (S-5) offers "Från kortköp" while the viewer has a connection: S-27 lists the viewer's purchases to handle, and one tap opens S-7 prefilled with that account. * AC-22.10 The expense detail (S-8) shows the card holder, and only the card holder, the line "Från kortköp: , " with "Ta bort koppling". * AC-22.11 From 14 days before the consent ends (`valid_until`, CP-6) S-23 shows "Kortanslutningen upphör " with "Förnya med BankID"; after it ends, S-23 shows "Kortanslutningen har upphört", keeps the stored purchases, disables "Uppdatera" and offers "Förnya med BankID". Renewing keeps every purchase and its state. * AC-22.12 "Koppla från" (S-26, with confirmation) revokes the session at Enable Banking, deletes the purchases neither linked nor ignored, and keeps the linked and ignored ones unless "Radera även kopplade och ignorerade köp" is ticked; expenses already created stay (CP-12). * AC-22.13 Linking, unlinking, ignoring and "Skapa utgift" work offline on the phone; the link changes are kept on the phone and sent after the next successful sync push (CP-9). Connecting, renewing, "Uppdatera", the history setting and "Koppla från" need the server (§9.9). * AC-22.14 No merchant text, amount, session id, authorisation code or `state` reaches a server log, an NPM log or a phone log (§8.7, D-163). * AC-22.15 The web client (K3) has the same features: W-21 Kortköp, W-22 Anslut kort with BankID by QR in the browser, W-23 the connection's settings (§15.6). --- ## 5. Data model This section explains the entities and their meaning. The exact server DDL is §14.1, the phone's Room entities §14.2, the wire JSON §14.3. Limits are in §9.6. ### 5.1 Entity overview | Entity | Id origin | Synced to phones | Written by phones | Notes | |---|---|---|---|---| | User | server | yes (pull) | REST only, own profile | Exactly two in a complete household | | Device | server | no | REST only | One per paired app install | | Invite | server | no | REST only, admin | Single-use pairing code | | Account | server | yes (pull) | REST only | Expense account, like a Splitwise group | | Account default share | with account | yes (inside account) | REST only | One row per user, sums to 10000 bp | | Category | server (defaults seeded with fixed ids) | yes (pull) | REST only | Global list | | Recurring template | server | yes (pull) | REST only | Monthly generator | | Recurring template split | with template | yes (inside template) | REST only | Shares for modes `ratio`, `exact`, `full` | | Expense | client (UUIDv7) | yes (pull and push) | sync push | Soft delete | | Expense split | with expense | yes (inside expense) | sync push | One row per user | | Settlement | client (UUIDv7) | yes (pull and push) | sync push | Soft delete | | Settlement allocation | with settlement | yes (inside settlement) | sync push | One row per account | | Audit entry | server | yes (pull) | never | Append-only | | Sync mutation record | server | no | never | Idempotency of pushes | | Sync sequence, meta | server | no (epoch is sent in pull) | never | Global `server_version` counter, `sync_epoch` | | Card connection (D-167) | server | the holder's phones only, through `GET /cards/sync` (E-40), never `GET /sync` | REST only, its own user | One per user and card provider; holds the Enable Banking session (server only) | | Card purchase (D-167) | server (from the bank's transactions) | the holder's phones only, through E-40 | REST only (`PUT /cards/purchases/{id}`, E-44): link and ignore | Private to the card holder; `card_version` from `card_sequence` | | Card auth attempt (D-162) | server | no | never (created by E-37) | Single-use `state` of one BankID connection attempt | | Card sequence | server | no | never | `card_version` counter of card connections and purchases, separate from `server_version` | Local-only phone tables: `sync_state` and `sync_rejections` (§14.2), and `card_sync_state` (§14.2.2, D-173). ### 5.2 Ownership of fields * **`server_version`**: an integer from one global, strictly increasing sequence. Every insert or update of a synced row (users, accounts, categories, recurring templates, expenses, settlements, audit entries) takes the next value. Child rows (shares, splits, allocations) have none; a change to a child bumps its parent. Phones never set it. * **`created_at`, `updated_at`** of expenses and settlements are set by the phone's clock at the time of the local write and are the last-write-wins key. For server-written rows (all others, and expenses created by the scheduler or importer) they are the server clock. * **`created_by_user_id`** is set by the server from the authenticated device's user when it first stores the row, never taken from the client. `updated_by_user_id` and `updated_by_device_id` are set by the server on every accepted write. * **`deleted_at`** marks a tombstone. Tombstones are kept forever on both server and phone and are synced like any other row. * **`archived_at`** (accounts, categories) hides an item from pickers; it never affects money. ### 5.3 Money * Amounts are integer öre in signed 64-bit integers (Kotlin `Long`, Rust `i64`, SQLite `INTEGER`). * An expense's shares, one per user, always sum exactly to its amount. * Ratios are integer bp per user summing to 10000. * A user's **net position** in an account is: what they paid for expenses, minus their shares, plus settlement allocations they paid, minus settlement allocations they received. The two users' net positions always sum to zero. A positive net means the other user owes them that much. Formal definition §9.3. ### 5.4 Relationships * An expense belongs to one account and one category, has one payer and exactly two splits (one per user), and optionally references the recurring template and period it was generated for. * A settlement has a payer (`from_user_id`), a recipient (`to_user_id`) and one or more allocations, each to one account. * An audit entry references one expense or settlement by type and id. * A device belongs to one user. An invite of kind `device` targets one user. * References from synced rows point only at rows that are never hard-deleted, so a reference always resolves on the server. On the phone a reference may be briefly unresolved during a pull (§14.2); the UI shows such a row only after its references resolve. ### 5.5 What is not stored * No balances, totals or analytics are stored anywhere; they are always computed from rows. * No exchange rates, no currencies (SEK is implicit). * No passwords. Tokens and invite codes are stored only as SHA-256 hashes. --- ## 6. Architecture & stack ### 6.1 System view ``` Samsung S26 (owner) Samsung S26+ (partner) ┌──────────────────┐ ┌──────────────────┐ │ Kvitt app │ │ Kvitt app │ │ Compose UI │ │ │ │ domain (pure) │ │ │ │ Room (full copy) │ │ │ └───────┬──────────┘ └────────┬─────────┘ │ HTTPS :443 (bearer token) │ ▼ ▼ router: external 443 ──► Home Assistant Pi :443 (already in place for Home Assistant, D-124; external 80 too, for Let's Encrypt) ┌──────────────── Home Assistant Pi ────────────────┐ │ Nginx Proxy Manager (TLS, Let's Encrypt, D-120) │ └─────────────────────────┬─────────────────────────┘ │ plain HTTP over the LAN, :8080 ▼ ┌──────────────────── Kvitt Pi: Raspberry Pi 3, docker compose ─────────────────────┐ │ server (axum, :8080 published on the LAN) ──► /data/kvitt.sqlite (WAL) │ │ backup (sqlite .backup + gzip nightly ──► rclone ──► Google Drive) │ └───────────────────────────────────────────────────────────────────────────────────┘ │ FCM HTTP v1 (service account) ▲ Pi pulls images ▼ │ Firebase Cloud Messaging ──► phones owner's M4 Mac: build linux/arm64 images ──► Gitea registry ``` For card purchases (D-160) the server, and only the server, calls the Enable Banking API (`https://api.enablebanking.com`) over outbound HTTPS from the Kvitt Pi; the phones and the browser never talk to Enable Banking. The user's browser (a Custom Tab on the phone) visits Enable Banking's and the bank's pages for the BankID step and is redirected back to `https:///cards/callback` (E-39, §10.5.2). A browser running the web client (§15) reaches the same server the same way: HTTPS to NPM, which lets `/app/`, the invite accept and bearer-token requests through from outside the LAN (§8.5, D-130), and plain HTTP to the server, which serves the web client's files from its own image (D-132). ### 6.2 Android app | Concern | Choice | Why | |---|---|---| | Language | Kotlin 2.x, latest stable | Owner's requirement | | UI | Jetpack Compose, Material 3; Material 3 Expressive components where they are stable. The Material 3 artifact is pinned explicitly in the catalog (1.4.0 at M2b) alongside the Compose BOM. Against material3 1.4.0 only `ShortNavigationBar` of the Expressive set is public and used; every other Expressive API takes the fallback listed in D-064 (M6) | Native, modern, screenshot-testable | | Min / target SDK | `minSdk` 36 (Android 16); `compileSdk` and `targetSdk` 37 (the latest stable API when M2b pinned them, in `libs.versions.toml`) | Both phones run Android 16; no legacy paths | | Local DB | Room 3 (`androidx.room3`, 3.0.3 at M2b), KSP only, an `SQLiteDriver` with `androidx.sqlite:sqlite-bundled`, exported schema (D-012) | Full offline copy; migrations with tests | | DI | Hilt. `:data`'s `DataModule` provides implementations only; the bindings of the `:core:model` interfaces to them live in `DataBindingsModule` (`@DisableInstallInCheck`), which `:app` includes in both variants through `di/AppDataModule.kt` in `src/main` (D-041, revised by D-081) | Standard, works with WorkManager and ViewModels | | HTTP | Ktor client (OkHttp engine), kotlinx.serialization JSON | Kotlin-first, multiplatform-style DTOs | | Background | WorkManager (periodic 15 min sync, expedited sync) | Survives process death, respects Doze | | Dates | kotlinx-datetime | Allowed in pure modules (no `java.time`) | | Images | Coil (Compose) with an auth header interceptor | Disk cache for offline avatars | | Charts | Compose `Canvas` in `:core:design`, no chart library (D-063). Vico 3.3.1 cannot give the per-bar TalkBack nodes and tap targets, 2 dp segment gaps, corner radii on the whole bar (10/4 at M6; top 4, bottom 2 dp since D-097) and an axis gutter growing at 200 % that `docs/ux/design-spec.md` §3.10 requires. Vico was removed from the version catalog on 2026-10-18 (`a312758`); it was never used | The three charts are small; the design spec's accessibility rules need per-bar control | | Push | Firebase Cloud Messaging (firebase-messaging 26.0.0 through the Firebase BoM 35.0.2, google-services plugin 4.5.0; registration by Firebase Installation ID, D-083), only inside `:adapter:fcm` | Free tier; vendor isolated per `CLAUDE.md` | | Secrets | Android Keystore: an AES-256-GCM key (non-exportable) encrypts the device token; ciphertext in a private DataStore file | `EncryptedSharedPreferences` is deprecated | | Build | Gradle with version catalog `android/gradle/libs.versions.toml`, Gradle daemon on JDK 25 (pinned), bytecode target Java 17. With AGP 9, `check` runs only the debug unit-test variant. `versionCode` and `versionName` come from the Gradle properties `kvitt.versionCode` and `kvitt.versionName`, defaulting to `1` and `dev` (§10.6) | Existing pin | | Tests | JUnit, Compose screenshot tests with Roborazzi (1.76.0 at M2b) on Robolectric (4.17) with `@Config(sdk = [36])` (D-010), Room tests (DAOs and migrations) on Robolectric with the framework SQLite driver, migration tests built from the exported schema JSON rather than `room3-testing` (D-042), Ktor `MockEngine`, WorkManager's test `WorkManager` from `androidx.work:work-testing` (a `testImplementation` of `:data`, the same version as `work-runtime`, `c366fbd`). Unit tests run on JDK 25 with `--add-exports java.base/jdk.internal.access` and `--add-opens java.base/java.io` for Robolectric, set in `build-logic` for every Android module (D-013). `failOnNoDiscoveredTests` is off only for modules without `src/test` (the shells) and on everywhere else (D-014) | No mocking libraries (`CLAUDE.md`) | | Typeface | The Material 3 default typeface; the app bundles no font file (D-020) | No licence step, no font resource | | App icon | Adaptive launcher icon, vectors only (D-095): `android/app/src/main/res/mipmap-anydpi/ic_launcher.xml` with background colour `#9C5238` (the light theme's primary, `values/ic_launcher_colors.xml`) and foreground and monochrome layers (`drawable/ic_launcher_foreground.xml`, `drawable/ic_launcher_monochrome.xml`) drawing the `receipt_long` app mark at 48 dp, centred in the 108 dp canvas; the manifest's `android:icon` and `android:roundIcon` point at it, which clears lint `MissingApplicationIcon` | No raster assets; themed icons use the monochrome layer | Module graph (directory `android/`): | Module | Kind | Contents | May depend on | |---|---|---|---| | `:core:model` | pure Kotlin/JVM | Domain types (§14.5.1), analytics types (§14.5.4, package `se.hagstedt.kvitt.model.analytics`), `*State` classes, repository interfaces (§14.2.4) | kotlin, kotlinx | | `:domain` | pure Kotlin/JVM | Split, balance, allocation, analytics functions (§14.5); test-vector tests | `:core:model` | | `:core:design` | Android library | Built by M6 (`7730a31`) from `docs/ux/design-spec.md` §2 and §3: `KvittTheme`, tokens (`KvittColors`, `KvittShapes`, `KvittMotion`, which holds only the seesaw spring; navigation has no motion, D-108), typography, shared components (money text, avatar, category tile and pickers, balance header, sync pill, banners, charts drawn with `Canvas`, D-063; the ledger rows for expenses and settlements used by S-5, S-12 and S-16; the expense editor fields used by S-7 and S-19, D-019), `MoneyFormatter`, `DateFormatter`, the shared failure texts (`format/Failures.kt`: `FAILURE_NETWORK`, `commonFailureText`), the shared limits (`format/Limits.kt`: `BP_TOTAL`, `DISPLAY_NAME_MAX`, `DESCRIPTION_MAX`, `NOTES_MAX`, `FIRST_DATE`, `DAYS_AHEAD_MAX`, §9.6) and `savedSplitLabel` (S-8, S-16), shared strings (D-118). Its test fixtures (`src/testFixtures`, package `se.hagstedt.kvitt.design.testing`) are the shared test support of every Android module (D-118); they are not a module | `:core:model` | | `:core:fakes` | Android library, used only by tests, screenshot tests and debug previews | Scenario-driven fakes of every repository interface (they call `:domain` for computed values so fakes never disagree with the real rules) | `:core:model`, `:domain` | | `:data` | Android library | Room DB, DAOs, entities, Ktor API client and DTOs, sync engine, WorkManager workers, token store, repository implementations, APK updater | `:core:model`, `:domain` | | `:adapter:fcm` | Android library | `FirebaseMessagingService`, registration by Firebase Installation ID (D-083), maps messages to `PushEvent` (`:core:model`, `model/Push.kt`, moved there by M12e, D-095) through `PushMapping`; `PushListener` stays in the adapter | `:core:model` | | `:feature:onboarding` | Android library | Pairing, partner profile step, waiting for partner, device removed | `:core:model`, `:core:design` | | `:feature:home` | Android library | Home (global balance, accounts), sync status, update banner | `:core:model`, `:core:design` | | `:feature:account` | Android library | Account detail, account editor | `:core:model`, `:core:design` | | `:feature:expense` | Android library | Expense editor, expense detail and timeline | `:core:model`, `:core:design` | | `:feature:settle` | Android library | Settle up (global and per account), settlement detail and timeline | `:core:model`, `:core:design` | | `:feature:analytics` | Android library | Analytics screen and drill-down list | `:core:model`, `:core:design` | | `:feature:settings` | Android library | Settings hub, profile, categories, recurring templates, devices, invites, about | `:core:model`, `:core:design` | | `:feature:cards` | Android library (D-173, K2) | Kortköp tab (S-23) with the purchase sheet, Anslut kort (S-24), connect (S-25), the connection's settings (S-26), "Från kortköp" (S-27), the card artwork (`CardArt`) | `:core:model`, `:core:design` | | `:app` | Android application | `MainActivity`, navigation graph, Hilt wiring, deep links, notification channel and building, `Application` class | everything | **Screenshot tests (D-010).** `roborazzi.test.verify=true` in `android/gradle.properties` makes every unit-test run, and so `check`, compare renders with the committed goldens. An intended change is re-recorded with `tools/gradle-serial.sh recordRoborazziDebug`, and every changed PNG is opened before committing. AGP's Compose Preview Screenshot Testing plugin was still alpha when M2b chose. Golden layout (D-098, M8c, revises D-066): three goldens per component sheet or screen state, `_sv_light.png`, `_sv_dark.png` and `_en_light.png` (English covers string length, dark covers the theme; there is no `en_dark`), plus `_sv_light_200.png` at 200 % font scale for the long states; `:app`'s `empty_screen_.png` are the only other names. The content is composed once and language, theme and font scale are switched through a configuration context, so Compose's non-linear font scaling still applies. Every capture renders at 1x density through the Robolectric qualifier `w411dp-hdp-mdpi`, so a full screen of 411 × 891 dp is 411 × 891 px. Every committed PNG (goldens and the `docs/ux/` mock-ups) is compressed losslessly with `oxipng -o 4 --strip safe` before committing (`7c453d8`); Roborazzi verifies against the compressed files. Feature modules follow the same layout: each feature module has Roborazzi screenshot tests of its screen states with goldens in `src/test/screenshots/`, rendered full screen. A screen harness renders one more frame and idles the main looper before each capture; without that `:feature:home` and `:feature:account` captured the previous variant, so their committed `sv_dark` (and some `_sv_light_200` and dialog) goldens were one variant stale, fixed and re-recorded in `dc7a296` and `5b2ed37`. The reduction took the goldens from 1143 PNGs and 131 MB to 865 and 26 MB, the mock-ups from 36.8 to 26.0 MB, for a small repository on the home Gitea server. Since the Android overhaul (`c582363`, D-118) the harness is shared: `:core:design`'s test fixtures (`src/testFixtures`, package `se.hagstedt.kvitt.design.testing`) hold `Variant`, `TestClock`, `TestEnvironment`, `ScreenSnapshots` with the capture modes `Root`, `Screen` and `ScreenWhenIdle`, `MainDispatcherRule` and `withMain`, consumed as `testImplementation(testFixtures(project(":core:design")))`. Test fixtures are not a module, so the module graph below is unchanged; the architecture check rejects a module's test fixtures in any configuration other than a test configuration. The convention plugin `kvitt.android.screenshots` wires Roborazzi (plugin, `outputDir` `src/test/screenshots`) and the test libraries; feature build scripts carry no screenshot block. **Hilt wiring (D-081, M8).** Both variants bind every `:core:model` interface to `:data`: `android/app/src/main/.../di/AppDataModule.kt` includes `DataBindingsModule` (D-041), and `AppBuildInfo` (defined in `:data`'s `di/DataModule.kt`, provided by `:app`'s `di/AppBuildInfoModule.kt`, §14.2.4) comes from `BuildConfig` in both variants and also carries `RELEASE_SERVER_URL` as `releaseServerUrl` (D-122). The debug fakes module (`a41f240`) and the release-only data module were deleted by M8; no Hilt module lives in `src/debug` or `src/release`. Fakes live only in test source sets and feature previews and never reach an installed build; `:app`'s unit tests run on a plain `Application`, not the Hilt application. Features never call `:domain` directly: repositories return computed state (balances, previews, proposals), implemented in `:data` with `:domain`. The architecture check (Gradle task in `check`, owned by milestone M2b) fails the build on any other dependency edge and on `java.time`, `java.io` or `android.*` imports in `:core:model` and `:domain`. **Feature convention (D-017).** Binding for every feature module (worked example and the stateless overload used by previews in `android/README.md`): * Each feature exposes one composable entry point per screen. The entry point takes a ViewModel provided with `hiltViewModel()` and its navigation as plain lambdas (for example `onOpenExpense: (ExpenseId) -> Unit`, `onBack: () -> Unit`). * Features never reference other features or the `NavHost`. `:app` owns the `NavHost` and connects the lambdas to routes. * ViewModels receive repositories through the `:core:model` interfaces (§14.2.4). `:app` binds the interfaces through Hilt to `:data`; bindings to `:core:fakes` exist only in test and debug-preview source sets and never reach a release build. * Shared UI that two features need is built in `:core:design` by M6 (D-019); a feature never copies another feature's component. * Strings: `:app` holds only the app name; `:core:design` holds the shared strings (including the notification copy and channel name of §13.3 and §14.6, which `:app` uses); each feature module holds its own. Lint `MissingTranslation` is an error in every module. Keys of a feature module are prefixed by the feature name (`home_`, `account_`, `expense_`, ...); keys in `:core:design` never start with a feature name. This rule wins over any other key convention (§13.4, §14.7). **Fakes.** Every fake in `:core:fakes` has at least the empty scenario (D-018): the household is complete (two named users, no pictures), there are no accounts, the categories are exactly the fourteen defaults of AC-7.2 (D-054), and there are no expenses, settlements, templates, rejections or pending changes. Feature agents add their own scenarios (§11.3). ### 6.3 Server | Concern | Choice | Why | |---|---|---| | Language | Rust stable, edition 2024; the exact release pinned in `server/rust-toolchain.toml` (1.99.1 at M2a) and the same tag in the Dockerfile (§10.3) | Owner's requirement; small RSS on a 1 GB Pi | | HTTP | axum on tokio (multi-thread runtime, 2 worker threads) | Mature, small | | DB | SQLite via sqlx, WAL mode, migrations embedded with `sqlx::migrate!` | One file, easy backup, ample for two users | | Serialization | serde, serde_json | Standard | | OpenAPI | utoipa generates the document from code; `contracts/openapi.yaml` is the committed contract; a test asserts the generated document equals it (§14.3) | One contract, checked by the gate | | Logging | tracing, tracing-subscriber, JSON lines to stdout | Docker log rotation; no personal content (§8.7) | | Time | chrono with chrono-tz for Europe/Stockholm (M2a confirmed; no other time crate) | DST-correct scheduling | | Ids | uuid crate, v7 | Same format as phones | | Images | image crate (decode JPEG/PNG/WebP, encode JPEG) | Pure Rust, no system libs | | HTTP client (FCM) | reqwest with rustls (crypto provider aws-lc-rs, certificates checked by the platform verifier); needs a C toolchain at build time and CA roots in the image (§10.3) | No OpenSSL on the image | | FCM auth | Service-account JWT (RS256, jsonwebtoken crate, header `kid` = the key file's `private_key_id`) exchanged at `https://oauth2.googleapis.com/token` for an access token with scope `https://www.googleapis.com/auth/firebase.messaging`, cached until 5 minutes before expiry | HTTP v1 API needs OAuth2 | | Scheduler | A tokio task inside `kvitt-server` (no extra crate) | One process, simple | | Card purchases (D-160, K1) | `kvitt-server` module `src/cards/`: the provider registry (`providers.rs`), the `CardSource` trait and its one adapter `src/cards/enable_banking/` (the only code that names the vendor or reads its JSON; it maps it into Kvitt's own records), fetching and de-duplication (§9.12), the background poller (started from `src/tasks/`), the callback page; HTTP to Enable Banking through the existing reqwest client with rustls, JWTs signed RS256 with jsonwebtoken (both already in the stack) | Vendor isolated per `CLAUDE.md`; no new crate | Cargo workspace (directory `server/`): | Crate | Kind | Contents | |---|---|---| | `kvitt-domain` | library, no I/O | Money types, split, balance, allocation, settlement validation (§14.5), `next_occurrence_date` and the day clamping of §9.5 (D-015), test-vector tests. Depends only on `serde`; `serde_json` is a dev-dependency of the vector tests. Ids are plain strings (§14.5.1), so no `uuid` | | `kvitt-server` | binary `kvitt-server` | Config, DB, migrations, HTTP API, auth, sync, audit, push, scheduler, pictures, APK, CLI subcommands (§10.4) | | `kvitt-import` | binary `kvitt-import` | Splitwise CSV importer (§10.8). Depends on `kvitt-domain` and reuses `kvitt-server` as a library for DB access and audit writing (`kvitt-server` exposes a `lib.rs`) | **Server seams (D-015).** So that M3, M4 and M9 work in parallel on M2a's skeleton: * **Middleware in `router.rs` (M2a):** the `X-Kvitt-Protocol` check (426 `protocol_unsupported`), the `X-Kvitt-Server-Version` response header and the §8.6 body limits (1 MiB, 5 MiB for `PUT /me/picture`) are layers in `src/router.rs`. On authenticated routes the protocol check runs before the token check, so a request with a bad protocol header and a bad token gets 426, not 401. M3's rate limit (§8.6, AC-2.12) is a layer defined in `src/api/rate_limit.rs` and attached at one line in `router.rs` that M2a marks with a comment; that line is the only one M3 edits in `router.rs`. * **Background tasks:** M2a creates `src/tasks/mod.rs` with a `start` function that `serve` calls once after migrations (initially doing nothing). M4 owns `src/tasks/**` and starts the scheduler, the push sender and the `sync_mutations` pruning from it. As M4 built it (D-039): `tasks::start` returns the `AppState` with the real push queue installed, and `serve` builds the router from that returned state. Since the server overhaul (`6c405e1`) it is a synchronous `fn start(AppState) -> AppState` (it never awaited or failed); D-039 holds. * **Audit and push interfaces:** M2a defines the traits `AuditWriter` (in `src/audit/`) and `PushQueue` (in `src/push/`) with no-op implementations, and passes them through the application state (`AppState.audit`, `AppState.push`). M4 replaces the implementations (audit writer and diff of §14.4.7, FCM queue of §14.6); M3 and M9 only call the traits. Since M4 (D-039): `AppState::new` holds `SqlAuditWriter` by default and `NoopAuditWriter` no longer exists; `NoopPushQueue` stays as the queue when push is disabled (§14.6), and `tasks::start` installs the bounded `PushHub` otherwise. As M2a built them (exact signatures in `server/README.md`): * `AuditWriter::write` takes the caller's open connection inside its `BEGIN IMMEDIATE` transaction and one `AuditEvent` (entity type and id, action, source, actor user and device, `client_at`, `server_at`, and the wire object before the change, absent for a create, and after it, as JSON), and returns the new audit entry's id or an `AppError`. The writer derives `changes` (§14.4.7) and takes the entry's `server_version` from that same transaction. * `PushQueue::enqueue` takes one `PushMessage` (push type, one of the eight of §14.6; entity id or none; actor user id; count). It never blocks and never fails; callers call it after commit. * **Recurring generation:** M2a implements `next_occurrence_date` and the day clamping in `kvitt-domain` (§14.5.2). M3 writes the one function that generates the due periods of one template (§9.5: candidate periods, one transaction per candidate, audit through `AuditWriter`, push through `PushQueue`) in `src/api/recurring.rs`, used by E-28 and E-29; M4's scheduler calls that function for every template. M4 therefore lands after M3 (it may start in parallel and rebases on M3 before its gate). ### 6.4 Why these choices * **Phone computes balances and analytics.** Offline is a hard requirement and the data set is small (six years of two-person history is a few thousand rows). Computing on the phone keeps the server dumb and the Pi idle. * **SQLite over Postgres.** Postgres alone would use a large share of the Pi's 1 GB. SQLite in WAL mode handles two users trivially and backs up as one file. * **Global `server_version` sequence.** One integer cursor per phone is the simplest correct incremental sync. SQLite's single writer, with every write in a `BEGIN IMMEDIATE` transaction, makes versions commit in order, so a reader never sees a gap that later fills. * **Last-write-wins on client time.** Two people rarely edit the same expense concurrently; LWW with an audit trail lets them see and fix the rare collision. Clock-skew protection is in §14.4. * **Push carries ids only.** Personal data does not pass through Google; the phone syncs and builds the text locally. * **No Play Store.** Two users; a self-hosted APK with `PackageInstaller` is enough. * **Domain rules twice, one test suite.** Kotlin and Rust implement the same pure functions and must pass the same JSON vectors, so phone and server agree to the öre. --- ## 7. API outline Base URL: `https://` (the value of `KVITT_PUBLIC_URL`, port 443, D-124; `KVITT_PUBLIC_HOST` is the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), D-120). All paths below are relative to it. There is no path prefix and no API version in the path; the protocol version travels in a header (§14.3.1). | # | Method | Path | Auth | Purpose | |---|---|---|---|---| | E-1 | GET | `/health` | none | Liveness and DB check; S-22 shows its `server_version` and `schema_version` (D-110) | | E-2 | GET | `/setup/status` | none | Whether the household has been bootstrapped | | E-3 | POST | `/invites/accept` | none | Pair a device with an invite code (all three invite kinds) | | E-4 | GET | `/i/{code}` | none | Invite landing page (HTML) | | E-5 | GET | `/apk/latest` | none | Latest APK metadata | | E-6 | GET | `/apk/files/{file_name}` | none | APK download | | E-7 | GET | `/me` | device | Current user, device and household users | | E-8 | PATCH | `/me` | device | Change own display name | | E-9 | PUT | `/me/picture` | device | Upload own picture | | E-10 | DELETE | `/me/picture` | device | Remove own picture | | E-11 | GET | `/users/{user_id}/picture` | device | Download a user's picture | | E-12 | POST | `/invites` | admin | Create a `partner` or `device` invite | | E-13 | GET | `/invites` | admin | List pending invites | | E-14 | DELETE | `/invites/{invite_id}` | admin | Cancel an invite | | E-15 | GET | `/devices` | device | List devices (admin: all; member: own) | | E-16 | DELETE | `/devices/{device_id}` | device | Revoke a device | | E-17 | PUT | `/devices/current/push-token` | device | Set or clear this device's FCM token | | E-18 | POST | `/accounts` | device | Create account | | E-19 | PATCH | `/accounts/{account_id}` | device | Rename, change default ratio | | E-20 | POST | `/accounts/{account_id}/archive` | device | Archive | | E-21 | POST | `/accounts/{account_id}/unarchive` | device | Unarchive | | E-22 | DELETE | `/accounts/{account_id}` | device | Soft delete when empty | | E-23 | POST | `/categories` | device | Create category | | E-24 | PATCH | `/categories/{category_id}` | device | Rename, icon, colour, order, reset name | | E-25 | POST | `/categories/{category_id}/archive` | device | Archive | | E-26 | POST | `/categories/{category_id}/unarchive` | device | Unarchive | | E-27 | DELETE | `/categories/{category_id}` | device | Soft delete when unused and not default | | E-28 | POST | `/recurring-templates` | device | Create template | | E-29 | PATCH | `/recurring-templates/{template_id}` | device | Edit, pause, resume | | E-30 | DELETE | `/recurring-templates/{template_id}` | device | Soft delete | | E-31 | GET | `/sync` | device | Pull changes since a cursor | | E-32 | POST | `/sync` | device | Push local expense and settlement changes | | E-33 | POST | `/settlements/proposal` | device | Server-side allocation proposal | | E-34 | GET | `/analytics/trends` | device | Spending per month, averages and trends, household and per category (D-133, D-138) | | E-35 | GET | `/analytics/forecast` | device | Forecast of the next two calendar months per account (D-133, D-138) | | E-36 | GET | `/cards/providers` | device | The card catalogue: supported cards, artwork key, whether this server can connect them for the caller (D-160) | | E-37 | POST | `/cards/auth` | device | Start connecting (or renewing) a card: an authorisation attempt and the bank URL (D-162) | | E-38 | GET | `/cards/auth/{attempt_id}` | device | Status of the caller's authorisation attempt (D-162) | | E-39 | GET | `/cards/callback` | none | The bank's redirect target: finishes the attempt, HTML page (D-162, D-163) | | E-40 | GET | `/cards/sync` | device | The caller's card connections and purchases since a card cursor (D-167) | | E-41 | PATCH | `/cards/connections/{connection_id}` | device | Shorten the history setting (D-164) | | E-42 | POST | `/cards/connections/{connection_id}/refresh` | device | Fetch from the bank now, user present (D-165) | | E-43 | DELETE | `/cards/connections/{connection_id}` | device | Disconnect (D-167) | | E-44 | PUT | `/cards/purchases/{purchase_id}` | device | Link to an expense, unlink, ignore (D-169) | Reads of accounts, categories, templates, expenses, settlements and audit entries happen only through `GET /sync`; E-34 and E-35 are computed read-only views over them (§14.5.7), not a second way to read rows. Card connections and purchases are not household rows: they are read only through E-40, by their holder (D-161). The web client's static files under `/app/` (§15.4, D-135) are not API endpoints: they have no E-number, no protocol check and no entry in `contracts/openapi.yaml`. Every REST write that changes a synced row returns that row in its wire form (§14.3.3), with its new `server_version`, so the phone can store it immediately. Request and response shapes, error codes and status codes: §14.3. The OpenAPI 3.1 file `contracts/openapi.yaml` is written by milestone M1 from §14.3, which is binding. --- ## 8. Auth & security ### 8.1 Threat model Two trusted users. The server is on the public internet on port 443, through NPM (D-120, D-124); outside the home LAN NPM passes only requests that carry the app key (§8.5, D-126). Threats in scope: random internet scanners, a stolen or lost phone, leaked invite links, a compromised backup location, accidental personal data in logs or push payloads. Out of scope: a malicious partner, an attacker with physical access to the Pi. Accepted risk (owner, 2026-10-19, security review M-1): the Drive backups stay unencrypted and the rclone token keeps the full `drive` scope, so a compromised Google account or `rclone.conf` exposes every backup (§8.7, §10.9). Card purchases (D-161, D-163) add: the Enable Banking application's private key (in `secrets/cards/` on the Pi, never in the database or a backup) and the Enable Banking session ids (in the database) are credentials to read the connected cards' transactions; a session id alone is useless without the private key, so a leaked backup or database does not expose the cards, and a leaked key without the database names no session. Kvitt can never pay or move money: the access is PSD2 account information only. In scope also: a forged or replayed bank redirect (single-use `state` bound to user and device, short expiry, CP-2), the authorisation code in a URL reaching a log (`/cards/callback` is never logged, §10.5.2), and one user seeing the other's card purchases (holder-only authorization, §8.4). Card purchases are personal content: everything §8.7 says about descriptions and amounts applies to merchant texts and card amounts. ### 8.2 Device tokens * On a successful invite accept the server generates 32 random bytes from the OS CSPRNG and returns the token `kvt_` + base64url without padding of those bytes (47 characters total). * The server stores only `token_hash` = lowercase hex SHA-256 of the full token string. * Tokens do not expire. They stop working only on revocation. * Every authenticated request sends `Authorization: Bearer `. The server hashes it, looks up a device with that hash and `revoked_at IS NULL`, and loads the user. Missing or unknown token: 401 `unauthorized`. Revoked: 401 `device_revoked`. * The server updates `devices.last_seen_at`, `devices.app_version_code` and `devices.app_version_name` at most once per hour per device. That update sets `app_version_code` only when the request carries `X-Kvitt-App-Version` and its value parses as an integer, and `app_version_name` only when the request carries `X-Kvitt-App-Version-Name` with 1 to 32 characters after trimming (D-090); otherwise the stored value is kept. E-3 stores both from the accepting request. * On the phone the token is encrypted with an Android Keystore AES-256-GCM key (alias `kvitt_token_key`, `setUserAuthenticationRequired(false)`, no export) and the ciphertext and IV are stored in DataStore file `auth.preferences_pb`. The server base URL, user id and device id are stored next to it in plain text, together with the pairing status (D-043). * The token is never logged, never put in a URL, never included in a crash report or screenshot. ### 8.3 Invite codes * 12 characters from the Crockford base32 alphabet `0123456789ABCDEFGHJKMNPQRSTVWXYZ` (60 bits), generated from the CSPRNG. * Displayed as three groups of four separated by `-`, as `7K3M-QX9D-2HPA`. URLs carry the 12 characters without hyphens. * Normalisation before hashing or sending: uppercase; remove `-` and spaces; replace `O` with `0` and `I` and `L` with `1`. A normalised code that is not exactly 12 alphabet characters is `invite_invalid`. * Stored as `code_hash` = lowercase hex SHA-256 of the normalised code. * Single-use, 72-hour expiry, cancellable. Rate limit in AC-2.12. ### 8.4 Authorization rules | Action | admin | member | |---|---|---| | All expense, settlement, account, category, recurring template operations | yes | yes | | Edit own display name and picture | yes | yes | | Edit the other user's display name or picture | no | no | | Create, list, cancel invites | yes | no (`forbidden`) | | List devices | all | own only | | Revoke a device | any except current | own except current (another user's device: `not_found`, D-156) | | Card connections and purchases (E-37 to E-44) | own only; the other user's: `not_found` (D-161) | own only; the other user's: `not_found` (D-161) | ### 8.5 Transport * TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi, with NPM's Let's Encrypt certificate for the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), on the default HTTPS port 443 (D-124); the router forwards external TCP 443 to the Home Assistant Pi's 443 (NPM), already in place for Home Assistant; nothing is forwarded to the Kvitt Pi (§10.1, §10.5, D-120, D-124). TLS 1.2 minimum, NPM defaults otherwise. NPM forwards plain HTTP over the home LAN to the server's port 8080 on the Kvitt Pi (accepted by the owner, D-120). * App gate (D-126, owner decision 2026-10-21): NPM closes the connection (`return 444`, nothing sent) of every request that neither carries the header `X-Kvitt-App: ` with the app key nor comes from the home LAN `192.168.1.0/24`. Every route is covered, `/health`, `/setup/status`, `/invites/accept`, `/i/{code}` and `/apk/*` included; only Let's Encrypt's `/.well-known/acme-challenge/`, which NPM serves from its own folder, passes (§10.5.2). D-130 later lets the web client's requests through (below). The LAN check reads nginx's `$realip_remote_addr`, the address that really connected, because NPM's `real_ip_header X-Real-IP` for private peers lets a client set `$remote_addr`. The release app sends `X-Kvitt-App` on every request when its build carries the key: the Gradle property `kvitt.appKey` (`-Pkvitt.appKey=…`, else the key `kvitt.appKey` in `android/local.properties`, else empty), 16 to 128 printable ASCII characters without white space or the build stops with a message giving the length, never the value; exposed as `BuildConfig.APP_KEY` and to `:data` as `AppBuildInfo.appKey` (whose `toString` hides it); added in `KvittApi`'s common headers (every API call: `/health`, `/apk/latest`, `/apk/files/{file_name}`, `/invites/accept` included) and in `PictureAuth` for profile pictures from the paired server; an empty key sends no header (debug builds against the development stack need none). The key is generated by the owner (`openssl rand -hex 32`) and lives only in `android/local.properties` and NPM's Custom Nginx Configuration: never in git, never in `.env`, never printed in docs or logs. The Rust server neither sees nor checks the header and `contracts/openapi.yaml` is unchanged (§14.3.1). The per-device bearer token stays the authentication (§8.2); the gate is a perimeter that keeps scanners and anyone without the app from the server. On the home LAN the "secret" is the Wi-Fi password, and the owner controls the APK's distribution (he installs it on both phones). Rotation: a second `if` line with the new key next to the old in NPM, the new APK through the update path (FR-16), then the old line removed. Accepted: the invite landing page works only on the LAN; `/health` from outside without the header gets a closed connection, so the outside check is the app's About screen (S-22) or `curl -H "X-Kvitt-App: $KEY"`; a leaked APK leaks the key until rotation; nothing at home protects against a bandwidth flood. **Web client (D-130, extends D-126):** from outside the LAN three more kinds of request pass without the key, because a browser cannot carry it: a path that is `/app` or starts with `/app/` (the web client's files), `POST /invites/accept` (the web login; AC-2.12's lockout bounds guessing), and a request with an `Authorization: Bearer` header whose path does not start with `/health`, `/setup/`, `/i/` or `/apk/` (the server checks the token; an unknown one gets 401 and counts against its address's unauthenticated limit, §8.6, the H-1 fix, D-115). `/health`, `/setup/status`, `/i/{code}` and `/apk/*` stay LAN-or-key only. Consequence, accepted: the server's authenticated routes are reachable from the internet with a wrong token (401, at most 60 per minute per address), which the security review's threat model already assumed before D-126 (§15.2). * Release builds' network security config (`android/app/src/main/res/xml/network_security_config.xml`): cleartext disabled, system trust anchors only, unless the pinned-root switch is on, in which case a pinned root replaces the system anchors for one domain only (available but unprepared since D-120, §10.5.3). The pinned-root config is generated at build time (task `generatePinnedRootNetworkSecurityConfig`) from the template `android/app/src/releasePinnedRoot/network_security_config.xml` with the domain `kvitt.publicHost` (D-121), into the release resources only when the Gradle property `kvitt.tls.pinnedRoot` is `true` (D-082); the build stops with a message while `android/app/src/release/res/raw/kvitt_root_ca.crt` is not committed (`038c603`). * Debug builds override the config in `android/app/src/debug` and allow cleartext to every host, because a network security config cannot express IP ranges; the AC-2.8 limit (`localhost`, `10.0.2.2`, private LAN addresses) is enforced by `SessionRepositoryImpl.normaliseServerUrl`. Lint's `InsecureBaseConfiguration` warning on the debug config is intended (D-082). * The server trusts `X-Forwarded-For` only from NPM: `KVITT_TRUSTED_PROXY` is `HA_HOST`, NPM's LAN IPv4 address (an IP literal in the Pi's `.env`, D-125), and from that peer the server takes the last `X-Forwarded-For` entry as the client IP for rate limiting (D-120; the server is unchanged). A request from any other peer counts against the peer's own address. ### 8.6 Rate limiting and input limits * Unauthenticated endpoints other than E-6, the web client's files (`/app`, `/app/` and `/app/*`, D-136) and unknown paths: at most 60 requests per minute per client IP, then 429 `rate_limited` with a `Retry-After` header in seconds. The `/app` paths count against the client IP even when the request carries a bearer token (D-136): a page load is `index.html` and a few hashed assets that the browser then caches for a year (§15.4), so 60 per minute leaves room for reloads. * APK downloads (E-6, `GET /apk/files/{file_name}`): at most 10 requests per minute per client IP, then 429 `rate_limited` with `Retry-After`. Own fixed window, not counted against the 60; every request counts, a 404 included (D-119, security review L-3). * Authenticated endpoints: at most 600 requests per minute per device. * Keying (D-032, revised by D-115): the per-device limit is keyed by the hash of the bearer token, with no database lookup. A token is accepted only when a response to it is 2xx. A request with a token not yet accepted counts against its client IP's unauthenticated limit before it runs and is refused with `rate_limited` once the IP is over; a 2xx gives that count back (a newly paired device costs its IP nothing); a 401 for an accepted token counts against the IP and withdraws the acceptance; token entries, accepted or not, are pruned once their window expires; every 401 counts against the IP exactly once (security review H-1: before, a random token answered 426 without `X-Kvitt-Protocol` became accepted for good, bypassed the IP limit and was never pruned). All windows are fixed from the first counted request. The client IP is the peer address, or, when the peer is the trusted proxy (`KVITT_TRUSTED_PROXY`, re-resolved at most every 60 seconds), the last entry of `X-Forwarded-For`. Per-IP keying needs the peer address: `serve` runs the router with `into_make_service_with_connect_info::()` (added by M4 in `15056dc`, D-032 fulfilled). * `GET /health` from the container healthcheck counts against the loopback IP's limit; at the compose healthcheck interval this stays far below 60 per minute and is accepted. * Invite accept failures: AC-2.12. * Card purchases (D-162, D-165): the bank's redirect `GET /cards/callback` (E-39) counts as an unauthenticated endpoint (60 per minute per client IP). E-37 keeps at most one pending attempt per user and provider (a new one expires the previous). E-42 contacts the bank at most once per connection per 60 seconds; a request within 60 seconds of the previous user-present fetch's start answers the current connection without contacting the bank (CP-5). Background fetches are capped at 4 per connection per 24 hours (CP-4). * Request bodies: 1 MiB maximum, except `PUT /me/picture` at 5 MiB. Larger: 413 `payload_too_large`. Both limits are layers in `router.rs` (§6.3). * JSON strings are validated for length after trimming leading and trailing whitespace, and are stored trimmed, `notes` included (D-034). Control characters other than newline are refused with `validation_failed` in `notes` only; this is intended. Names and descriptions accept newlines and are not checked for control characters. * The rate limiter is in memory (resets on restart); no table. ### 8.7 Privacy and logging * Server logs contain: timestamp, level, request method, route template (not the raw path with codes), status, latency, device id, error code. They never contain tokens, invite codes, display names, descriptions, notes, amounts or picture bytes. * The phone never logs personal content, debug builds included (`CLAUDE.md`). Log tags use the prefix `Kvitt`. * Nginx Proxy Manager's access log (`proxy-host-_access.log` inside NPM) records the full `$request_uri`, query string included, on every path except `/i/`; the `location ^~ /i/` of the Custom Nginx Configuration has `access_log off` and `error_log /dev/null crit`, so neither the invite code nor the inviter's name (`?from=`) reaches a log, a 502 included. NPM logs no request headers, so the `Authorization` token is never logged. The API's other query parameters (`since`, `limit`, `size`, `v`) hold no personal content (§10.5.2, D-120, revises D-117). * The push sender logs one info line `push sent` per message with aggregates only: `push_type`, `devices_sent`, `devices_targeted` and the last 8 characters of each device id that got it; the per-device line is at debug (D-113). * FCM payloads carry ids and types only (§14.6). * Card purchases (D-161, D-163): server logs carry no merchant text, amount, date of a purchase, Enable Banking session id, account uid, authorisation `code` or `state`, and no Enable Banking response body; the card adapter logs one info line per fetch with aggregates only (`card_fetch`: provider id, kind `background` or `user`, the counts of new, updated and removed purchases, the HTTP status class of the bank call, and the last 8 characters of the connection id; since v0.36 also the number of bank requests, of windows split and of windows truncated (CP-7), and of pending purchases booked under their own reference (`booked_by_ref`) and taken over by CP-8's secondary rule (`booked_by_match`), D-178). `/cards/callback` is logged by the server under its route template only, and NPM does not log it at all (§10.5.2). The phone never logs card content (`CLAUDE.md`). Card data is never in a push, the audit log or `GET /sync`. Backups (§10.9) include the card tables in the SQLite snapshot, like every table; the private key is not in `data/` and is never backed up. * Backups on Google Drive are not encrypted in v1; they go to the owner's own Drive folder, reachable only by his Google account. The owner decided on 2026-10-19 not to encrypt them with an rclone `crypt` remote and to keep the token's `drive` scope (security review M-1 closed as an accepted risk, §8.1, `docs/validation/security-review.md`). ### 8.8 Phone hardening * `android:allowBackup="false"` and `android:dataExtractionRules` excluding everything, so neither Google backup nor device transfer copies the token or database. * Exported components: only `MainActivity` (launcher and deep-link intent filters) and library receivers guarded by a permission (Firebase's, as Firebase requires). The FCM service `KvittMessagingService` is not exported. Checked on the merged release manifest at M8. * On `device_revoked` the app wipes local data as in AC-3.3. ### 8.9 Server hardening * The server container runs as a non-root uid/gid: on the Kvitt Pi the owner's (`KVITT_UID`/`KVITT_GID` in `.env`, 1000:1000, D-125), on the development stack and as the image's default `10001`; with a read-only root filesystem and a 16 MB tmpfs `/tmp`, `/data` as the only writable volume, its secrets read-only (the FCM key file and, since D-175, the folder `secrets/cards/` with the Enable Banking private keys), no Linux capabilities, `no-new-privileges`. * The TLS-terminating process is the owner's Nginx Proxy Manager on the Home Assistant Pi; its hardening is that install's, outside this repository (D-120). * Only the server publishes a port (8080 on the Kvitt Pi's LAN; the router forwards only to NPM). The `backup` sidecar publishes no port and is reachable only on the compose default network. Anyone on the home LAN can reach `http://:8080` directly; such requests count against their own address (§8.6, D-120). * SQLite `PRAGMA foreign_keys = ON`, `journal_mode = WAL`, `synchronous = NORMAL`, `busy_timeout = 5000` on every connection. --- ## 9. Edge cases & business rules ### 9.1 Splits Split modes: | `split_mode` | Meaning | Stored `share_bp` per user | Stored `share_ore` per user | |---|---|---|---| | `default` | The account's default ratio at the time of saving | copied from the account | computed by the ratio rule | | `ratio` | A custom ratio for this expense | as entered | computed by the ratio rule | | `exact` | Exact amounts for this expense | `null` | as entered | | `full` | One user bears the whole amount | bearer 10000, other 0 | bearer = amount, other 0 | **Ratio rule** (modes `default`, `ratio`, `full`; exactly two users P = payer, N = non-payer): ``` share_ore(N) = floor(amount_ore * share_bp(N) / 10000) share_ore(P) = amount_ore - share_ore(N) ``` The non-payer's exact share is rounded down, so the payer absorbs the remainder: the payer's share is 0 or 1 öre more than their exact share. Intermediate products are computed in 64-bit integers (the maximum, 1 000 000 000 × 10000, fits). Examples (also test vectors): | amount_ore | payer | bp payer / non-payer | share payer | share non-payer | |---|---|---|---|---| | 1001 | A | 5000 / 5000 | 501 | 500 | | 1 | A | 5000 / 5000 | 1 | 0 | | 100 | B | 4000 / 6000 | 40 | 60 | | 999 | A | 3333 / 6667 | 333 | 666 | | 999 | B | 6667 / 3333 | 667 | 332 | | 50000 | A | 0 / 10000 | 0 | 50000 | | 50000 | A | 10000 / 0 | 50000 | 0 | **Exact rule:** both shares are given, each 0 or more, and they must sum exactly to the amount, otherwise `exact_sum_mismatch`. **Validity:** exactly two share entries, one for each household user, no other user ids; bp each 0 to 10000 and summing to 10000. **Server check:** for every pushed expense the server recomputes the shares from `split_mode`, the amount, the payer and the given bp (or exact öre) with `kvitt-domain` and rejects the mutation with reason `validation_failed` if the pushed `share_ore` values differ. For mode `default` the server does not compare the bp with the account's current default (the phone may have copied an older default while offline); it only checks they sum to 10000. ### 9.2 Effect line from the viewer's side (lists) For viewer V and an expense with payer P: | Case | Line | |---|---| | V = P and the other's share > 0 | "You lent " | | V ≠ P and V's share > 0 | "You borrowed " | | the non-payer's share = 0 | "Not involved" when V ≠ P; "Paid for yourself" when V = P | ### 9.3 Balances For an account and user u, over non-deleted expenses and non-deleted settlements: ``` net(u) = Σ expenses where payer = u : amount_ore − Σ expenses : share_ore(u) + Σ settlement allocations where from = u : allocation.amount_ore − Σ settlement allocations where to = u : allocation.amount_ore ``` * `net(A) + net(B) = 0` always. * The account balance shown to viewer V is `net(V)`: positive " owes you net(V)", negative "You owe −net(V)", zero "All settled up" (account screens say "Settled up"). * The global balance is the sum over all accounts, archived included, deleted excluded (a deleted account has no non-deleted rows by AC-6.5). * Balances include pending local changes. ### 9.4 Settlement allocation proposal Inputs: payer user id, amount (öre, > 0), and for every non-deleted account: id, `created_at`, archived flag, and the payer's current net in it (computed with §9.3, excluding the settlement being edited if any). Algorithm (normative; §14.5 has the signature): 1. Owing accounts = accounts where payer's net < 0. Sort by `created_at` ascending, then by `id` ascending. 2. `remaining = amount`. For each owing account in order while `remaining > 0`: `a = min(remaining, −net)`, emit allocation `(account, a)`, `remaining −= a`. 3. If `remaining > 0` (the amount exceeds the total debt) and at least one owing account exists, add `remaining` to the **last** emitted allocation and report that account as over-settled. 4. If no owing account exists: the target is the oldest non-archived account (same sort); if every account is archived, the oldest account. Emit one allocation of the whole amount to it and report it as over-settled. 5. If there are no accounts: error `no_accounts`. Output: ordered allocations, `excess_ore` (the amount added in steps 3 or 4, 0 otherwise), and `over_settled_account_ids`. Consequences, stated so nobody "fixes" them: * A global settle-up of exactly the global debt can leave individual accounts non-zero when the payer is owed in some account and owes in another (allocations are never negative). The global balance is then zero. Users settle individual accounts with per-account settle-ups in either direction. * The proposal is only a suggestion; the user may edit any allocation, add accounts or remove rows. **Over-settle warning** for a user-edited allocation list: allocation `(account, a)` is warned when `a > max(0, −net_payer(account))`. **Settlement validity** (phone before saving, server on push): amount within §9.6; `from_user_id ≠ to_user_id`, both household users; 1 to 50 allocations; each `amount_ore ≥ 1`; account ids distinct and referencing existing non-deleted accounts; allocations sum to the amount. Error tokens in §14.5.3. ### 9.5 Recurring generation * The scheduler runs once at server start and then daily at 06:00 Europe/Stockholm (DST-aware: always local 06:00). It reads the injected clock (`AppState.clock`) at least every 30 seconds rather than sleeping until the next run: after a forward clock jump past one or more runs it runs the missed job once; after a backward jump of more than a day it plans the next run again from the new time. The 04:00 pruning loop (§14.1) follows the same rules. * For each template with `deleted_at IS NULL` and `paused = 0`, the candidate periods are every month `p` with `start_period ≤ p`, `p ≤ end_period` (if set), `p > last_generated_period` (if set), and whose occurrence instant (the clamped date at 06:00 Europe/Stockholm) is at or before now. * For each candidate in ascending order the server, in one transaction: creates the expense (server-generated UUIDv7 id; `expense_date` = clamped date; fields copied from the template; `created_by_user_id` and `updated_by_user_id` = the template's `created_by_user_id`; `updated_by_device_id` null; `created_at` = `updated_at` = server now; `recurring_template_id` and `recurring_period` set), writes its splits (mode `default` resolves the account's current default ratio at generation time; other modes use the template's splits, with `ratio`/`full` recomputed by the ratio rule for the template amount), writes an audit entry (`create`, `source` = `recurring`), sets the template's `last_generated_period` and `updated_at` (server now), bumping its `server_version`, then enqueues the push (AC-9.6). * The unique index on `(recurring_template_id, recurring_period)` makes generation idempotent; a conflict means the period was already generated and is skipped silently. * Pausing stops generation. Resuming does not backfill periods whose occurrence passed while paused: on resume the server sets `last_generated_period` to the latest period whose occurrence instant is before now, if that is later than the current value and at or after `start_period`. Resuming a template that has not started yet therefore keeps its `start_period` (D-033). * Changing `day_of_month` or `start_period` never regenerates past periods. * `next_occurrence_date` (in the wire form, computed with `kvitt-domain::next_occurrence_date`, §14.5.2) is the clamped date of the first candidate period after `last_generated_period` that is ≥ `start_period` and ≤ `end_period`; `null` when paused, deleted or past `end_period`. * A template's account may be archived (generation continues). Account deletion is blocked while a non-deleted template references it (AC-6.5). ### 9.6 Limits and validation All string lengths are counted in Unicode code points after trimming. One table, used by phone validation and server validation alike: | Field | Rule | Error (REST) / reason (sync) | |---|---|---| | `amount_ore` (expense, settlement, template) | integer 1 to 1 000 000 000 (10 000 000,00 kr) | `validation_failed` | | allocation `amount_ore` | integer ≥ 1 | `validation_failed` | | allocations per settlement | 1 to 50 | `validation_failed` | | `share_bp` | integer 0 to 10000; the two sum to 10000 | `validation_failed` | | `share_ore` | integer ≥ 0; the two sum to the amount | `validation_failed` | | expense `description`, template `description` | 1 to 100 code points | `validation_failed` | | expense `notes`, template `notes` | 0 to 1000 code points; newlines allowed | `validation_failed` | | settlement `note` | 0 to 200 code points | `validation_failed` | | `expense_date`, `settlement_date` | valid date, from `2000-01-01` to server today (Europe/Stockholm) + 365 days | `validation_failed` | | account `name` | 1 to 60 code points; unique (AC-6.2) | `validation_failed`, `name_taken` | | category `name` | 1 to 40 code points, or `null` for a default category | `validation_failed` | | user `display_name` | 1 to 40 code points | `validation_failed` | | device `name` | 1 to 60 code points | `validation_failed` | | `fcm_token` (E-17) | 1 to 512 characters after trimming, or `null` to clear; a Firebase Installation ID has 22 (D-114) | `invalid_request` with field `fcm_token` | | `day_of_month` | integer 1 to 31 | `validation_failed` | | `start_period` | `YYYY-MM`, not earlier than 12 months before the current month | `validation_failed` | | `end_period` | `null` or `YYYY-MM` ≥ `start_period` | `validation_failed` | | `updated_at` in a push | ≤ server now + 10 minutes | `clock_skew` | | mutations per `POST /sync` | 1 to 100; 0 is also `batch_too_large` (D-036) | `batch_too_large` | | `GET /sync` `limit` | 1 to 1000, default 500 | `invalid_request` | | `GET /sync` `since` | required, integer ≥ 0 | `invalid_request` | | picture upload | ≤ 5 MiB, ≤ 8000 × 8000 px, JPEG/PNG/WebP | `payload_too_large`, `image_invalid`, `unsupported_media_type` | | card `history_days` (E-37, E-41) | one of 7, 30, 90, 180 (CP-3; K0 reached at least 150 days right after BankID, so 180 stays, D-177); E-41 accepts only a value ≤ the current one | `validation_failed` | | card purchase `merchant` (stored) | 0 to 140 code points, truncated by the server (CP-7) | none (never sent by clients) | | E-44 body | `expense_id` a non-deleted expense id or `null`; `ignored` bool; `ignored` true needs `expense_id` null; a `credit` purchase accepts only `expense_id` null and `ignored` false (CP-8) | `validation_failed`; an unknown or deleted `expense_id` is `not_found` with field `expense_id`; `expense_already_linked` | | `GET /cards/sync` `since`, `limit` | as `GET /sync`: `since` required, integer ≥ 0; `limit` 1 to 1000, default 500 | `invalid_request` | Strings are stored as validated, that is trimmed (§8.6, D-034). On the phone, a rule of this table that has no domain error token of §14.5.3 (the length limits, the date limits, `day_of_month`, the periods) is reported as `FieldError.error` = `validation_failed` (§14.2.4), the same token as the REST code; a missing required value is `required`. The phone input field for amounts accepts digits, one decimal separator (`,` or `.`) and at most two decimals, without sign, and converts to öre exactly (string parsing, never floating point). A stored amount opens for editing with both decimals ("1 247,60", "12 400,00"); ratio percent fields keep the trimmed form ("60", "60,5") (D-078; `AmountInput.of` and `AmountInput.percent` in `:core:design`). ### 9.7 Icon and colour tokens Category icon tokens are Material Symbols names. The allowed set (exhaustive, 33 tokens; `key`, `shield` and `account_balance` added by D-054): `shopping_cart`, `restaurant`, `local_cafe`, `home`, `bolt`, `water_drop`, `wifi`, `directions_car`, `train`, `local_gas_station`, `flight`, `hotel`, `luggage`, `movie`, `music_note`, `sports_esports`, `fitness_center`, `medical_services`, `medication`, `shopping_bag`, `checkroom`, `pets`, `child_care`, `school`, `card_giftcard`, `celebration`, `build`, `chair`, `subscriptions`, `category`, `key`, `shield`, `account_balance`. Colour tokens (exhaustive, 14 tokens; `slate` and `amber` added by D-054) with the muted Kalas values of `docs/ux/design-spec.md` §2.3 (D-047, D-051; recorded in v0.9). The table holds the base colour of each token; `design-spec.md` §2.3 also gives each token's container (the colour over `surface` at 18 % in light, 24 % in dark) and the contrast ratios, all at least 3:1 as graphics. Category colours are never used for text. | Token | Light | Dark | |---|---|---| | `green` | `#4E7A55` | `#8FB896` | | `orange` | `#B26A3E` | `#E8AA7E` | | `brown` | `#6B5244` | `#B8A090` | | `yellow` | `#9C7C2A` | `#E6C67A` | | `blue` | `#4A78A8` | `#98C0E6` | | `teal` | `#367F82` | `#8CCAC8` | | `purple` | `#865891` | `#C59CDA` | | `red` | `#A84B45` | `#DC8F86` | | `pink` | `#AE5D7C` | `#EFB0BE` | | `grey` | `#6C7177` | `#B0B2B3` | | `indigo` | `#45508D` | `#969DD8` | | `lime` | `#73822F` | `#B9CC86` | | `slate` | `#5E2D4E` | `#B0719A` | | `amber` | `#937D63` | `#DFCFC0` | The server validates icon and colour against these lists (`validation_failed`). ### 9.8 Formatting * Money, Swedish locale: `1 234,50 kr` (U+00A0 no-break space as the thousands separator and before `kr`; comma decimal; always two decimals). * Money, any other locale: `SEK 1,234.50` (U+00A0 after `SEK`; comma thousands; point decimal; always two decimals). * Money is never shown with a minus sign; direction is in words (§9.2, §9.3). * Dates in lists: Swedish `3 okt.`, English `3 Oct`; with year when not the current year (`3 okt. 2025`, `3 Oct 2025`). Detail views: Swedish `lördag 3 oktober 2026`, English `Saturday, 3 October 2026`. Timeline times: 24-hour `14:02` in both languages. * Relative "last synced": "just now" (< 1 minute), "N min ago" (< 60 minutes), "N h ago" (< 24 hours), otherwise the list date and time. Swedish: "nyss", "för N min sedan", "för N h sedan". * Chart axis labels (months): Swedish three-letter forms without a dot (`maj`, `jun`, … `okt`), English `May`, `Jun`, … `Oct`; list dates keep the Swedish abbreviation dot (`3 okt.`, `25 sep.`; `mars`, `maj`, `juni`, `juli` are written out) (D-065). * Day ranges in captions and summaries (S-11 total caption, S-12 summary) are written out with the year: Swedish `24 augusti–17 oktober 2026`, `1–30 november 2026`, `28 december 2025–3 januari 2026` (the year once when both ends share it, the month once when both ends share it); the period chip and the S-12 title keep the short form `24 aug.–17 okt.` (D-077, extends D-065; `DateFormatter.dayRangeLong`). * File sizes (the update banner, S-17): SI megabytes (10^6 bytes) with one decimal rounded half up and U+00A0 before `MB`: Swedish `8,4 MB`, English `8.4 MB` (D-065). * Month and weekday names in Swedish and English are tables inside `DateFormatter`, not platform locale data, so JVM tests and the phone give identical output (D-065). * Formatting lives in `:core:design` (`MoneyFormatter`, `DateFormatter`), never in `:domain`. ### 9.9 Online-only actions When the phone has no validated network (Android `NET_CAPABILITY_VALIDATED`) or the last request failed with a network error less than 30 seconds ago, these actions are disabled with "Needs connection" ("Kräver anslutning", `common_needs_connection`): create, edit, archive, unarchive, delete of accounts, categories and recurring templates; profile name and picture; invites; device revoke; in-app update; "Sync now" ("Synka nu") on S-8 and S-10 (their missing states) and on S-16 (D-080); and for card purchases (D-162, D-165): connecting and renewing (S-25, "Förnya med BankID"), "Uppdatera" on S-23, the history setting and "Koppla från" on S-26. Linking, unlinking, ignoring and "Skapa utgift" are not online-only (CP-9); pull to refresh on S-23 ends at once offline, as on S-4. A REST call that fails with a network error shows "Could not reach the server" and changes nothing locally. Pull-to-refresh on S-4 and S-5 is not disabled offline; it ends at once without a sync (D-127). ### 9.10 Other edge cases | Situation | Rule | |---|---| | Both users edit the same expense offline | Last `updated_at` wins; the loser's phone receives `stale` with the winner's row, overwrites locally and lists it under "Changes not synced" (§14.4.6). Both edits are in the audit log only if both were accepted, so only the winning edit is logged. | | One user deletes, the other edits the same expense offline | Delete wins regardless of arrival order and timestamps: a pushed delete is always applied, and any non-delete write to a tombstoned row is rejected `deleted` (§14.4.4). | | An expense is created offline in an account that was deleted meanwhile | Rejected `reference_not_found`; the row is removed locally and listed under "Changes not synced". Cannot happen for archived accounts (writes to archived accounts are accepted). | | An expense uses a category that was archived meanwhile | Accepted. | | Phone clock far in the future | Pushes rejected `clock_skew`; the app shows "Your phone's clock is wrong" on the sync status screen and keeps the changes pending. | | Phone clock behind | Its edits may lose LWW against older real edits. Accepted risk. | | Server restored from an older backup | The server's `sync_epoch` changes (§10.9); phones detect it and re-push everything they hold (§14.4.8), so changes made after the backup are recovered from the phones. | | Partner never joins | Admin stays in the waiting state (AC-5.1). | | Admin loses phone | `kvitt-server pair-device --user ` on the Pi (AC-1.6); then revoke the lost device from the new phone. | | Both users' devices revoked | Same recovery as above for each user. | | An account is renamed to a name used by a deleted account | Allowed (uniqueness ignores deleted accounts). | | Recurring template's category deleted | Cannot happen: category deletion is blocked while a template uses it. | | Expense amount edited in mode `exact` | The editor keeps the typed user's share and recomputes the other; if the typed share now exceeds the amount, saving is blocked (AC-8.7). | | Settlement edited so that an allocation's account was deleted meanwhile | Cannot happen: an account with allocations cannot be deleted. | | Same user on two devices edits offline | Same as two users: LWW per row. | | Time zones | Dates (`expense_date`, `settlement_date`) are calendar dates chosen by the user; analytics never convert them. Only "today" defaults use the phone's zone; server validation and scheduling use Europe/Stockholm. | | A day with a DST change | Scheduling uses local 06:00, which exists on every day in Europe/Stockholm. | ### 9.11 Category suggestion The expense editor (S-7) selects the category from the description while the user types (D-049, owner 2026-10-18). The rule is a pure function in `:domain` (§14.5.6, with platform Unicode services through `UnicodeText`), fed by `ExpenseRepository.suggestCategory` in `:data` (§14.2.4) from Room and the keyword table `contracts/category-keywords.csv` (§14.8). **CS-1 Scope.** The suggestion runs only in the editor for a **new** expense. It never runs when an existing expense is edited and never changes the category of a saved expense. The recurring template editor (S-19) does not use it in v1. **CS-2 Manual choice wins.** The editor keeps a flag "category picked by hand", false when the editor opens. Picking any category in the category grid sets it to true, also when the user picks the category already shown. While the flag is true the suggestion never changes the category, for the rest of this editor session (until the editor closes). Nothing resets the flag. **CS-3 When it runs.** While the flag is false, the category is recomputed on every change of the description and on every change of the account. It is a local computation over Room data; no network. **CS-4 Normalisation.** Descriptions (typed and stored) and keywords are compared after this normalisation: 1. Unicode NFC, then lowercase without locale rules (Kotlin `lowercase()`, which uses `Locale.ROOT`). 2. Delete every `'` (U+0027), `’` (U+2019) and `:` (so "Willy:s" becomes "willys" and "McDonald's" becomes "mcdonalds"). 3. Replace every remaining code point that is not a Unicode letter (general category L), a decimal digit (general category Nd) or `&` with a space. Whitespace is therefore replaced too. 4. Collapse runs of spaces to one space and trim. Diacritics are kept (å, ä, ö and é stay distinct; the keyword table lists both spellings where it matters). Digits are kept. The **tokens** are the normalised description split on spaces; the **first token** is the first of them. An empty normalised description matches no history and no keyword. **CS-5 Order.** The suggested category is the first that applies of: 1. **History, exact:** among the non-deleted expenses on the phone (every account, either user, pending, imported and recurring ones included) whose normalised description equals the normalised typed description, the category used most often; on a tie, the one used most recently. 2. **History, first token:** among the non-deleted expenses whose first token equals the typed description's first token, the category used most often; on a tie, the one used most recently. 3. **Keywords:** the category of the winning entry of the keyword table (CS-6). 4. **Default:** the AC-8.6 fallback: the category last used in the selected account on this phone if still active, else `other`; when `other` is archived too, no category (AC-8.6). "Used most often" counts the matching expenses per category. "Most recently" compares the latest matching expense of each tied category by (`expense_date`, then `created_at`), the expense-list order of AC-8.10; if that still ties, the lower category id (string order) wins. Archived and deleted categories are left out of the count; when no category is left, the next step applies (owner, 2026-10-18: most often, not most recent). **CS-6 Keyword table.** Each entry has a `keyword` (already normalised per CS-4), a `match` and a `default_key`. An entry matches when: * `token`: some token equals the keyword; * `prefix`: some token starts with the keyword; * `phrase`: the keyword's tokens occur consecutively in the tokens. When several entries match, the longest keyword (in code points, spaces included, a surrogate pair counting as one) wins; on equal length, the entry earlier in the table wins (the table is sorted by `default_key`, then `keyword`, both in code-point order). The winning entry gives the active category with that `default_key` (a renamed default included, since the key stays). If that category is archived, the next matching entry in the same order is tried; when none is left, step 4 applies. The table is fixed data shipped with the app; users do not edit it. No entry maps to `other`. **CS-7 What the user sees.** The suggestion simply replaces the category shown in the Kategori field: no caption, no marker, no confirmation (owner, 2026-10-18: a wrong guess is corrected with one tap). Clearing the description returns the field to the step-4 default. Evidence (D-049): a chronological replay of the household's 1 775 Splitwise expenses with the fourteen categories of AC-7.2 and the §10.8 mapping put 79.7 % of rows in the right category, against 30.5 % for the step-4 default alone; with no history, the keywords cover 77 % of rows at 96 % accuracy. The replay is in `docs/ux/category-suggestion.md`. ### 9.12 Card purchases (D-158 to D-176) The rules of FR-22. The card holder is the user who connected the card; "purchase" is one card transaction as the bank reports it. Facts about the provider (established 2026-10-24, D-159): the SJ Prio Mastercard is issued by SEB Kort Bank AB and, as a payment account, is reachable through PSD2 account information (AIS): read only, no payments. Kvitt reaches it through Enable Banking (enablebanking.com), whose changelog (August 2024) lists an "SJ Prio Mastercard (SE)" integration, in its restricted production mode: free, for evaluation and individual non-commercial use, fetching data only from accounts linked to the application in Enable Banking's control panel ("Activate by linking accounts", done with the bank's own authorisation, BankID). PSD2 RTS Art. 36(5)(b): without the user actively requesting it, a provider may access an account at most 4 times per 24 hours. Since the 2023 RTS amendment, access through a provider needs a renewed strong customer authentication (BankID) at most every 180 days; the bank's `maximum_consent_validity` may be lower. History older than about 90 days is usually available only right after authentication. The spike K0 measured the owner's card on 2026-10-24 (PLAN-K0, report `docs/spikes/K0-enable-banking.md`, D-177); its results are stated in the rules below as measured ("K0: …"). Three points were not measured and rest on the owner's decision of 2026-10-24 ("let's just assume it all works now given your test", D-177): the partner's card links to the owner's application (CP-13), a pending transaction keeps its `entry_reference` when it is booked (CP-8), and a session is reused on later days without BankID (CP-6). K1's owner steps verify all three on the real server (PLAN-K1 §8). **CP-1 Catalogue (D-160).** Supported cards are entries of a provider registry in `kvitt-server` (`src/cards/providers.rs`), served by E-36. Each entry has an open-string id, a display name, the issuer, the Enable Banking ASPSP name and country, an image key and its source adapter (`enable_banking`); nothing outside the adapter names the vendor, and ids are never enums (`CLAUDE.md`). The registry has one entry: id `sj_prio_mastercard`, name "SJ Prio Mastercard", issuer "SEB Kort", ASPSP name `SJ Prio Mastercard`, country `SE`, image key `sj_prio_mastercard` (K0: listed exactly so in `GET /aspsps?country=SE`, one of 126 Swedish ASPSPs, `beta` false, `psu_types` [`personal`], auth method `REDIRECT`). The app and the web map the image key to their bundled artwork, committed in both (D-180); an unknown key draws the fallback card. A user has at most one connection per provider (D-167). **CP-2 Connecting (D-162).** E-37 creates an authorisation attempt for the caller (user and device), the provider, the chosen history and the client (`android` or `web`): a random 32-byte `state` (base64url, stored only as its SHA-256 hash, like invite codes, §8.3), status `pending`, expiry 15 minutes after creation. It calls Enable Banking `POST /auth` with `access.valid_until` (CP-6), the ASPSP, the `state`, `redirect_url` = `/cards/callback` and `psu_type` `personal`, and returns the attempt id, the bank URL and the expiry. A new attempt for the same user and provider expires a pending older one. The client opens the URL (Android: a Custom Tab, D-173; web: the same tab, D-174); the user signs with BankID (app-to-app on the phone, QR in a desktop browser). The bank redirects the browser to E-39 with `code` and `state` (or `error`). E-39 looks the attempt up by the hash of `state` (D-184): an unknown or already used `state` changes nothing and shows the failure page; an expired one stores the attempt as `expired` and shows it too; otherwise the attempt is claimed atomically (only one callback gets past this point) before Enable Banking is asked for anything; then an `error` marks it `failed` with `cancelled`, a callback with neither `code` nor `error` `failed` with `bank_error`, and with a code it calls Enable Banking `POST /sessions` with the code, picks the card account (exactly one account in the session: that one; several: the first whose `cash_account_type` is `CARD`, else the first; none: `failed` with `no_account`, and that session is revoked at Enable Banking, best effort, D-184; K0: the SJ Prio session holds exactly one account, currency SEK, `cash_account_type` `CARD`), creates or updates the user's connection for that provider (status `active`, the new session id, account uid, `valid_until`, history), marks the attempt `connected`, starts the first fetch in the background (CP-4, user present with the callback request's address and user agent) and shows the success page. The attempt is single use: a second callback with the same `state` shows the failure page and changes nothing. The pages (bilingual by `Accept-Language`, copy in §13.3 "Card callback page") link back to the client: `kvitt://cards/connected?attempt=` for `android`, `/app/cards?attempt=` for `web`. The page never redirects by itself (a browser may block a scripted jump to `kvitt://`); the client learns the result by polling E-38 every 2 seconds while its connect screen is open (S-25, W-22) and when the return link opens it. Renewing an existing connection (CP-6) is the same flow; on success the connection keeps its id and every purchase, and the previous session is revoked at Enable Banking (best effort). **CP-3 History (D-164).** The connect screen offers "Hämta köp från de senaste 7 / 30 / 90 / 180 dagar", default 30 days. The first fetch after an authorisation asks for transactions of that many days up to today (Europe/Stockholm), today counted as one of them (`date_from` = today − (`history_days` − 1), D-181), in windows (CP-7). K0: right after BankID the card gave at least 150 days (five consecutive 30-day windows back to 150 days, all answered with rows; the last 30 days to 180 were not asked by window), so 180 stays offered (D-177). The setting can be shortened later (E-41; it only affects later renewals, stored purchases stay); a longer value needs a fresh BankID, so S-26 offers it only through "Förnya med BankID" (E-37 with the new value), and says so ("En längre period kräver BankID igen"). **CP-4 Background fetch (D-165).** Enable Banking's FAQ: "many ASPSPs have the limit of 4 times a day for data fetches when PSU is not online". The server fetches every `active` connection in the background at 06:45, 11:45, 16:45 and 21:45 Europe/Stockholm (four slots spread over the day, away from the 04:00 pruning and the 06:00 recurring run), without the user's headers (not user present). A slot is skipped when the connection started 4 background fetches in the preceding 24 hours (`background_fetches`, the start times of the last four), when any fetch for it succeeded less than 2 hours before, or when one is running. The scheduler reads the clock as §9.5 does; after downtime it runs at most the slot that is due, never a backlog: at start-up the latest slot already passed runs once, subject to the 4-per-24-hours quota and the 2-hour rule (D-185). Every background start counts towards the quota, whatever its outcome (D-185). K0: a fetch without the user's headers was accepted for the SJ Prio card (HTTP 200); how many the bank accepts per 24 hours was not measured, so the 4-per-24-hours rule stands. A background fetch normally makes one bank request (its range starts 10 days before the latest stored booking date, one window of CP-7); the `card_fetch` line counts the requests (§8.7). **CP-5 User-present fetch (D-165).** "Uppdatera" and pull to refresh on S-23 (and "Uppdatera" on W-21) call E-42, which fetches with the user present, so it does not count as background access. Enable Banking's PSU headers are `Psu-Ip-Address`, `Psu-User-Agent`, `Psu-Referer`, `Psu-Accept`, `Psu-Accept-Charset`, `Psu-Accept-Encoding`, `Psu-Accept-language` and `Psu-Geo-Location`, and a fetch sends "either all required PSU headers (in case of online data fetching) or none", the required set being the ASPSP's `required_psu_headers` in `GET /aspsps`. A user-present fetch therefore sends `Psu-Ip-Address` (the client IP of §8.6, which the rate limiter puts in a request extension for this, D-188) and `Psu-User-Agent` always, plus every other header the ASPSP requires, each taken from the app's own request (`User-Agent`, `Referer`, `Accept`, `Accept-Charset`, `Accept-Encoding`, `Accept-Language`); `Psu-Geo-Location` is never sent (Kvitt has no location). Each header is sent only when the app's request carries a value for it (D-186). The ASPSP's details from `GET /aspsps` are cached for 24 hours; when `/aspsps` fails, only `Psu-Ip-Address` and `Psu-User-Agent` are sent (D-186). K0: the SJ Prio ASPSP's `required_psu_headers` is exactly [`psu-ip-address`]; Enable Banking lists the names in lower case, so they are compared case-insensitively; a fetch with `Psu-Ip-Address` and `Psu-User-Agent` was accepted (HTTP 200) from the MacBook, and from the Pi it is checked by K1's owner steps. A background fetch sends none. E-42 contacts the bank at most once per connection per 60 seconds (§8.6) and waits up to 25 seconds for the fetch; if it takes longer it answers with `fetching` true and the fetch completes in the background. The app then pulls E-40. On the phone the regular sync triggers (AC-15.3) also pull E-40, so purchases fetched in the background reach the phone without a tap. **CP-6 Consent (D-166).** `access.valid_until` asks for now plus the smaller of 180 days and the ASPSP's `maximum_consent_validity` (given in seconds in `GET /aspsps`), less 5 minutes (K0: the SJ Prio value is 15552000 seconds, 180 days, so the request is now plus 180 days less 5 minutes; the session made on 2026-10-24 returned `valid_until` 2027-04-22); the stored `valid_until` is the one Enable Banking's session returns. The session serves every fetch, user-present and background, until then without a new BankID (owner's assumption, D-177; K0 did not repeat a fetch on a later day, K1's owner steps check it). From 14 days before it, S-23 and S-26 show the expiry notice with "Förnya med BankID"; once it has passed, or Enable Banking reports the session expired or revoked, the connection's status becomes `expired`: no fetch runs, the stored purchases stay and keep working (link, ignore), and "Förnya med BankID" renews (CP-2). **CP-7 Incremental fetch and mapping (D-168, D-178, D-179).** Each fetch covers the range from `date_from` = the later of (today − (`history_days` − 1), today being one of the `history_days` days, D-181, revising the earlier "today minus `history_days`", which made 181 days and 7 windows for 180) and (the latest stored booking date of the connection minus 10 days) up to today, so a purchase booked late and a pending purchase turning booked are seen again; the first fetch after an authorisation starts at today − (`history_days` − 1). K0: the SJ Prio card answers every transactions request with at most 100 transactions and never a `continuation_key`, so a longer range loses rows without an error (90 days gave 100 rows covering only the newest 60 days; 180 days with `strategy=longest` gave 100 rows that were neither the newest nor a contiguous range). The range is therefore fetched in windows (D-178): * Windows: consecutive, non-overlapping, at most 30 days each, newest first, each asked with `date_from` and `date_to` (both inclusive); the oldest window takes what is left and may be shorter. A first fetch of 180 days makes 6 requests; an incremental fetch (10 to about 20 days) makes 1. A `continuation_key`, if one is ever returned, is followed within its window. * Split: a window whose response holds 100 or more transactions (the cap, so the response may be cut) is split into two halves, the newer half taking the extra day of an odd length, and each half is asked again, newest first, down to windows of one day; the rows of the cut response are discarded and only the halves' rows are kept (D-185). A one-day window that still holds 100 is kept as returned and counts as truncated. * `strategy` is never sent (Enable Banking's default strategy): `strategy=longest` neither lifts the cap nor returns the newest rows. * `WRONG_TRANSACTIONS_PERIOD` for a window (not seen in K0) means the bank has no older history: that window and every older one are skipped and the fetch succeeds with the newer windows; the deletion of vanished pending purchases (CP-8) then covers only the windows that answered (D-185). * A window may return transactions whose booking date lies one or two days outside it (K0: the bank filters on another date), so every transaction is kept as returned and the transactions of all windows are de-duplicated by `provider_ref` (below) before CP-8; the first occurrence, from the newer window, wins. * Any other error ends the fetch with CP-14's error; the transactions of the windows already fetched are applied (CP-8). * A fetch is complete when every window of its range answered and none was truncated; only a complete fetch deletes vanished pending purchases (CP-8). Each transaction maps to a purchase: * `provider_ref`: the transaction's `entry_reference`, which Enable Banking documents as "unique and immutable" but "in most cases … provided only for booked transactions" (`transaction_id` "may change" and is not used). K0: on the SJ Prio card it is on every transaction, the pending ones included, unique, and unchanged between two fetches. When absent, `h:` + lowercase hex SHA-256 of `||||`, `` the 0-based index of an identical earlier transaction in the same window's response. * `direction`: `debit` for `DBIT`, `credit` for `CRDT`. `status`: `pending` for `PDNG`, `booked` for `BOOK`; other statuses are skipped. `bank_transaction_code` is not used: the direction alone decides (D-179). K0 saw `PURCHASE` 92, `PAYMENT` 5, `UNKNOWN` 2 and `FEE` 1 in 100 transactions; a debit `FEE` (a card fee) or `PAYMENT` is a debit like any purchase, so it is "Att hantera" and the holder turns it into an expense or ignores it. * `purchase_date`: `booking_date`, else `value_date` (a pending transaction has no booking date), else `transaction_date`; a transaction with none of the three is skipped. `booking_date` is stored when the transaction is booked. K0: `value_date` on every transaction, `booking_date` on every booked one and on no pending one, `transaction_date` on none (D-179). * Amount: `transaction_amount.amount` is a decimal string parsed to öre exactly (string parsing, never floating point, as §9.6's amount input); its sign is ignored, the direction carries it. K0: positive decimal strings with a point, and every transaction in `SEK`, purchases at German, Irish and Estonian merchants included (`exchange_rate` empty): the card account reports foreign purchases converted to SEK. A transaction whose currency is not `SEK` (not seen in K0; kept as a safeguard) is stored with `amount_ore` null, its amount as the decimal string `original_amount` and `original_currency` (ISO 4217); such a purchase shows " " with "Utländsk valuta", gets no matches (CP-10) and "Skapa utgift" opens S-7 with the amount empty. A transaction of 0,00 is skipped. * `merchant`: the `remittance_information` lines joined with a space, else `creditor.name`, else "Kortköp" ("Card purchase"); whitespace collapsed, trimmed, at most 140 code points (truncated). K0: `remittance_information` holds exactly one line on every transaction and it is the merchant text; `creditor.name` is never present (D-179). * Nothing else of a transaction is stored, logged or sent to a client (D-179): in particular not `debtor.name` (K0: the card holder's name), `debtor_account` (K0: a masked card number, scheme `CPAN`), `creditor.postal_address` (K0: the merchant's country and town), `transaction_id` or `bank_transaction_code`. **CP-8 Matching a fetch to stored purchases (D-168, D-177).** Within one connection: a transaction whose `provider_ref` is stored updates that purchase's status, dates, amount and merchant (link and ignore state kept). On the SJ Prio card a pending transaction carries an `entry_reference` (K0), and a pending transaction is assumed to keep it when it is booked (owner's assumption, D-177; K1's owner steps check it through `booked_by_ref` and `booked_by_match`, §8.7), so the booked transaction updates the pending purchase by its reference. The secondary rule, for a booked transaction with no stored `provider_ref` (a bank that gives the booked transaction a new reference, or a pending one stored under the fallback hash), takes over a stored pending purchase of the connection (its id, link and ignore state kept; its `provider_ref` replaced) when the amounts and directions are equal, the dates are at most 3 days apart and the CS-4 first tokens (§9.11) of the merchant texts are equal (the server takes the first token with Rust's standard Unicode character classes and without NFC normalisation: it only compares two texts from the same bank, D-182); among several, the oldest pending one. Otherwise it is a new purchase. After a complete fetch (CP-7), a stored pending purchase dated inside the fetched range that the bank no longer reports, neither linked nor ignored, is deleted (a tombstone with its content erased, D-167); a linked or ignored one stays as it is. Credit purchases (`credit`: refunds, deposits, the card bill's payment; K0: 2 of 100 transactions, both payments of the card bill, no refund) are stored but never "Att hantera": they show only under "Alla", marked "Insättning", with no actions, because §9.6 has no negative expense; the sheet says to change the original expense by hand. **CP-9 Turning a purchase into an expense (D-169).** "Skapa utgift" opens S-7 (W-5 on the web) as a new expense prefilled with: amount = `amount_ore`; description = the merchant text, trimmed to 100 code points (§9.6), case unchanged; date = `purchase_date`; payer = the card holder (the viewer); account = from S-27 the account it was opened from, otherwise the AC-8.6 default (the account last used on this phone, or browser, if still active, else the oldest active account); category = the §9.11 suggestion for that description and account (it is a new expense, CS-1; the user's own pick wins, CS-2); split mode `default`; notes empty. Saving creates an ordinary household expense (sync push, audited, the partner is notified as for any expense) and links the purchase to its id. A purchase links to at most one expense, and an expense is linked from at most one purchase of each user (`expense_already_linked`). "Ta bort koppling" (S-23 sheet, S-8) clears the link; the expense stays. When a linked expense is deleted, by either user, the server clears the link in the same transaction (§14.4.4 step 7), so the purchase is "Att hantera" again; the phone clears it locally at its own delete. On the phone, link, unlink and ignore are written to the local copy at once (offline too) and sent with E-44 after the household push of the next sync cycle, so the expense exists on the server first (§14.4.5, D-173); the web sends E-44 at once. The link is not audited and not pushed; editing the expense later leaves the link. **CP-10 Suggested matches (D-170).** For a debit purchase in SEK that is neither linked nor ignored, the matches are the household's non-deleted expenses (either user's, imported and recurring included) with `amount_ore` equal to the purchase's, `expense_date` within 3 days before or after `purchase_date`, and not linked from another purchase of the viewer; ordered by the absolute day difference, then `created_at` descending, then id; at most 3 shown. Computed on the phone from Room and in the browser from the snapshot by the pure function `matchCardPurchase` (§14.5.8, vectors `match_card_purchase.json`). A row with exactly one match shows the chip "Koppla till "; with two or three, " möjliga utgifter", which opens the sheet. "Välj en annan utgift" in the sheet lists the non-deleted expenses not linked from another purchase of the viewer, dated within 14 days of the purchase, any amount, newest first. **CP-11 Privacy (D-161).** Owner, 2026-10-24: only the card holder sees their card purchases. Card connections and purchases are read and written only by their holder's devices; every other caller, the other user's devices included, gets `not_found` for their ids, the same as an unknown id (cf. D-156). They are not in `GET /sync`, not audited, not pushed, not in S-11, S-12, E-34 or E-35, and the partner's phone stores none. An expense created from a purchase is an ordinary household expense; only the holder sees that it came from a card (S-8, AC-22.10). **CP-12 Disconnecting (D-167).** E-43 revokes the session at Enable Banking (`DELETE /sessions/{id}`, best effort: a failure is logged without the id and the disconnect goes on), sets the connection's status to `disconnected` and erases its session id and account uid, and deletes the connection's purchases that are neither linked nor ignored (tombstones with their content erased). Linked and ignored purchases stay (the user's decisions, which keep a later reconnect of the same card from offering them again) unless `erase_handled` is true, when they are deleted too. Expenses created from purchases stay. Connecting the same card again reuses the connection (one per user and provider). **CP-13 Server configuration (D-175).** Enable Banking's production control panel accepts only `https` redirect URLs (an `http://localhost` URL "uses unsupported scheme"); the application's registered redirect URL is `https:///cards/callback`, the path of E-39, and no other is needed. The application id is not a secret but is kept out of the repository's text (it is the configuration's value, §10.4, and the key file's name in K0's git-ignored folder). Card features need an Enable Banking application: an application id and its private key file (§10.4). One application may serve both users (the owner links both cards to it in Enable Banking's control panel) or each user has their own. The owner's application exists (K0: restricted production, activated by linking the owner's card, its key file in K0's git-ignored folder until K1's owner steps copy it to the Pi). The partner's card is assumed to link to the owner's application (owner's assumption, D-177; K0 did not try it, K1's owner steps do); the fallback is the partner's own application, which `KVITT_EB_MEMBER_APP_ID` and its key file configure without code change. A user without a configured application sees the provider as not available (E-36 `available` false, AC-22.1); E-37 answers `cards_unavailable`. The owner accepts Enable Banking's Terms of Service before an application exists (the licence step of `CLAUDE.md`, PLAN-K0). **CP-14 Errors (D-165).** A fetch that fails records `last_fetch_error` on the connection: `bank_unavailable` (Enable Banking or the bank did not answer, or answered 5xx), `rate_limited` (the bank refused for too many requests), `consent_expired` (CP-6) or `failed` (anything else); a later successful fetch clears it. Enable Banking's answers map as follows (D-183): `EXPIRED_SESSION`, `CLOSED_SESSION`, `REVOKED_SESSION` and `SESSION_DOES_NOT_EXIST` → `consent_expired`, and the connection's status becomes `expired` (CP-6); `ASPSP_RATE_LIMIT_EXCEEDED` or HTTP 429 → `rate_limited`; `ASPSP_ERROR`, `ASPSP_TIMEOUT`, HTTP 408, any 5xx or no answer → `bank_unavailable`; `WRONG_TRANSACTIONS_PERIOD` is no error but ends the walk back (CP-7); anything else → `failed`. S-23 shows "Kunde inte hämta köp. Senast uppdaterad