K3 (card purchases on the web, landed faeb752; web gate 70 files, 684 tests, build ok; contracts-check 157): - SPEC §15.6 W-21 to W-23 as built, with W-5's ?cardPurchase= prefill and link, W-6's card line, W-13's "Anslut kort" row; §15.5 the card snapshot; §15.3 the shared card pieces (api/cards.ts, fixtures, dev proxy, three icons) and matchCardPurchase ported; §11.1, §11.2 K3 row, §11.3, §14.7, §15.9; v0.40 row. - decisions D-201 to D-207 (refresh while fetching, query names, https-only auth_url, attempt outcome, W-5 purchase errors, date forms, S-27 on the web); D-208 to D-210 unused. - HANDOFF: K0 to K3 landed, v1.0.4 deployed, v1.0.5 being built, kvitt-164 on the Pi; pending owner steps PLAN-K2 §8, PLAN-K3 §8, PLAN-K1 §8 steps 5 to 7; K3's open questions; next free D-212, v0.41. - web/README.md: card routes, the card snapshot, query names, the new icons; prompts/README.md: K2 and K3 done. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
607 KiB
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, literalZ). This form sorts correctly as a string. - Dates without time are ISO 8601
YYYY-MM-DD. Months ("periods") areYYYY-MM. ISO weeks areYYYY-Wwwwith 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 lowercasesnake_casestrings. - "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://<KVITT_PUBLIC_HOST>(the default HTTPS port 443, D-124), whereKVITT_PUBLIC_HOSTis 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:
- G-1 Correct money. Balances are exact to the öre and computed identically on phone and server, guaranteed by shared test vectors.
- 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.
- 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.
- 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.
- 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).
- G-6 Trust. Every change to an expense or settlement is visible as a timeline of who did what.
- 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).
- 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.
- 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 bootstrapon a database with no users creates an invite of kindadmin_setup, prints its code (format §8.3), its landing linkhttps://<KVITT_PUBLIC_HOST>/i/<CODE>(the base URL fromKVITT_PUBLIC_URL), its expiry, and exits 0. - AC-1.2
kvitt-server bootstrapon a database that has users printsalready initializedto stderr and exits 2. It creates nothing. - AC-1.3 Running
kvitt-server bootstrapagain before the first code is used cancels the previousadmin_setupinvite and prints a new one. - AC-1.4 Accepting the
admin_setupinvite (FR-2) creates theadminuser with the given display name and the first device, and returns a device token. - AC-1.5
GET /setup/statusreturns{"initialized": false}before AC-1.4 and{"initialized": true}after. - AC-1.6
kvitt-server pair-device --user <user_id>creates adeviceinvite 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
partnerinvite from Settings. It is refused withhousehold_fullwhen two users exist. Creating a newpartnerinvite cancels any pending one. - AC-2.2 The admin creates a
deviceinvite 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_setupandpartner) 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 apartnerinvite it creates thememberuser. 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/<CODE>serves an HTML landing page in Swedish if the request'sAccept-Languagepreferssv, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking tokvitt://invite?server=<urlencoded KVITT_PUBLIC_URL>&code=<CODE>(followed by&from=<urlencoded name>when the request carries a validfromquery 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: thefromname is only passed on into thekvitt://invitelink and never shown on the page; an invalidfrom(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 answernot_found) the page omits the download button. The page is sent withReferrer-Policy: no-referrerandCache-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 fromhttps://<kvitt.publicHost>/i/<CODE>[?from=…](the manifest placeholderpublicHost, 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:8443link still opens S-1 with the server it names; invite-link parsing drops an explicit default port,:443forhttpsand:80forhttp, 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. Thehttpslink is declared withandroid: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://<kvitt.publicHost>, 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_URLin 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 accepthttp://forlocalhost,10.0.2.2and private LAN addresses (10.*,172.16.*-172.31.*,192.168.*). The limit is enforced in code bySessionRepositoryImpl.normaliseServerUrl, not by the network security config, which cannot express IP ranges (§8.5, D-082). A release build therefore pairs with anyhttps://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
partnerpairing (E-3invite_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 adeviceinvite. - 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_limitedfor 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 withrate_limiteduntil 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 getsnot_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 in07c36d2). - 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_atand clears the device'sfcm_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'spicture_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 withimage_invalid,unsupported_media_typeorpayload_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-1andavatar-2(values in U2'sdocs/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.startandrecomputereturn null andsavereturnsSaveResult.Invalidwith apayervalidation_failederror; 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_incompletewhile 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_atascending, 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_order25, 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 maximumsort_orderover 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_useotherwise). Default categories (with adefault_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); whenotheris 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 modedefault. 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
defaultre-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
fullon 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, thencreated_atdescending. - 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_periodand an optionalend_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=recurringand sendsrecurring_expense_createdto 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_periodis the current month ifday_of_monthis today or later in the phone's time zone, else the next month. A new template'sday_of_monthdefaults to today's day of the month, so the defaultstart_periodis the current month until the user picks an earlier day (D-072). The user may choose astart_periodup 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/proposalreturns exactly what the phone'spropose_allocationreturns 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 /syncthat produces more than 3 notifiable changes sends onesync_batchpush 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_batchand 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 toonRegistered(fid)is sent asfcm_tokenwithPUT /devices/current/push-token(E-17). The server sends pushes to that id in the FCM HTTP v1message.fidfield (§14.6, D-096). When the device is revoked (AC-3.3) the app callsunregister(). 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 fromonRegisteredreplaces the kept one (PushTokenRetryin: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_PRIVATEand 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. Defaultlast_6_months. - AC-14.3 Granularity:
monthorweek(ISO, Monday start). Defaultmonth. - AC-14.4 Measure:
household(sum of full expense amounts, whoever paid) ormy_share(sum of the viewer's shares). Defaulthousehold. - AC-14.5 Group by:
categoryoraccount. Defaultcategory. - 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/lateston every cold start and at most once per 6 hours while resumed (the manual check of AC-16.6 skips this throttle, D-140). Whenversion_codeis 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:UpdateRepositoryemitsUpdateState.NeedsInstallPermissiononce (the explanation-shown flag inprefs.preferences_pb, §14.2.2), and the nextinstall()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 anUpdateCheck(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);Availableshows the update banner. Debug builds reportUpToDatewithout a request (AC-16.5). S-22 "Check for update" always callsGET /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(cratekvitt-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
otherand are listed in the summary. - AC-17.4 Splitwise payments become settlements with one allocation.
- AC-17.5
--dry-runprints 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=importand 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) andvalues-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.ktssetsandroidResources.localeFilterstoen,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 /healthreturns 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
serverservice uses it.
FR-21 Web client
The web client of §15 (D-129 to D-134). Its acceptance criteria:
- AC-21.1
https://<KVITT_PUBLIC_HOST>/app/serves the web client from the server image; every path under/app/that is not a file servesindex.html, except a missingassets/*file, which is 404not_found(D-135); without the web files (KVITT_WEB_DIRmissing)/app/*is 404not_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 · <browser>; the token is kept inlocalStorageand 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_revokedadds 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 /syncon 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 /syncfor 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.jsonandspend_series.json; its category suggestion passessuggest_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
Nopens a new expense,/the search,?the shortcut help,Esccloses 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 inGET /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
statereaches 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_atof 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_idis set by the server from the authenticated device's user when it first stores the row, never taken from the client.updated_by_user_idandupdated_by_device_idare set by the server on every accepted write.deleted_atmarks 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, Rusti64, SQLiteINTEGER). - 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
devicetargets 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://<KVITT_PUBLIC_HOST>/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, <name>_sv_light.png, <name>_sv_dark.png and <name>_en_light.png (English covers string length, dark covers the theme; there is no en_dark), plus <name>_sv_light_200.png at 200 % font scale for the long states; :app's empty_screen_<light|dark>.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-h<height>dp-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 exampleonOpenExpense: (ExpenseId) -> Unit,onBack: () -> Unit). - Features never reference other features or the
NavHost.:appowns theNavHostand connects the lambdas to routes. - ViewModels receive repositories through the
:core:modelinterfaces (§14.2.4).:appbinds the interfaces through Hilt to:data; bindings to:core:fakesexist only in test and debug-preview source sets and never reach a release build. - Shared UI that two features need is built in
:core:designby M6 (D-019); a feature never copies another feature's component. - Strings:
:appholds only the app name;:core:designholds the shared strings (including the notification copy and channel name of §13.3 and §14.6, which:appuses); each feature module holds its own. LintMissingTranslationis an error in every module. Keys of a feature module are prefixed by the feature name (home_,account_,expense_, ...); keys in:core:designnever 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): theX-Kvitt-Protocolcheck (426protocol_unsupported), theX-Kvitt-Server-Versionresponse header and the §8.6 body limits (1 MiB, 5 MiB forPUT /me/picture) are layers insrc/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 insrc/api/rate_limit.rsand attached at one line inrouter.rsthat M2a marks with a comment; that line is the only one M3 edits inrouter.rs. - Background tasks: M2a creates
src/tasks/mod.rswith astartfunction thatservecalls once after migrations (initially doing nothing). M4 ownssrc/tasks/**and starts the scheduler, the push sender and thesync_mutationspruning from it. As M4 built it (D-039):tasks::startreturns theAppStatewith the real push queue installed, andservebuilds the router from that returned state. Since the server overhaul (6c405e1) it is a synchronousfn start(AppState) -> AppState(it never awaited or failed); D-039 holds. - Audit and push interfaces: M2a defines the traits
AuditWriter(insrc/audit/) andPushQueue(insrc/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::newholdsSqlAuditWriterby default andNoopAuditWriterno longer exists;NoopPushQueuestays as the queue when push is disabled (§14.6), andtasks::startinstalls the boundedPushHubotherwise. As M2a built them (exact signatures inserver/README.md):AuditWriter::writetakes the caller's open connection inside itsBEGIN IMMEDIATEtransaction and oneAuditEvent(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 anAppError. The writer deriveschanges(§14.4.7) and takes the entry'sserver_versionfrom that same transaction.PushQueue::enqueuetakes onePushMessage(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_dateand the day clamping inkvitt-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 throughAuditWriter, push throughPushQueue) insrc/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_versionsequence. One integer cursor per phone is the simplest correct incremental sync. SQLite's single writer, with every write in aBEGIN IMMEDIATEtransaction, 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
PackageInstalleris 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://<KVITT_PUBLIC_HOST> (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 <token>. The server hashes it, looks up a device with that hash andrevoked_at IS NULL, and loads the user. Missing or unknown token: 401unauthorized. Revoked: 401device_revoked. - The server updates
devices.last_seen_at,devices.app_version_codeanddevices.app_version_nameat most once per hour per device. That update setsapp_version_codeonly when the request carriesX-Kvitt-App-Versionand its value parses as an integer, andapp_version_nameonly when the request carriesX-Kvitt-App-Version-Namewith 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 fileauth.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
-, as7K3M-QX9D-2HPA. URLs carry the 12 characters without hyphens. - Normalisation before hashing or sending: uppercase; remove
-and spaces; replaceOwith0andIandLwith1. A normalised code that is not exactly 12 alphabet characters isinvite_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 headerX-Kvitt-App: <key>with the app key nor comes from the home LAN192.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'sreal_ip_header X-Real-IPfor private peers lets a client set$remote_addr. The release app sendsX-Kvitt-Appon every request when its build carries the key: the Gradle propertykvitt.appKey(-Pkvitt.appKey=…, else the keykvitt.appKeyinandroid/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 asBuildConfig.APP_KEYand to:dataasAppBuildInfo.appKey(whosetoStringhides it); added inKvittApi's common headers (every API call:/health,/apk/latest,/apk/files/{file_name},/invites/acceptincluded) and inPictureAuthfor 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 inandroid/local.propertiesand 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 andcontracts/openapi.yamlis 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 secondifline 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;/healthfrom outside without the header gets a closed connection, so the outside check is the app's About screen (S-22) orcurl -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/appor 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 anAuthorization: Bearerheader 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 (taskgeneratePinnedRootNetworkSecurityConfig) from the templateandroid/app/src/releasePinnedRoot/network_security_config.xmlwith the domainkvitt.publicHost(D-121), into the release resources only when the Gradle propertykvitt.tls.pinnedRootistrue(D-082); the build stops with a message whileandroid/app/src/release/res/raw/kvitt_root_ca.crtis not committed (038c603). - Debug builds override the config in
android/app/src/debugand 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 bySessionRepositoryImpl.normaliseServerUrl. Lint'sInsecureBaseConfigurationwarning on the debug config is intended (D-082). - The server trusts
X-Forwarded-Foronly from NPM:KVITT_TRUSTED_PROXYisHA_HOST, NPM's LAN IPv4 address (an IP literal in the Pi's.env, D-125), and from that peer the server takes the lastX-Forwarded-Forentry 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 429rate_limitedwith aRetry-Afterheader in seconds. The/apppaths count against the client IP even when the request carries a bearer token (D-136): a page load isindex.htmland 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 429rate_limitedwithRetry-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_limitedonce 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 withoutX-Kvitt-Protocolbecame 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 ofX-Forwarded-For. Per-IP keying needs the peer address:serveruns the router withinto_make_service_with_connect_info::<SocketAddr>()(added by M4 in15056dc, D-032 fulfilled). GET /healthfrom 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/pictureat 5 MiB. Larger: 413payload_too_large. Both limits are layers inrouter.rs(§6.3). - JSON strings are validated for length after trimming leading and trailing whitespace, and are stored trimmed,
notesincluded (D-034). Control characters other than newline are refused withvalidation_failedinnotesonly; 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 prefixKvitt. - Nginx Proxy Manager's access log (
proxy-host-<id>_access.loginside NPM) records the full$request_uri, query string included, on every path except/i/; thelocation ^~ /i/of the Custom Nginx Configuration hasaccess_log offanderror_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 theAuthorizationtoken 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 sentper message with aggregates only:push_type,devices_sent,devices_targetedand 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
codeorstate, and no Enable Banking response body; the card adapter logs one info line per fetch with aggregates only (card_fetch: provider id, kindbackgroundoruser, 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/callbackis 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 orGET /sync. Backups (§10.9) include the card tables in the SQLite snapshot, like every table; the private key is not indata/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
cryptremote and to keep the token'sdrivescope (security review M-1 closed as an accepted risk, §8.1,docs/validation/security-review.md).
8.8 Phone hardening
android:allowBackup="false"andandroid:dataExtractionRulesexcluding 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 serviceKvittMessagingServiceis not exported. Checked on the merged release manifest at M8. - On
device_revokedthe 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_GIDin.env, 1000:1000, D-125), on the development stack and as the image's default10001; with a read-only root filesystem and a 16 MB tmpfs/tmp,/dataas the only writable volume, its secrets read-only (the FCM key file and, since D-175, the foldersecrets/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
backupsidecar publishes no port and is reachable only on the compose default network. Anyone on the home LAN can reachhttp://<kvitt-pi-address>:8080directly; such requests count against their own address (§8.6, D-120). - SQLite
PRAGMA foreign_keys = ON,journal_mode = WAL,synchronous = NORMAL,busy_timeout = 5000on 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 <other's share>" |
| V ≠ P and V's share > 0 | "You borrowed <V's share>" |
| 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) = 0always.- 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):
- Owing accounts = accounts where payer's net < 0. Sort by
created_atascending, then byidascending. remaining = amount. For each owing account in order whileremaining > 0:a = min(remaining, −net), emit allocation(account, a),remaining −= a.- If
remaining > 0(the amount exceeds the total debt) and at least one owing account exists, addremainingto the last emitted allocation and report that account as over-settled. - 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.
- 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 NULLandpaused = 0, the candidate periods are every monthpwithstart_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_idandupdated_by_user_id= the template'screated_by_user_id;updated_by_device_idnull;created_at=updated_at= server now;recurring_template_idandrecurring_periodset), writes its splits (modedefaultresolves the account's current default ratio at generation time; other modes use the template's splits, withratio/fullrecomputed by the ratio rule for the template amount), writes an audit entry (create,source=recurring), sets the template'slast_generated_periodandupdated_at(server now), bumping itsserver_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_periodto the latest period whose occurrence instant is before now, if that is later than the current value and at or afterstart_period. Resuming a template that has not started yet therefore keeps itsstart_period(D-033). - Changing
day_of_monthorstart_periodnever regenerates past periods. next_occurrence_date(in the wire form, computed withkvitt-domain::next_occurrence_date, §14.5.2) is the clamped date of the first candidate period afterlast_generated_periodthat is ≥start_periodand ≤end_period;nullwhen paused, deleted or pastend_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 beforekr; comma decimal; always two decimals). - Money, any other locale:
SEK 1,234.50(U+00A0 afterSEK; 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., English3 Oct; with year when not the current year (3 okt. 2025,3 Oct 2025). Detail views: Swedishlördag 3 oktober 2026, EnglishSaturday, 3 October 2026. Timeline times: 24-hour14:02in 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), EnglishMay,Jun, …Oct; list dates keep the Swedish abbreviation dot (3 okt.,25 sep.;mars,maj,juni,juliare 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 form24 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: Swedish8,4 MB, English8.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 <admin id> 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:
- Unicode NFC, then lowercase without locale rules (Kotlin
lowercase(), which usesLocale.ROOT). - Delete every
'(U+0027),’(U+2019) and:(so "Willy:s" becomes "willys" and "McDonald's" becomes "mcdonalds"). - 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. - 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:
- 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.
- 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.
- Keywords: the category of the winning entry of the keyword table (CS-6).
- Default: the AC-8.6 fallback: the category last used in the selected account on this phone if still active, else
other; whenotheris 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 = <KVITT_PUBLIC_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=<attempt id> for android, /app/cards?attempt=<attempt id> 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_fromanddate_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. Acontinuation_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.
strategyis never sent (Enable Banking's default strategy):strategy=longestneither lifts the cap nor returns the newest rows.WRONG_TRANSACTIONS_PERIODfor 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'sentry_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<purchase_date>|<amount>|<indicator>|<merchant text>|<n>,<n>the 0-based index of an identical earlier transaction in the same window's response.direction:debitforDBIT,creditforCRDT.status:pendingforPDNG,bookedforBOOK; other statuses are skipped.bank_transaction_codeis not used: the direction alone decides (D-179). K0 sawPURCHASE92,PAYMENT5,UNKNOWN2 andFEE1 in 100 transactions; a debitFEE(a card fee) orPAYMENTis 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, elsevalue_date(a pending transaction has no booking date), elsetransaction_date; a transaction with none of the three is skipped.booking_dateis stored when the transaction is booked. K0:value_dateon every transaction,booking_dateon every booked one and on no pending one,transaction_dateon none (D-179).- Amount:
transaction_amount.amountis 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 inSEK, purchases at German, Irish and Estonian merchants included (exchange_rateempty): the card account reports foreign purchases converted to SEK. A transaction whose currency is notSEK(not seen in K0; kept as a safeguard) is stored withamount_orenull, its amount as the decimal stringoriginal_amountandoriginal_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: theremittance_informationlines joined with a space, elsecreditor.name, else "Kortköp" ("Card purchase"); whitespace collapsed, trimmed, at most 140 code points (truncated). K0:remittance_informationholds exactly one line on every transaction and it is the merchant text;creditor.nameis 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, schemeCPAN),creditor.postal_address(K0: the merchant's country and town),transaction_idorbank_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://<KVITT_PUBLIC_HOST>/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
10. Deployment & ops
All infrastructure files live in infra/ (ownership §14.7). Secrets live in infra/secrets/ on the Mac (git-ignored) and in ~/kvitt/secrets/ (/home/jacobhagstedt/kvitt/secrets/) on the Pi (D-125); neither is ever committed.
Development first (D-001). During development the server runs locally on the owner's M4 Mac (Docker compose from infra/dev/compose.yaml, or plain cargo run in server/), and debug builds pair against it over the home Wi-Fi (debug builds accept http:// to private LAN addresses, AC-2.8). The development stack (built by M2c-dev, landed c885aba, D-022) has the server service and the backup job runnable on demand without the Drive upload; it has no proxy and no TLS (plain http://, which debug builds accept on private LAN addresses). Its data is a bind-mounted folder on the Mac (infra/dev/data/, git-ignored, and infra/dev/backups/ for local snapshots); it mounts infra/dev/secrets/ (git-ignored) read-only at /secrets, into which infra/dev/up.sh copies only fcm-service-account.json from infra/secrets/, so the server container sees no other secret (D-117; a folder rather than the file, so a missing key leaves the folder empty and push disabled, §10.4); its env file is infra/dev/.env.example (no RCLONE_REMOTE since fc6126c: the development backup never uploads); its server healthcheck is kvitt-server healthcheck; its backup runs as uid 10001 so that it can open the WAL files the server owns (on the Pi both run as the owner's uid, D-125). The Pi's env template is infra/pi/.env.example (D-103, D-125). The development stack's database was migrated to schema 2 (migration 0002, §14.1.1) on 2026-10-18 after a backup, and to schema 3 (migration 0003, §14.1.2) on 2026-10-18 when it was rebuilt from b7b7483, again after a backup. On the Mac's Podman setup infra/dev/up.sh alone does not swap in a rebuilt image: run infra/dev/up.sh --down, then infra/dev/up.sh. Firebase (§10.7) is set up during development because push is needed then. Everything in this section that concerns the Pi (the Pi hosts and router of §10.1, §10.2, the registry and Pi deploy of §10.3, §10.5 TLS, publishing to the Pi in §10.6, the Drive upload of §10.9) is built by milestone M2c-pi, which runs after M8 and before M10.
No SSH from the Mac to the Pis (D-100, revises D-023). M2c-pi delivers a self-contained compose project in infra/pi/ (D-125, revises D-103): compose.yaml, the env template .env.example and a .gitignore, with the owner's checklist in infra/README.md "Pi: what the owner does" (seven steps) and the restore runbook infra/RESTORE.md. The owner fills infra/pi/.env on the Mac, copies compose.yaml, .env and secrets/ to /home/jacobhagstedt/kvitt (~/kvitt) on the Kvitt Pi and runs plain docker compose commands there; nothing on the Pi outside that folder is installed or changed, there is no install script and no script on the Pi at all, and Docker and the registry trust are prerequisites (D-125, owner decision 2026-10-20). Agents never connect to the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive and need no allowance for them; they check the project on the Mac (docker compose config with the env template, shellcheck on the scripts under infra/, a Podman rehearsal). Where §10.2 to §10.10 name a command on the Pi, the owner runs it in ~/kvitt. M2c-pi runs as the last step before M10, after the app is proven on the phone against the development server. D-001 stands: development stays on the Mac. History: the first bundle landed on 2026-10-18 (2476667, D-103 to D-106: install, deploy, bootstrap, backup and APK scripts under /opt/kvitt); since D-120 (d05f13a, 62f5279, landed with 9231a1d on 2026-10-19) TLS terminates in the owner's Nginx Proxy Manager on the Home Assistant Pi (no Caddyfile, no Caddy, no cert-sync); since D-125 (6e5981d, 2026-10-20) the scripts install.sh, deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh and lib.sh are removed and their equivalents are the compose commands of §10.6, §10.9 and §10.10. Step 4 of the checklist sets up the NPM proxy host (§10.5).
10.1 Hosts and network
| Host | Role | Facts |
|---|---|---|
| Kvitt Pi | Runs the compose stack; publishes the server's port 8080 on the LAN for NPM (D-120) | Raspberry Pi 3, 1 GB RAM, 64-bit Raspberry Pi OS Lite (Debian bookworm or later), Docker Engine with the compose plugin (from Docker's Debian arm64 apt repository, D-104; a prerequisite, already installed, usable without sudo by the owner's user jacobhagstedt, uid and gid 1000, D-125), static LAN address. Kvitt runs from /home/jacobhagstedt/kvitt (D-125). LAN address <kvitt-pi-address>, known to the owner (OQ-3, D-100; no .env key since 200c99f); also NPM's Forward Hostname / IP; no agent and no Mac script reaches it over SSH. Outbound HTTPS from the server container to oauth2.googleapis.com and fcm.googleapis.com (push) and, since D-175, to the Enable Banking API api.enablebanking.com (card purchases); nothing inbound but NPM's forward |
| Home Assistant Pi | Runs the owner's Nginx Proxy Manager (NPM), which owns ports 80/443: terminates TLS for the Kvitt host name (KVITT_PUBLIC_HOST, owner-chosen, recorded in HANDOFF) with NPM's own Let's Encrypt certificate (HTTP-01 on port 80) and forwards plain HTTP to <kvitt-pi-address>:8080 (§10.5, D-120); closes every request from outside the home LAN without the app key (§8.5, D-126) |
Its LAN IPv4 address is HA_HOST in the Pi's .env, filled by the owner (OQ-3), and is the server's KVITT_TRUSTED_PROXY. NPM is also the proxy host for Home Assistant under icehack.asuscomm.com, on the same port 443, and selects the proxy host by name, so Kvitt has its own name (D-124). The owner sets up the proxy host in NPM's web UI (§10.5.1); nothing of Kvitt runs there and nothing connects to it over SSH |
| Owner's M4 Mac | Builds release APKs (infra/publish-apk.sh); builds images only as the fallback of the Gitea workflow (infra/build-images.sh, D-104); runs the development server (D-001) |
Apple Silicon, so linux/arm64 builds run natively in Docker |
| Router (ASUS) | icehack.asuscomm.com DDNS (Home Assistant's name); the Kvitt host name (KVITT_PUBLIC_HOST, owner-chosen; currently kvitt.suorra.duckdns.org, HANDOFF) points at the same public address (a subdomain of the owner's DuckDNS family domain suorra.duckdns.org) |
External TCP 443 → Home Assistant Pi 443 (NPM), already in place for Home Assistant, serves Kvitt too: no new rule (D-124; the owner confirmed the 443 forward, 2026-10-19); external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (D-120) |
| Container registry | Holds Kvitt images | A Gitea instance on another Pi at http://gitea.lan: plain HTTP, reachable only on the home Wi-Fi (D-001). Kvitt images live under the owner path gitea.lan/jacobh2 (D-104); Gitea Actions builds them (§10.3). Pushes use the Gitea secret kvitt_cicd (§10.3). The Kvitt packages are public, so the Pi pulls anonymously; were they private, the owner runs docker login gitea.lan once on the Pi with a read:package token (HUMAN, D-125). Agents never hold credentials (D-100) |
The Kvitt Pi's compose stack never builds anything. It only pulls images and runs them.
10.2 Compose stack
File infra/pi/compose.yaml (compose project name kvitt). The owner copies it with .env (from infra/pi/.env.example) and secrets/ to /home/jacobhagstedt/kvitt (~/kvitt) on the Kvitt Pi, a self-contained compose project: every path in it is relative to that folder and nothing outside it is installed or changed (D-125, revises D-103; until then install.sh installed the stack to /opt/kvitt). The folder holds compose.yaml, .env, secrets/ (fcm-service-account.json, rclone.conf) and the bind-mounted folders data/ (SQLite, pictures, data/apk/), backups/ and import/, which the owner creates before the first start (mkdir -p data/apk backups import; and mkdir -p secrets/cards && chmod 700 secrets/cards since K1, D-188), because a bind-mount source that Docker creates itself belongs to root and the containers cannot write to it. Services:
| Service | Image | Ports / volumes | Memory limit | Healthcheck |
|---|---|---|---|---|
server |
${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}, command serve |
./data:/data; ./secrets/fcm-service-account.json at /secrets/fcm-service-account.json, read-only, create_host_path: false; ./secrets/cards at /secrets/cards (the Enable Banking private keys, D-175), read-only, create_host_path: false (an empty folder disables card features, but a missing one stops docker compose up, even with no card configured: the folder must exist from K1's image on, D-188); environment also KVITT_EB_APP_ID=${KVITT_EB_APP_ID:-} and KVITT_EB_MEMBER_APP_ID=${KVITT_EB_MEMBER_APP_ID:-}; ./import:/import:ro (empty except during the one-off import, §10.8, D-025); publishes 8080:8080 on all of the Kvitt Pi's interfaces (NPM connects over the LAN, D-120); environment TZ, KVITT_DATA_DIR=/data, KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST} (port 443, D-124), KVITT_TRUSTED_PROXY=${HA_HOST}; the other settings keep their §10.4 defaults |
160 MiB (budget: RSS under 100 MB in steady state) | kvitt-server healthcheck, the binary's own GET http://localhost:8080/health (no curl in the image); every 30 s, timeout 5 s, 3 retries, start period 20 s |
backup |
${KVITT_REGISTRY}/kvitt-backup:${KVITT_TAG} |
./data:/data; ./backups:/backups; ./secrets/rclone.conf at /config/rclone/rclone.conf, read-only, create_host_path: false; environment TZ, HOME=/tmp, RCLONE_REMOTE=gdrive: (fixed in compose.yaml, the [gdrive] remote of rclone.conf), RCLONE_FOLDER_ID=${RCLONE_FOLDER_ID} |
96 MiB | /healthcheck.sh: last successful run marker newer than 26 hours, or no run yet since start; every 5 min, start period 30 s |
- No named volumes:
dataandbackupsare the bind mounts./dataand./backups(D-125; were the named volumesdataandbackups;certsandcaddy_datawere removed by D-120). - Both services:
user: "${KVITT_UID}:${KVITT_GID}", the owner's uid and gid (1000:1000 on the Pi);read_only: truewith a 16 MB tmpfs/tmp;cap_drop: [ALL];no-new-privileges;restart: unless-stopped; logging driverjson-filewithmax-size: 10m,max-file: 3;TZ=Europe/Stockholm. - The compose default network (no network is declared; was the bridge network
kvitt); onlyserverpublishes a port. - No host firewall limits the published port 8080 to
HA_HOST, so any device on the home LAN can reach the server over plain HTTP; the owner deferred such a rule on 2026-10-24 (D-156). It would be the owner's own step on the Pi, outside~/kvitt(D-125). - The Memory limit column is advisory on the Kvitt Pi (D-156, owner 2026-10-24):
compose.yamlkeeps itsmem_limitlines, but the Pi's kernel runs without the memory cgroup, so Docker warns atdocker compose upand does not enforce the limits. The warning is accepted; the RSS budget is checked withdocker stats(§10.10). - One uid for both containers (D-105, revised by D-125):
sqlite3 .backupon the WAL database must open the-shmfile the server owns, sobackupruns as the server's uid. On the Pi that uid is the owner's own, so every file indata/andbackups/belongs to the owner, the two secret files are the owner's with mode 0600 (chmod 700 secrets && chmod 600 secrets/*), and a restore moves files with plainmvandcp(§10.9). The images need no particular uid: the server image'sUSER 10001is only its default, which compose'suser:overrides (the binary needs no account and nothing writable outside/data), and supercronic runs as any uid; the owner's uid has no account inside either image, sobackupgetsHOME=/tmpfor rclone's cache. rclone writes refreshed access tokens back to its config, and the mounted file is read-only, sobackup.shworks on a private copy in/tmpon every run (D-105). - Each container mounts only its own secrets, read-only (D-117): the server never sees the Drive token, and the backup container never sees the FCM key or the Enable Banking keys (
secrets/cards/, D-175).create_host_path: falsemakes a missing secret file stopdocker compose upwith "bind source path does not exist" instead of Docker creating a root-owned directory in its place. These two, and since D-175 the Enable Banking key files insecrets/cards/, are the only secret files on the Pi. - The development stack (
infra/dev/compose.yaml) is unchanged by D-125: both containers there run as uid 10001 (§10 intro).
.env keys (exhaustive, D-125, revises D-103; two optional keys added by D-175): exactly the nine keys compose.yaml reads, and nothing else reads .env on the Pi. The two card keys KVITT_EB_APP_ID (the Enable Banking application id, shared by both users or the owner's) and KVITT_EB_MEMBER_APP_ID (the partner's own application, empty when the shared one serves her, CP-13) may be empty; an application id is not a secret (its private key is, in secrets/cards/). KVITT_REGISTRY (gitea.lan/jacobh2); KVITT_TAG (the image tag of both services, v1.0.N; a deploy or a rollback changes it, §10.10); KVITT_PUBLIC_HOST (the Kvitt host name, owner-chosen, recorded in HANDOFF; kvitt.suorra.duckdns.org in the template); HA_HOST (NPM's LAN IPv4 address, an IP literal rather than an mDNS name such as homeassistant.local, which the container cannot resolve; the template's only CHANGE_ME); KVITT_UID and KVITT_GID (id -u and id -g of the owner on the Pi; 1000 in the template); RCLONE_FOLDER_ID (the Drive folder id of OQ-4, filled in the template). Compose refuses to start while one of the seven required keys is unset (${VAR:?…}; the two card keys default to empty); nothing else checks the values, since there is no install script. Removed keys: HA_PORT, HA_USER, HA_CERT_DIR (D-120); the informational KVITT_PI_HOST and KVITT_PI_SSH_USER, read by nothing on the Pi (2026-10-20, 200c99f, revises D-103; the owner's scp commands and NPM name <kvitt-pi-address> instead); RCLONE_REMOTE (fixed to gdrive: in compose.yaml); KVITT_REGISTRY_USER and KVITT_REGISTRY_TOKEN (D-125: private packages mean a one-time docker login gitea.lan on the Pi). On the Mac, infra/publish-apk.sh reads KVITT_PUBLIC_HOST from infra/pi/.env (§10.6).
10.3 Images, registry and build
- Image names (D-104, D-120):
${KVITT_REGISTRY}/kvitt-server,${KVITT_REGISTRY}/kvitt-backup, withKVITT_REGISTRY=gitea.lan/jacobh2(was${KVITT_REGISTRY}/kvitt/<name>). - The Gitea Actions workflow (D-104; the fallback since D-211, see the Mac build below):
.gitea/workflows/build-images.yaml, following the owner's working example workflow. Runner labelhost: act_runner 0.6.1 on a Raspberry Pi 3 with 1 GB RAM, with an Alpine job container; the builds run on that Pi itself.shsteps,apk add git docker-cli docker-cli-buildx,docker login gitea.lanas${{ github.actor }}withkvitt_cicd, a user-level secret of the repository ownerjacobh2(created by the owner; a user-level secret of the repository owner reaches the repository), a plain-HTTP buildx builderlocal-http-builder(buildkitd.tomlwithhttp = trueforgitea.lan) created once and reused so its BuildKit cache survives, andactions/checkout@v4(below). The token is interpolated into the login step'srunline (printf '%s' "${{ secrets.kvitt_cicd }}" | docker login …), the owner's proven form, because the env-based form (env: REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }}) came through empty on act_runner 0.6.1 ("password is empty" on the first run, 2026-10-19); act_runner masks the value in the log (4f69ae0, D-120 point 7; this reverses the L-8 fix ofe70fe0d).kvitt_cicdneeds only thewrite:packagescope, because the git tag push goes through the credentialsactions/checkoutstored (the job's own token) (D-117); the tag push worked on the first successful run (itsv1.0.0tag is why the next run madev1.0.1). Checkout (3820f9c): a cone-mode sparse checkout (sparse-checkoutserver,web,contracts(both added by D-137) andinfra/backup,sparse-checkout-cone-mode: true), what the two builds read (the server image builds from the repository root with the allowlist.dockerignoresince D-137; the backup image frominfra/backup), which makes the action fetch with--filter=blob:none, so only those paths' blobs are downloaded and the Android screenshot PNGs are not checked out;fetch-depth: 0stays, because "Determine Version" needs everyv1.0.*tag and with a shallow fetchfetch-tagsbrings only the tags that point at the fetched tip (the full fetch carries commits and trees, no blobs). The temporary "Diagnose secrets" step (dd07ca4) is removed (3820f9c). Everybuildxpush passes--provenance=falseand--metadata-file, and a step "Print image digests" prints each pushed image's digest, which the owner compares with the digest the Pi pulled (docker image inspect, §10.10; the Pi keeps no digest record since D-125, which revises D-117 point 5). Triggers: a push touchingserver/**,web/**,contracts/**(the last two added by D-137),infra/backup/**or.gitea/workflows/**, andworkflow_dispatch(infra/cert-sync/**dropped by D-120). Versionv<MAJOR>.<MINOR>.<PATCH>with MAJOR 1 and MINOR 0 set in the workflow: the next patch after the newestv1.0.*git tag (v1.0.0when there is none); each image is tagged with it andlatest, and the git tag is pushed after the build. That tag is the Pi'sKVITT_TAG(§10.2, §10.10); it says nothing about the APK version (§10.6). The Pi runner passes--build-arg CARGO_BUILD_JOBS=1to the server build (Pi 3, 1 GB), andserver/Dockerfilehonours it (ARG CARGO_BUILD_JOBSin its build stage, passed to cargo as--jobsonly when set; unset on the Mac means one job per core;b4d9467). "Determine Version" also setsVERSION_CODEtogit rev-list --count HEAD(the full history is fetched,fetch-depth: 0), and the server build passes--build-arg KVITT_VERSION_CODE=$VERSION_CODE, the web client's version code (§15.3, v0.34). Runs on the owner's Pi runner: the first run (2026-10-19) failed at the login with the env-based form; the next ones failed at the login with an empty secret, because the user-level secret was misnamed on the owner's side (found with the diagnostic step, fixed by the owner on 2026-10-19). Run 69 succeeded: login, versionv1.0.0,kvitt-backupbuilt and pushed in 5 min 20 s,kvitt-serverin 2 h 42 min (the first build on the Pi 3 withCARGO_BUILD_JOBS=1; later runs reuse the builder's cargo cache), the git tag pushed. The push of the sparse checkout (3820f9c) producedv1.0.1, the tag the Pi uses. Since28fa71b(2026-10-20) the workflow's comments andinfra/build-images.sh's closing hint give the compose deploy (cd ~/kvitt, setKVITT_TAG,docker compose pull && docker compose up -d) anddocker image inspectinstead of/opt/kvitt/deploy.shand/opt/kvitt/deployed-digests; the workflow's header still says "NOT YET RUN" and that the first build's duration is unknown. Unverified risk: the workflow pushes a git tag, and whether that push re-triggers the path-filtered workflow is not known. - Script
infra/build-images.sh [tag]on the Mac, the normal path since D-211 (the fallback under D-104 and D-156): builds the two images forlinux/arm64, tag defaults to the short git SHA ofHEAD, also tagslatest; pushes wheninfra/secrets/registry.env(git-ignored:KVITT_REGISTRY, optionalKVITT_REGISTRY_USERandKVITT_REGISTRY_TOKEN) exists, otherwise builds locally and prints the exact push commands. Refuses to run with a dirty working tree or while a Gradle build holds thetools/gradle-serial.shlock (the lock check shared throughtools/gradle-lock.sh,2fac1ec). Works with Docker buildx and with Podman. - Where images are built (owner, 2026-10-24, D-211, revises D-156 point 5): on the owner's Mac with Podman,
infra/build-images.sh v1.0.N; the Gitea workflow on the Pi 3 runner stays the fallback (about three hours per run and disk pressure on its 15 GB card; the owner cancels runs it queues). Tag rule:git fetch --tags, then the nextv1.0.Nafter the highestv1.0.*tag onorigin; the images carry that tag andlatest, and the same tag is pushed as a git tag on the built commit (git tag v1.0.N && git push origin v1.0.N), so the workflow's own numbering continues after it (v1.0.3=8fa9198, the first Mac build). Registry from the Mac:gitea.lanon port 80, plain HTTP;infra/secrets/registry.env(git-ignored) holdsKVITT_REGISTRY=gitea.lan/jacobh2,KVITT_REGISTRY_USERand awrite:packagetoken asKVITT_REGISTRY_TOKEN. The Pi's own.envkeepsKVITT_REGISTRY=gitea.lan:3001/jacobh2, the port it reaches the same registry on locally; both name the same images. Podman marks the registry insecure in two places,~/.config/containers/registries.conf.d/kvitt.confon the Mac and/etc/containers/registries.conf.d/kvitt.confinside the Podman machine, each[[registry]]location = "gitea.lan"insecure = true(owner's allowance 2026-10-24 for these files outside the repository). Two script fixes made the Mac path work: the server build gets--build-arg KVITT_VERSION_CODE=<git rev-list --count HEAD>as the workflow passes it (f15fa23), and the engine test capturesdocker versionbefore matching it (8fa9198: underpipefail,grep -qclosed the pipe early,docker versiondied of SIGPIPE and the script chose docker over Podman). The script prints no image digests, so the digest comparison of §10.10 applies to workflow builds. If the fallback runs, the owner still runsdocker builder prune -afon the runner Pi between builds (its disk reached 11 of 15 GB); that can drop the BuildKit cache, so the next server build there may be a full one. - Web client (D-132, as built D-137,
50a0d67): the server image's build context is the repository root (docker build -f server/Dockerfile .) with an allowlist.dockerignoreat the root: everything is excluded exceptserver,webandcontracts, and inside themserver/target,server/data,web/node_modules,web/dist, SQLite and.dbfiles, APKs,.pemfiles,.envfiles and.DS_Storeare excluded too, so the Android project, the docs, the goldens, worktrees and secrets never enter the context;server/.dockerignoreis removed (infra/backup/Dockerfilebuilds from its own directory and does not read the root file).server/Dockerfilehas three stages: (1)web:docker.io/library/node:24.22.1-bookworm-slimpinned by the digest of its multi-arch index (Node 24, the Active LTS line on 2026-10-21), on$BUILDPLATFORM(the output is static files, so an amd64 build never emulates npm),npm ciwith a BuildKit npm cache, thennpm run buildin/repo/webbeside/repo/contracts(the web build may read../contracts), failing unlessdist/index.htmlexists; the stage has no.git, soARG KVITT_VERSION_CODE=1is passed tonpm run buildas the environment variableKVITT_VERSION_CODE, the web's version code (§15.3; default 1, the workflow passes the commit count, v0.34); aweb/withoutpackage.jsonandpackage-lock.jsonfails the build by design; (2) the Rust build stage, which copies the web stage'sdistto/out/web, so it orders the two builds and npm and rustc never run at once on the 1 GB Pi 3 runner (BuildKit would otherwise run the independent stages in parallel; a web-only change re-runs the cargo step, which finds nothing to compile);CARGO_BUILD_JOBS=1stays; (3) the runtime stage, which copies/out/webto/app(owned by root, read-only for uid 10001;KVITT_WEB_DIR§10.4, serving §15.4). The workflow builds with context.andfile: server/Dockerfile, its sparse checkout and path triggers addwebandcontracts(above);infra/build-images.shbuildskvitt-serverwith context.;infra/dev/compose.yamlhas no build section, soinfra/dev/build.shbuilds with the repository root as context (KVITT_SERVER_BUILD_CONTEXTstill overrides it). The Pi runner builds the Node stage too (its duration on the Pi 3 not yet measured); the web client adds no image and no service. serverimage: multi-stage (since D-137 the Nodewebstage above runs first; the stages below are the Rust build and the runtime). Stage 1rust:<version>-bookworm, where<version>is the exact Rust release pinned inserver/rust-toolchain.toml(the current stable when M2a pins it; the two always change together), buildskvitt-serverandkvitt-importin release mode with--lockedforaarch64-unknown-linux-gnu(native on the M4; notarget-cpu=native, the Pi 3 is a Cortex-A53). Stage 2gcr.io/distroless/cc-debian12:nonroot(arm64) pinned by digest, with the two binaries in/usr/local/bin/, user 10001, entrypointkvitt-server, commandserve. The Dockerfile selects the Rust target fromTARGETARCH:arm64(aarch64-unknown-linux-gnu) is the shipped target;amd64(x86_64-unknown-linux-gnu) also builds, emulated on the Mac, for local use only. Since M4 the server depends on reqwest with rustls on aws-lc-rs and on jsonwebtoken (§6.3): aws-lc-rs compiles C code, so the arm64 build stage needs a C toolchain beside Rust, and the distroless runtime image must carry CA roots for the platform verifier to reach Google's endpoints. The dev image (infra/dev, linux/arm64 on the Mac) was rebuilt with these crates on 2026-10-18 and built cleanly. The Pi image's C stage and CA roots, and an OAuth plus FCM call from the image, were confirmed on the Mac on 2026-10-18 (docs/spikes/pi-image-arm64.md, landed3e6c337): arm64 image 63.8 MB, cold build 48 s, CA bundle from distroless, binaries run natively; running on the Pi itself is M2c-pi's.backupimage:alpinewithsqlite,gzip,tar,rcloneandtzdata, andinfra/backup/backup.shrun by supercronic; the healthcheck isinfra/backup/healthcheck.sh.- The backup sidecar schedules with supercronic instead of busybox
crond(D-104): it runs as a non-root user, and busyboxcrondstarted as a non-root user runs no job (tested 2026-10-18 in alpine 3.24.2). The schedule is unchanged (30 3 * * *). - Pinned versions (resolved 2026-10-18, D-104; table in
infra/README.md"Versions"): Alpinealpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6for the backup sidecar; backup packages supercronic 0.2.49 beside M2c-dev's sqlite, rclone and tar. The Caddy pin and the cert-sync packages left with D-120; Nginx Proxy Manager is the owner's existing install, and the block of §10.5.2 was written against NPM v2.16.0's templates. Docker Engine on the Kvitt Pi (a prerequisite, already installed, D-125) comes from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is for 32-bit only),docker-ce5:29.8.2 withdocker-ce-cli,containerd.io,docker-buildx-pluginanddocker-compose-plugin. - No scripts on the Pi (D-125, revises D-103):
install.sh,deploy.sh,bootstrap.sh,backup-now.sh,install-apk.shandlib.shwere removed in6e5981d. Their equivalents are plaindocker composecommands the owner runs in~/kvitt: install = copycompose.yaml,.envandsecrets/from the Mac,mkdir -p data/apk backups import,docker compose pull && docker compose up -d,curl localhost:8080/health; bootstrap =docker compose exec server kvitt-server bootstrap; partner invite =docker compose exec server kvitt-server invite-partner; one backup =docker compose exec backup /backup.sh; deploy and rollback §10.10; APK §10.6; restore §10.9. A changedcompose.yamlor.envis copied again the same way. The checksinstall.shmade on.envare gone (compose refuses an unset key, §10.2); its/opt/kvitt/deployed-digestsrecord is replaced bydocker image inspect(§10.10). - The Pi's Docker daemon must trust
gitea.lanas an insecure (plain HTTP) registry (insecure-registriesin/etc/docker/daemon.json). This is a prerequisite the owner keeps (the Kvitt Pi already has it for another project); nothing of Kvitt edits the daemon or the system (D-125;install.shadded it with sudo, D-103). Were the packages private, the owner runsdocker login gitea.lanonce with aread:packagetoken (D-125).
10.4 Server configuration and CLI
Environment variables (exhaustive):
| Variable | Default | Meaning |
|---|---|---|
KVITT_DATA_DIR |
/data |
Holds kvitt.sqlite, pictures/, apk/ |
KVITT_BIND |
0.0.0.0:8080 |
Listen address |
KVITT_PUBLIC_URL |
none: required (D-123) | Used in invite links and the landing page. Unset or empty, every kvitt-server command exits 1 with a configuration error (configuration: KVITT_PUBLIC_URL is not set … on stderr) before opening the database; there is no built-in host (the fallback DEFAULT_PUBLIC_URL was removed by D-123). The Pi's compose sets https://${KVITT_PUBLIC_HOST} (port 443, D-124) from the Kvitt host name (KVITT_PUBLIC_HOST, owner-chosen, recorded in HANDOFF) (D-120); the development stack sets http://<Mac LAN address>:<port> (infra/dev/up.sh; the dev compose falls back to http://localhost:8080) |
KVITT_FCM_CREDENTIALS |
/secrets/fcm-service-account.json |
Service-account key; if the file is missing, push is disabled and a warning is logged at start |
KVITT_FCM_PROJECT_ID |
kvitt-1337 |
FCM project |
KVITT_TRUSTED_PROXY |
none |
IP literal or host name whose resolved address may set X-Forwarded-For (not a CIDR). The default none (NO_TRUSTED_PROXY in config.rs, compared ignoring case and surrounding white space) means no proxy and is never looked up (D-123; was caddy); an empty value counts as unset. Compose sets it to HA_HOST, NPM's LAN IPv4 address (D-120). The server must start when the name does not resolve (as on the development stack, which has no proxy, D-022 unchanged); with none or an unresolvable name no peer is trusted and the peer address is the client IP (D-022). The name is re-resolved at most every 60 seconds (D-032) |
KVITT_WEB_DIR |
/app |
Directory of the built web client, served under /app/ (D-135, §15.4). Missing (an image without the web client, cargo run in development): every /app path is 404 not_found |
KVITT_EB_APP_ID |
empty | Enable Banking application id for card purchases (D-175, CP-13): the admin's, and the member's too unless KVITT_EB_MEMBER_APP_ID is set. Empty, or its key file missing or unreadable: card features are disabled for that user (E-36 available false), with one warning at start, as push is (§14.6) |
KVITT_EB_KEY_FILE |
/secrets/cards/enable-banking.pem |
The PEM private key of KVITT_EB_APP_ID, as Enable Banking's control panel downloads it (<application id>.pem, renamed) |
KVITT_EB_MEMBER_APP_ID |
empty | The member's own Enable Banking application (CP-13 fallback); empty: the member uses KVITT_EB_APP_ID |
KVITT_EB_MEMBER_KEY_FILE |
/secrets/cards/enable-banking-member.pem |
The PEM private key of KVITT_EB_MEMBER_APP_ID |
KVITT_EB_API_URL |
https://api.enablebanking.com |
Enable Banking API base URL; tests point it at a local stub |
RUST_LOG |
info |
tracing filter |
The time zone Europe/Stockholm is a constant in code, not configuration.
Files under KVITT_DATA_DIR:
kvitt.sqlite(+-wal,-shm)pictures/<user_id>/<picture_version>-512.jpgand-128.jpg; older versions are deleted after a successful upload. E-10 deletes the JPEGs and leaves an empty marker file<picture_version>-removedinpictures/<user_id>/; the next upload uses the highest version on disk (JPEG or marker) plus one, so a cache keyed by (user_id,picture_version) never shows a removed picture. The first upload is version 1 (D-030).apk/latest.jsonandapk/kvitt-<version_code>.apk
kvitt-server subcommands (exhaustive):
| Command | Effect | Exit codes |
|---|---|---|
serve |
Run migrations, start scheduler, push sender and HTTP server | 0 on clean shutdown (SIGTERM drains in-flight requests up to 10 s) |
bootstrap |
FR-1 | 0 ok, 2 already initialized |
pair-device --user <user_id> |
AC-1.6 | 0 ok, 3 unknown user |
invite-partner |
Creates a partner invite by the admin, as E-12 (D-113) | 0 ok, 2 household full, 3 no admin |
rotate-epoch |
Sets a new random sync_epoch and prints it on stdout (§10.9) |
0 ok, 1 database error |
healthcheck |
Calls http://127.0.0.1:<port of KVITT_BIND>/health (8080 by default), exit 0 if 200; used by the compose healthchecks of both stacks (no curl in the image) |
0 or 1 |
migrate |
Run migrations only | 0 |
Run them on the Pi in ~/kvitt with docker compose exec server kvitt-server <command> (or docker compose run --rm server <command> when server is stopped) (D-125). Outside Docker, from the workspace root server/: cargo run -p kvitt-server -- <command> (the -p is needed because the workspace has two binaries), with KVITT_DATA_DIR set (server/README.md).
10.5 TLS
TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi (D-120; Caddy and cert-sync, which held this section until v0.20, are removed). Kvitt has its own public host name, KVITT_PUBLIC_HOST (owner-chosen, recorded in docs/HANDOFF.md), because icehack.asuscomm.com is NPM's proxy host for Home Assistant and NPM selects the proxy host by name, not by port. KVITT_PUBLIC_URL is https://${KVITT_PUBLIC_HOST}, with no explicit port: Kvitt is served on the default HTTPS port 443, on which NPM listens for every proxy host and selects by name (D-124). Serving Kvitt under a path prefix of another name (for example icehack.asuscomm.com/kvitt/) is not supported. The router forwards external TCP 443 to the Home Assistant Pi's 443 (NPM), already in place for Home Assistant, which serves Kvitt too: no new router rule (D-124; the 8443 rule of D-120 is not used); nothing is forwarded to the Kvitt Pi. NPM issues and renews the name's Let's Encrypt certificate through the HTTP-01 challenge on port 80, which already reaches NPM. The owner sets up the proxy host in NPM's web UI from infra/README.md "Pi: what the owner does", step 4 (step 6 before D-125); no agent and no script touches NPM. The proxy host carries the app gate of §8.5 (D-126) in its Custom Nginx Configuration (§10.5.2), set up by step 4a once both phones run an APK that carries the key; an APK without the key cannot reach the server from outside the LAN, its update download included. Since D-130 the gate also lets the web client through (§8.5, §15.2): its files under /app/, the invite accept and bearer-token requests.
10.5.1 Nginx Proxy Manager
NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web UI), Hosts, Proxy Hosts, Add Proxy Host:
| Tab | Field | Value |
|---|---|---|
| Details | Domain Names | <KVITT_PUBLIC_HOST> (currently kvitt.suorra.duckdns.org, HANDOFF) |
| Details | Scheme | http |
| Details | Forward Hostname / IP | <kvitt-pi-address> (the Kvitt Pi's LAN IPv4 address) |
| Details | Forward Port | 8080 |
| Details | Cache Assets | off |
| Details | Block Common Exploits | on |
| Details | Websockets Support | off |
| Details | Access List | Publicly Accessible (the app gate is in the Advanced block, D-126: an NPM access list checks addresses or HTTP basic auth, not a header, and basic auth would take the Authorization header the bearer token needs) |
| Custom Locations | (none) | left empty |
| SSL | SSL Certificate | Request a new Certificate: Let's Encrypt, HTTP challenge (Use DNS Challenge off), the owner's email address, Terms of Service accepted where shown |
| SSL | Force SSL | on |
| SSL | HTTP/2 Support | on |
| SSL | HSTS Enabled | on |
| SSL | HSTS Sub-domains | off |
| SSL | Trust Upstream Forwarded Proto Headers | off |
| Advanced | Custom Nginx Configuration | the block of §10.5.2, exactly, comments included |
- Plain HTTP on the LAN: NPM forwards plain HTTP over the home LAN to
<kvitt-pi-address>:8080, accepted by the owner (D-120). Anyone on the home LAN can also reach that port directly; such requests count against their own address for the limits of §8.6, and onlyHA_HOSTmay setX-Forwarded-For(§8.9). - Client IP: NPM's request to the server carries
X-Forwarded-For: <what the client sent>, <client address>. The server takes the last entry only when the TCP peer isKVITT_TRUSTED_PROXY, which compose sets toHA_HOST, NPM's LAN IPv4 address (an IP literal in.env, because the container cannot resolve an mDNS name such ashomeassistant.local; nothing checks it since D-125); the server is unchanged (D-032, D-115, D-119). The router's forward keeps the internet client's source address, and Docker's published port keeps NPM's (DNAT, no SNAT, for traffic arriving on the LAN interface), so the per-IP limits see real addresses (checked once, §10.10). The server does not useX-Forwarded-ProtoorHost: links are built fromKVITT_PUBLIC_URL(E-12). X-Real-IP(accepted): NPM'snginx.conftakes the client address fromX-Real-IPwhen the connection comes from 10/8, 172.16/12 or 192.168/16 (and the CDN ranges it downloads), so a device on the home LAN can choose its own client address for the rate limits. Internet clients arrive with their public address (the router's forward keeps the source address), so theirX-Real-IPis ignored.- HSTS: NPM sends
Strict-Transport-Securitywithmax-age=63072000; preload(two years, NPM's value) at server level. A browser then uses only HTTPS for<KVITT_PUBLIC_HOST>, on every port, and refuses certificate warnings for it; turning HSTS off later does not clear browsers that already stored it. Because the name is Kvitt's own nothing else is affected, but anything ever served under<KVITT_PUBLIC_HOST>(any port) must be HTTPS with a valid certificate too. The app does not rely on HSTS (release builds allow no cleartext, §8.5); it protects the landing page in a browser. - Health checks (D-125, revises D-120 point 6; no script runs them): after a start or a deploy the owner runs
curl -s localhost:8080/healthon the Kvitt Pi, and checks/healththrough NPM from the Pi (curl -fsS --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health, with the certificate checked; the Kvitt Pi is on the LAN, so no key is needed, D-126) and from outside: with the app gate in place (D-126) a browser outside the LAN gets a closed connection by design, so the outside check is the app's About screen (S-22) on the phone with Wi-Fi off, orcurl -fsS -H "X-Kvitt-App: $KEY" https://<KVITT_PUBLIC_HOST>/healthfrom outside the home network (before the gate:https://<KVITT_PUBLIC_HOST>/healthin Chrome on the phone with Wi-Fi off);infra/README.md"Pi: what the owner does" steps 3, 4, 4a and 6.
10.5.2 Custom Nginx Configuration
The block pasted into the proxy host's Advanced tab (D-120; it carries over D-117's response headers and log redaction). Written against NPM v2.16.0's proxy host template (backend/templates/proxy_host.conf); as of v0.28 it reads (infra/README.md, step 4, and the file infra/npm/kvitt.conf, three identical copies; the app gate lines added by D-126, the web client's gate lines and the $kvitt_csp variable by D-130 and D-132, the rest as of 62f5279):
# Kvitt (SPEC §10.5.2, D-120, D-126, D-130). NPM, Proxy Host <KVITT_PUBLIC_HOST>, Advanced tab, Custom Nginx Configuration.
# App gate (SPEC §8.5, D-126): outside the home LAN only a request carrying the key built into the app passes;
# everything else gets the connection closed (444: nothing sent, nothing to fingerprint). Put the key from
# android/local.properties (kvitt.appKey) in place of KVITT_APP_KEY. To rotate, add a second if-line with the new key.
# The LAN check reads $realip_remote_addr, the address that really connected: NPM replaces $remote_addr with the
# request's own X-Real-IP header for private peers, so $remote_addr could be forged. Let's Encrypt's renewal passes.
set $kvitt_gate 0;
if ($http_x_kvitt_app = "KVITT_APP_KEY") { set $kvitt_gate 1; }
if ($realip_remote_addr ~ "^192\.168\.1\.") { set $kvitt_gate 1; }
if ($uri ~ "^/\.well-known/acme-challenge/") { set $kvitt_gate 1; }
# Web client (SPEC §15, D-130): its files under /app/, the invite accept it logs in with, and any request carrying a
# bearer token (the server checks the token; unknown tokens are rate-limited per IP), except /health, /setup/, /i/
# and /apk/, which stay LAN-or-key only.
if ($uri ~ "^/app(/|$)") { set $kvitt_gate 1; }
if ($uri = "/invites/accept") { set $kvitt_gate 1; }
# Card connection (SPEC §9.12, D-163): the bank redirects the user's browser here, without the app key.
if ($uri = "/cards/callback") { set $kvitt_gate 1; }
set $kvitt_bearer 0;
if ($http_authorization ~ "^Bearer ") { set $kvitt_bearer 1; }
if ($uri ~ "^/(health|setup/|i/|apk/)") { set $kvitt_bearer 0; }
if ($kvitt_bearer = 1) { set $kvitt_gate 1; }
if ($kvitt_gate = 0) { return 444; }
# Server body: 1 MiB, PUT /me/picture 5 MiB (SPEC §8.6); the server answers anything larger with its own 413.
client_max_body_size 6m;
# No long-poll in the API (SPEC §14.3); 60 s between two reads from the server.
proxy_read_timeout 60s;
# Response headers (D-117). They live at server level, so no location below may use add_header (nginx would then
# drop all of these in it); NPM's HSTS line (SSL tab) sits at the same level. The server's own copy is hidden, so
# each header appears once (the landing page sends Referrer-Policy itself).
proxy_hide_header Content-Security-Policy;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header X-Frame-Options;
proxy_hide_header Referrer-Policy;
# The web client under /app/ (D-132) needs its own scripts, styles, images and API calls: one variable, still one
# server-level add_header.
set $kvitt_csp "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
if ($uri ~ "^/app(/|$)") { set $kvitt_csp "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; }
add_header Content-Security-Policy $kvitt_csp always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
# Replaces NPM's own "location /" (NPM leaves it out when this field has one), whose add_header X-Served-By would
# drop the headers above. $forward_scheme, $server and $port are NPM's Details tab values.
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass $forward_scheme://$server:$port$request_uri;
}
# Invite links /i/<code>?from=<name>: neither the code nor the inviter's name may reach a log (SPEC §8.7). NPM's
# access log format writes the whole $request_uri, query string included, so this path is not logged at all, and
# its error log (which would carry the request line on a 502) goes nowhere.
location ^~ /i/ {
access_log off;
error_log /dev/null crit;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass $forward_scheme://$server:$port$request_uri;
}
# Card connection callback /cards/callback?code=…&state=… (SPEC §9.12, D-163): the query carries a one-time
# authorisation code and the attempt's state, so this path is not logged at all, a 502 included.
location = /cards/callback {
access_log off;
error_log /dev/null crit;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass $forward_scheme://$server:$port$request_uri;
}
- Card callback (D-163, v0.35):
/cards/callbackpasses the gate from outside (the bank's redirect reaches it from the user's browser, a Custom Tab on mobile data included, which carries no app key) and is not logged by NPM (location = /cards/callback, as/i/), because its query holds the authorisationcodeandstate. The exemption tests the normalised$uriexactly, so no other path rides on it. The server answers it with its own page (E-39) and the D-117 headers apply (the page needs no script; its one link is an<a href>). Before the gate goes in, the line is left out with the other gate lines. K1 updatesinfra/npm/kvitt.confandinfra/README.mdstep 4 and repeats the nginx check (nginx -t, a request to/cards/callbackfrom a non-LAN address reaching the upstream, no access-log line). - Response headers (D-117):
Content-Security-Policy,X-Content-Type-Options,X-Frame-OptionsandReferrer-Policyare set at server level withalways, and the server's own copies are hidden withproxy_hide_header, so each header appears once (the landing page sendsReferrer-Policy: no-referreritself, AC-2.5). NPM's HSTS line from the SSL tab sits at the same level. - Its own
location /: nginx drops everyadd_headerinherited from the server level in a location that has anadd_headerof its own, and NPM's defaultlocation /has one (X-Served-By), which would silently drop the four headers. NPM leaves its default location out when the Custom Nginx Configuration containslocation /(NPM matches it with the regex^(?:.*;)?\s*?location\s*?\/\s*?{), so the block brings its own, withoutadd_header. Itsproxy_passuses NPM's Details values ($forward_scheme,$server,$port). If a later NPM release changes the proxy host template, the headers are checked again (§10.10). client_max_body_size 6m: above the server's own limits (1 MiB, 5 MiB forPUT /me/picture, §8.6), which answerpayload_too_largethemselves; larger bodies get NPM's 413.proxy_read_timeout 60s: the API has no long-poll.- Logs (§8.7): NPM writes one access log line per request to
/data/logs/proxy-host-<id>_access.loginside NPM, in itsproxyformat: time, cache and upstream status, status, method, scheme, host,"$request_uri"(the path with its query string),[Client <address>], length, gzip ratio,[Sent-to <KVITT_PI_HOST>], user agent and referer. It logs no request headers, so theAuthorizationtoken is never logged. Query strings are logged for every path except/i/; the API's other query parameters aresinceandlimit(GET /sync) andsizeandv(pictures), which hold no personal content.location ^~ /i/turns the access log off and sends its error log to/dev/null, so neither the invite code nor the inviter's name (?from=) reaches a log, a 502 included. Errors on other paths (for example a 502 while the server is down) go toproxy-host-<id>_error.logwith the request line. - App gate (§8.5, D-126):
set,ifandreturnare rewrite-module directives; at server level they run in NPM's server rewrite phase, before a location is chosen, so the gate covers every location,/i/included, and noadd_headeris involved.KVITT_APP_KEYis replaced by the key fromandroid/local.properties; a rotation adds a secondif ($http_x_kvitt_app = …)line.$realip_remote_addris the TCP peer: NPM'snginx.confhasset_real_ip_from10/8, 172.16/12 and 192.168/16 withreal_ip_header X-Real-IP, which would let a LAN client, or every client if NPM saw a Docker bridge address (security review I-5), choose$remote_addr. The Let's Encrypt exemption tests the normalised$uri, which location matching uses, so/.well-known/acme-challenge/../../healthis closed; that path is served by NPM's ownlocation ^~ /.well-known/acme-challenge/from its webroot, never proxied. NPM's own server-level rules come first in its template: Force SSL answers port 80 with a 301 tohttps://(Let's Encrypt follows it, which is why the exemption is needed on 443), and Block Common Exploits answers its patterns with 403. If NPM saw a Docker bridge address for every client, the LAN check would match nothing; if the router rewrote internet clients to its LAN address, everything; NAT hairpin makes a LAN client appear as the router's192.168.1.1, still inside the /24. The owner's acceptance test (infra/README.mdstep 4a): on the home Wi-Fihttps://<KVITT_PUBLIC_HOST>/i/TESTshows the page in a browser, with Wi-Fi off it gives a connection error, and the app's About screen still shows the server version. - Web client gate (D-130):
/appand/app/…andPOST /invites/acceptpass on the normalised$uri(so/app/../healthis closed); a bearer header passes through the helper variable$kvitt_bearer, which the next line clears for/health,/setup/,/i/and/apk/, because nginx'siftakes one condition. The value of the bearer header is not checked by NPM (the server does). Before the gate first goes in (step 4a), the gate lines fromset $kvitt_gate 0;toreturn 444;are left out together. - Content-Security-Policy (D-132):
$kvitt_cspholds the D-117 policy, and for/app/the web client's: its own origin for scripts, styles (inline styles allowed, as D-117 allows them), images (plusblob:for profile pictures fetched with the token anddata:), fonts and API calls;default-src 'none',base-uri 'none',form-action 'none',frame-ancestors 'none'as before. One server-leveladd_headerwith a variable keeps the rule that no location has anadd_header. The server's own/app/header (W7) is hidden like the others and should carry the same policy (it is the one the development stack shows). - Verified on the Mac on 2026-10-21 (D-130, D-132):
nginx -t(nginx:alpine 1.31.7) of the block above with a test key in a server block whose upstream is a stub, and 17 requests from a non-LAN address:/health,/syncwithout a header,/syncwithAuthorization: Basic,/invites/acceptx,/application,/app/../healthand, with a bearer header,/health,/i/TEST,/apk/latestand/setup/statuswere closed with no reply;/healthwith the key,/app,/app/,/app/login,POST /invites/accept,/accountswith a bearer header and the ACME path reached the upstream;/app/carried the web policy and/accountsthe D-117 policy, each once, with the other three headers. Not checked: NPM's own template andreal_ipsettings with these lines (the 2026-10-21 D-126 rehearsal covered them for the earlier lines), the real proxy host. - Verified on the Mac on 2026-10-21 (D-126): the block above, with a test key, inside a server block with NPM v2.16.0's
letsencrypt-acme-challenge.confandblock-exploits.confincludes and itsreal_ipsettings, run by the openresty 1.31.1.1 of thejc21/nginx-proxy-manager:2image (v2.16.0):nginx -tsuccessful; without the header, with a wrong key, with a forgedX-Real-IP: 192.168.1.50, on/i/and/apk/files/without the header, and on/.well-known/acme-challenge/../../health, the connection was closed with no reply; with the key/healthand/i/reached the upstream with the D-117 headers; an ACME token file was served; a second key line passed; a peer matching the LAN pattern passed without the header; with$remote_addrinstead of$realip_remote_addrthe forgedX-Real-IPpassed (why the block uses the latter). Not checked: HTTPS and HTTP/2 through NPM's real proxy host, the router and the real LAN addresses (the owner's acceptance test). - Verified on the Mac on 2026-10-19 (D-120):
nginx -t(nginx:alpine 1.31.7) of the block inside a minimal server block and inside a copy of NPM v2.16.0'snginx.confwith the proxy host rendered as NPM's template renders it for the §10.5.1 settings, both successful; a Podman rehearsal of the server image built fromserver/Dockerfile(arm64, hardened as on the Pi) behind that nginx with a self-signed certificate, 21 checks passed: the four headers andStrict-Transport-Securityonce each over HTTP/2 and noX-Served-By; oneReferrer-Policyon the landing page;http://answered 301 tohttps://; a 7 MB body refused by nginx (413), a 2 MB body answered by the server (payload_too_large); the rate limit applied per forwarded client and a forgedX-Forwarded-Fordid not escape it; the invite code, the name and/i/absent from nginx's access and error logs after/i/,//i/and/%69/requests and a 502 on/i/. Not checked (needs the real hosts): NPM's own UI and Let's Encrypt for the Kvitt host name, the router forward.
10.5.3 Fallback: NPM down
- NPM or the Home Assistant Pi down means Kvitt is unreachable from outside, and also from home over
https://<KVITT_PUBLIC_HOST>(that path goes through NPM too). The server on the Kvitt Pi keeps running and nothing there needs to change; the phones keep working on their full local copy and sync when NPM is back (FR-15). - On the home Wi-Fi only debug builds can reach the server directly, at
http://<kvitt-pi-address>:8080(AC-2.8 allowshttp://to private LAN addresses in debug builds only). Release builds require HTTPS with a publicly trusted certificate (§8.5): there is no LAN fallback for the release app. A debug build is a separate app (se.hagstedt.kvitt.debug) with its own pairing and data, not a stand-in for the release app. - If NPM cannot renew the Let's Encrypt certificate, it is fixed in NPM (SSL Certificates, the certificate, Renew Now; port 80 must reach NPM).
- The pinned-root switch of D-082 (
kvitt.tls.pinnedRoot=trueinandroid/gradle.properties; the pinned-root config is generated at build time from the templateandroid/app/src/releasePinnedRoot/network_security_config.xmlwith the domainkvitt.publicHost, into the release resources only whenkvitt.tls.pinnedRoot=true, adomain-configtrusting only@raw/kvitt_root_ca; the build stops with a message whileandroid/app/src/release/res/raw/kvitt_root_ca.crtis not committed, D-121,038c603) remains available for a self-made root whose certificate would be uploaded to NPM as a Custom certificate. It is not prepared: no such root exists and none is committed.
10.6 APK hosting and release
-
version_code=git rev-list --count HEADon the commit being released;version_name=YYYY.MM.DD-<short sha>of that commit (its commit date in Europe/Stockholm). Both are passed to Gradle as propertieskvitt.versionCodeandkvitt.versionNameby the release script; debug builds use1anddev. -
The release keystore is created once on the Mac (by the owner, or by an agent the owner allows; §11, D-024, D-101) at the root of the main checkout as
kvitt-release.jks, with exactly:keytool -genkeypair -v -keystore kvitt-release.jks -alias kvitt -keyalg RSA -keysize 4096 -validity 10000The file is git-ignored by the
*.jkspattern and never committed (D-101, revises D-024's "outside the repository").android/local.propertiesholds its absolute path and secrets in the keyskvitt.release.storeFile,kvitt.release.storePassword,kvitt.release.keyAlias(kvitt),kvitt.release.keyPassword. Losing it means the phones must uninstall and reinstall (data comes back by sync). The owner keeps a copy of the file and its password in their password manager. Without these keys inlocal.properties,:app:assembleReleasebuilds an unsigned APK (M8). Release signing was verified on 2026-10-18: aliaskvitt, RSA 4096, APK signature scheme v2. -
Release builds are R8-shrunk, optimised and obfuscated (
isMinifyEnabled,isShrinkResources,proguard-android-optimize.txtplusandroid/app/proguard-rules.pro, no project keep rules needed as of 2026-10-18; landedc30a49e); the signed APK is about 10 MB (10.0 MB ata0259ab; 43 MB before R8);infra/publish-apk.shsavesmapping.txtnext to the APK askvitt-<version_code>.mapping.txtfor retrace; mappings are never served and stay on the Mac, outside the Pi'sdata/(D-103, D-125). -
infra/publish-apk.shon the Mac (D-103): refuses a dirty working tree, readsKVITT_PUBLIC_HOSTfrominfra/pi/.env(read, not sourced; it refuses a missing file or key, aCHANGE_MEvalue, anything that is not a host name, a URL included, and the old nameicehack.asuscomm.com; landed4b1ae0c, D-122), builds:app:assembleReleasethroughtools/gradle-serial.shwith-Pkvitt.versionCode,-Pkvitt.versionNameand-Pkvitt.publicHost=<KVITT_PUBLIC_HOST>, prints the release serverhttps://<KVITT_PUBLIC_HOST>(D-124) in its header and summary, refuses an unsigned APK, computes SHA-256 and size, and writes intoinfra/pi/out/apk/(git-ignored)kvitt-<version_code>.apk,kvitt-<version_code>.mapping.txtandlatest.json(written to a temp file, then renamed), keeping the newest 5 APKs with their mappings there. The owner then copies, from the Mac, the APK first andlatest.jsonlast straight into the server'sdata/apk/on the Pi (scp infra/pi/out/apk/kvitt-<version_code>.apk jacobhagstedt@<kvitt-pi-address>:kvitt/data/apk/, then the same forinfra/pi/out/apk/latest.json), so the server, which readslatest.jsonon every request, never announces a file it does not have;curl -s http://<kvitt-pi-address>:8080/apk/latestthen shows the newversion_code(D-125;install-apk.sh, with its SHA-256 check againstlatest.jsonand its copy into the named volume, is removed). The mapping files stay on the Mac.latest.json:
{
"version_code": 412,
"version_name": "2026.11.02-1a2b3c4",
"file_name": "kvitt-412.apk",
"size_bytes": 18234567,
"sha256": "9f2c…64 hex chars…",
"released_at": "2026-11-02T19:40:00.000Z"
}
- The release server's host name is a build property (D-121):
kvitt.publicHostis read from the Gradle property (-Pkvitt.publicHost=…), else the keykvitt.publicHostinandroid/local.properties(the owner's, git-ignored), else the placeholderkvitt.example.invalid(RFC 2606; it never resolves, so a release built without the property cannot reach a real host). The build refuses a value that is not a host name.:appexposes it asBuildConfig.PUBLIC_HOST, as the manifest placeholderpublicHost(the AC-2.6httpsintent filter, no port, so it matches every port of the host, path prefix/i/,android:autoVerify="false", D-124) and asRELEASE_SERVER_URL=releaseServerUrl(host)=https://<host>(link/ReleaseServer.kt,releaseServerUrlin:feature:onboarding'sServerAddress.kt;RELEASE_SERVER_PORTremoved, D-124);:feature:onboardingreceives that URL through the Hilt qualifier@ReleaseServerUrl(§13.3 S-1), and:datathroughAppBuildInfo.releaseServerUrl, which S-22 shows before pairing (D-122). Invite-link parsing still accepts a server named by the link and drops an explicit default port (:443,:80) (AC-2.6, D-124). Tests and goldens use fixed fixture hosts.infra/publish-apk.shpasses-Pkvitt.publicHostfromKVITT_PUBLIC_HOSTininfra/pi/.env(above, §10.5, D-122); the owner may instead set the key inandroid/local.properties. Its siblingkvitt.appKey(D-126, next item) is read the same way. - The app key is a build property too (D-126, modelled on D-121):
kvitt.appKeyfrom the Gradle property (-Pkvitt.appKey=…), else the keykvitt.appKeyinandroid/local.properties, else empty (noX-Kvitt-Appheader sent);BuildConfig.APP_KEY(§8.5). A release APK carries the key it was built with, so the APK must be rebuilt after the key is set or rotated;infra/publish-apk.shneeds no change, because it builds on the Mac, whereandroid/local.propertiesholds the key. An APK without the key cannot reach the server from outside the home LAN once the app gate is in NPM, its update download included, so both phones run a keyed APK before the gate goes in (infra/README.mdstep 4a). Anyone holding the APK holds the key; the owner alone distributes it (first install on both phones by hand) and rotates the key if an APK leaks (§8.5). - With
--deploy(D-139) the script copies the APK first andlatest.jsonlast itself, with the forced-command deploy keyinfra/secrets/pi_deploy_ed25519(scp intodata/apk/only), and fails unlesshttps://<KVITT_PUBLIC_HOST>/apk/latest, requested withX-Kvitt-App, returns the newversion_code; the manual scp stays the fallback (infra/README.md"Publishing an APK"). - The server reads
latest.jsonon eachGET /apk/latest(no cache) and servesGET /apk/files/{file_name}only for afile_namematching^kvitt-[0-9]+\.apk$that exists, withContent-Type: application/vnd.android.package-archive. - Downloads are limited to 10 per minute per client IP (§8.6, D-119); E-5 stays on the 60 per minute of the other unauthenticated endpoints. If one address still fetches
/apk/files/over and over (NPM's access log), the owner's stop-gap is to disable Kvitt's proxy host in NPM (Hosts, Proxy Hosts, the row's menu, Disable) until it stops; the router's 443 forward stays, because it also serves Home Assistant (D-124,infra/README.md). Since the app gate (D-126) a request from outside without the app key never reaches the server, so this concerns the home LAN or a leaked key. - Older APKs stay in
data/apk/on the Pi until the owner removes them by hand (D-125;install-apk.shkept the newest 5, D-103);infra/pi/out/apk/on the Mac keeps the newest 5. - The web client (D-132) is part of the server image: it is published by deploying a server image (§10.10), with no step of its own here; a browser loads the new version on its next page load (
index.htmlis never cached, §15.4).
10.7 Firebase setup (M2c-dev, with a HUMAN sign-in)
No new command-line tool is installed for this (D-022): gcloud exists on the Mac, the Firebase CLI is not used, and the Firebase project and Android apps are created through the Firebase Management REST API (https://firebase.googleapis.com/v1beta1/) with the access token of gcloud auth print-access-token. The agent confirms the exact requests from Google's official API reference at implementation time.
- HUMAN:
gcloud auth loginon the Mac with the owner's Google account. gcloud config set project kvitt-1337;gcloud services enable fcm.googleapis.com firebase.googleapis.com.- Add Firebase to the project (
projects.addFirebase), skipped if it is already a Firebase project. - Create two Android apps (
projects.androidApps.create): display name "Kvitt", packagese.hagstedt.kvitt; display name "Kvitt debug", packagese.hagstedt.kvitt.debug. - Fetch each app's config (
projects.androidApps.getConfig) and merge them into onegoogle-services.jsonholding both clients, written toinfra/secrets/google-services.jsonin the main checkout (git-ignored). M8 copies it intoandroid/app/google-services.json(git-ignored) withinfra/dev/copy-google-services.sh <main checkout>, written by M2c-dev. gcloud iam service-accounts create kvitt-fcm --display-name "Kvitt FCM sender"; grantroles/firebasecloudmessaging.adminon the project;gcloud iam service-accounts keys create infra/secrets/fcm-service-account.json --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com.- Copy the key to
~/kvitt/secrets/fcm-service-account.jsonon the Pi, owned by the owner, mode 0600 (D-125; was/opt/kvitt/secrets/, mode 0440, root:10001, installed byinstall.sh, D-103). Done by the owner (infra/README.md"Pi: what the owner does", steps 2 and 3). - If an organisation policy blocks service-account key creation, stop and report; do not change org policies.
The agent may write these git-ignored files under infra/secrets/ in the main checkout and act on the Google Cloud project kvitt-1337 for steps 2 to 6 (D-022); nothing else outside the repository.
State after M2c-dev (c885aba): steps 1 to 6 are done. Firebase is added to project kvitt-1337; Android apps exist for se.hagstedt.kvitt and se.hagstedt.kvitt.debug; the APIs firebase.googleapis.com, fcm.googleapis.com, cloudresourcemanager.googleapis.com and iam.googleapis.com are enabled; the service account is kvitt-fcm@kvitt-1337.iam.gserviceaccount.com with role roles/firebasecloudmessaging.admin (a role named roles/firebasemessaging.admin does not exist); infra/secrets/fcm-service-account.json and infra/secrets/google-services.json (both clients) exist in the main checkout. infra/README.md documents the REST calls for repeating or inspecting the setup. Step 7 is part of the owner's install on the Kvitt Pi (checklist steps 2 and 3, D-125).
The FCM free tier is sufficient; no billing account is required.
10.8 Splitwise import
Command (run on the Pi with the server stopped, or on the Mac against a copy of the database):
kvitt-import --db /data/kvitt.sqlite --file <csv> --account "<Kvitt account name>" \
--user "<Splitwise name of the owner>=<owner user id or display name>" \
--user "<Splitwise name of the partner>=<partner user id or display name>" \
[--fx <CUR>=<SEK per unit>]... [--dry-run]
- One CSV file per Splitwise group (Splitwise's group "Export as spreadsheet"). The account is created with default 50/50 if no non-deleted account with that name exists; otherwise rows go into the existing one, and the existing account's stored name (not the spelling given to
--account) is the account name used in the import key (D-046). --user(exactly two, distinct Splitwise names, one per household user) maps a member column to a Kvitt user given as a user id or as a display name that exactly one user has; anything else is a usage error (D-046).- The importer never creates or migrates a database:
--dbmust name an existing file whosemeta.schema_versionis the version the server's migrations produce (3since D-090;2from D-054), else it exits 1 (runkvitt-server migratefirst) (D-046, D-054). - The whole run is one
BEGIN IMMEDIATEtransaction: find or create the account, skip rows whoseimport_keyexists, validate and insert the rest with the server's own push checks, write the audit entries, then reconcile (below). It commits only when every row is valid, the reconciliation is accepted and--dry-runis absent; otherwise it rolls back and nothing is written. A dry run runs the same transaction and rolls it back (D-046). - Exit status (D-046):
0imported (or, with--dry-run, would import);1database or file error;2usage (bad arguments,--usernot resolving to the two household users);3the file cannot be imported (header, invalid rows, non-SEK rows without--fx);4reconciliation mismatch or missing totals row. Nothing is written unless the status is0and--dry-runis absent. - Header (confirmed against both real exports, D-003; the exact header lines are recorded in PLAN-M9, not here): Splitwise localises the export, so two variants exist and the importer must accept both. English:
Date,Description,Category,Cost,Currency; Swedish:Datum,Beskrivning,Kategori,Kostnad,Valuta. Then one column per group member, named by the member's full Splitwise name, holding that member's net effect for the row (positive = paid more than their share). The member columns appear in different orders in different files, so the importer reads member names from the header and matches them to the--userarguments; it never relies on column positions. Any other header fails the import with a clear message, writing nothing. - Fields containing commas are double-quoted (CSV quoting). The file ends with an empty line followed by a row whose
DescriptionisTotal balance(English) orTotalsumma(Swedish), the totals row, holding each member's balance; it creates no row and is used only for the reconciliation. Blank rows are ignored. - Reconciliation (D-045): after the rows are written, inside the transaction, each user's balance of the whole account (
net(u), §9.3, over every non-deleted row of the account, not only the imported ones) must equal the sum of that member's values in the file's totals rows. One totals row is allowed per currency; a non-SEK totals row is converted with the--fxrate like any other value. A mismatch, or a file without a totals row, rolls the run back and exits4; a dry run reports the same. Importing into an account that already holds other rows therefore rolls back, since those rows enter the comparison. When any--fxconversion took place, per-value rounding can make an exact match impossible, so a mismatch is reported in the summary but accepted. A re-run of an already imported file skips every row and reconciles again on the same balance. DateisYYYY-MM-DD;Costand member columns are decimal numbers with.as separator, converted to öre exactly by string parsing.- For each expense row with members A and B and cost C (öre): the payer is the member with positive net. If
net_A > 0: payer A,share_B = net_A,share_A = C − net_A, modeexact. Symmetric for B. If both nets are 0: payer is the owner, modefullon the owner (the owner pays and bears the whole cost, D-046). Ifnet_A + net_B ≠ 0or a resulting share is negative, the row is reported as invalid and the import aborts (nothing written). - Rows whose
CategoryisPayment(English) orBetalning(Swedish) become settlements:from= the member with positive net,to= the other, amount = that net, one allocation to the account,note= the Splitwise description truncated to 200 code points (D-046). - Rows whose
Currencyis notSEKabort the import unless a--fx <CUR>=<rate>is given for that currency; then every amount is converted withround_half_up(amount_ore × rate)per value, and for expenses the exact shares are recomputed asshare_payer = C' − share_nonpayer'. import_key= lowercase hex SHA-256 ofsplitwise|v1|<account name>|<Date>|<Description>|<Category>|<Cost>|<Currency>|<member nets in header order, joined with |>|<n>where<n>is the 0-based occurrence index of an identical line earlier in the same file. Rows whose key exists are skipped. Idempotency uses the existingimport_keycolumns ofexpensesandsettlements(§14.1); no new column or table (D-046).- Every created row:
created_by_user_id=updated_by_user_id= the owner;updated_by_device_idnull;created_at=updated_at= the import time; one audit entrycreatewithsource=import; newserver_versions from the global sequence; no pushes. - Description is truncated to 100 code points; the full original goes into
noteswhen truncated. An empty description, or one over 1000 code points (thenoteslimit, §9.6), makes the row invalid (exit3, nothing written) (D-046). - Category mapping (case-insensitive exact match on the
Categorycolumn, English and Swedish names, D-003). The importer commits this table as the data fileserver/crates/kvitt-import/data/splitwise_categories.csv(columnssplitwise_name,language,default_key) and reads it from there; it is the only mapping. Splitwise categories are mapped as finely as the fourteen categories of AC-7.2 allow (D-053); the Swedish names are those observed in the real Swedish export, each placed with its English Splitwise equivalent (D-044, D-053):
| Splitwise category (English) | Splitwise category (Swedish) | Kvitt default_key |
|---|---|---|
| Groceries, Food and drink - Other, Liquor | Livsmedel, Mat och dryck - Övrigt, Alkohol | groceries |
| Dining out | Restaurangbesök | restaurants |
| Rent, Mortgage | Hyra, Avbetalning/Amortering | housing |
| Electronics, Furniture, Household supplies, Maintenance, Services, Pets, Home - Other | Elektronik, Möbler, Förbrukningsvaror, Skötsel/underhåll, Tjänster, Husdjur, Hem - Övrigt | home |
| Electricity, Heat/gas, Water, Trash, TV/Phone/Internet, Cleaning, Utilities - Other | El, Värme/gas, Vatten, Sophämtning, TV/telefon/internet, Städning, Verktyg - Övrigt | utilities |
| Insurance | Försäkringar | insurance |
| Bicycle, Bus/train, Car, Gas/fuel, Parking, Taxi, Transportation - Other | Cykel, Buss/tåg, Bil, Bensin/bränsle, Parkering, Taxi, Transport - Övrigt | transport |
| Hotel, Plane | Hotell, Flyg | travel |
| Games, Movies, Music, Sports, Entertainment - Other | Spel, Filmer, Musik, Sport, Underhållning - Övrigt | entertainment |
| Medical expenses | Sjukvård/medicin | health |
| Childcare | Barnomsorg | children |
| Clothing, Gifts | Kläder, Presenter | shopping |
| Taxes | Skatter | fees |
| General, Education, Life - Other, Other | Allmänt, Utbildning, Livet - Övrigt | other |
| any other value | any other value | other, counted as unmapped |
The Swedish names El, Värme/gas, Vatten, Sophämtning and Husdjur were not seen in the export; they are Splitwise's Swedish labels by analogy (D-053). A wrong guess only leaves such a row unmapped (other, counted), which the summary shows.
- The summary lists each unmapped category name with its row count, and the total count of unmapped rows as a warning.
Production run (M11, D-025), in this order:
- On the Pi in
~/kvitt(D-125), take a backup first:docker compose exec backup /backup.sh(it ends withbackup: ok <stamp>once both files are confirmed on Drive withrclone lsf). - Resolve the two production user ids by display name from the database (a read-only
SELECT id, role, display_name FROM usersthrough thebackupcontainer'ssqlite3); the owner is theadminuser. The ids go into the--userarguments (an exact, unique display name is also accepted, D-046). - Copy the CSV files from the Mac to the Pi into
~/kvitt/import/(/home/jacobhagstedt/kvitt/import/;scp <file> jacobhagstedt@<kvitt-pi-address>:kvitt/import/), which is bind-mounted read-only intoserveras/import(§10.2, D-125; was/opt/kvitt/import/). docker compose stop server; for each file rundocker compose run --rm --entrypoint kvitt-import server --db /data/kvitt.sqlite --file /import/<file> --account "<name>" --user "…" --user "…", first with--dry-run, then without; each must exit0with the reconciliationmatch(D-045); the target accounts must not exist yet or be empty;docker compose start server; check/health. (--entrypointis needed because the image's entrypoint iskvitt-server, §10.3.)- Delete the CSV files from
~/kvitt/import/.
10.9 Backups and restore
Backup (infra/backup/backup.sh, daily 03:30 Europe/Stockholm, also runnable on the Pi in ~/kvitt as docker compose exec backup /backup.sh, which ends with backup: ok <stamp> when the run succeeded; backup-now.sh is removed, D-125):
sqlite3 /data/kvitt.sqlite ".backup /backups/staging/kvitt.sqlite"(online, consistent).sqlite3 /backups/staging/kvitt.sqlite "PRAGMA integrity_check"must printok, else abort with a log line.gzip -9to/backups/kvitt-<UTC yyyyMMddTHHmmssZ>.sqlite.gz;tar -czf /backups/pictures-<same stamp>.tar.gz -C /data pictures.- For every pending
kvitt-*.sqlite.gzandpictures-*.tar.gzin/backups(this run's pair and any leftovers of an earlier failed run):rclone copy <file> "${RCLONE_REMOTE}" --drive-root-folder-id "${RCLONE_FOLDER_ID}" --immutable(copy only; neversync, never delete, never overwrite), then confirm it by name and size withrclone lsf(D-106). rclone uses a temporary copy of the read-onlyrclone.conf(D-105). The snapshot is the whole database, so it includes the card tables of migration 0004 (connections with their Enable Banking session ids, purchases, attempts; D-161): the backups on Drive therefore hold the users' card purchases, unencrypted like the rest (accepted risk M-1, §8.1). The Enable Banking private keys are insecrets/cards/, notdata/, and are never backed up; a restore needs them copied again from the Mac (infra/secrets/cards/). - Only after every file is confirmed are the local copies deleted from the Pi's
backups/folder (D-002), and only then is/backups/last-successwritten with the stamp (D-106). A failed upload or confirmation keeps the local files and leaveslast-successunchanged; the next good run uploads the leftovers. Nothing on Drive is ever deleted. WhenRCLONE_FOLDER_IDis empty (the development stack, D-001), step 4 is skipped with a log line, the local files are kept andlast-successis written.
rclone setup (HUMAN, once, done 2026-10-18): the owner ran rclone authorize "drive" and signed in; the token (infra/secrets/rclone-drive-token.json) became infra/secrets/rclone.conf with a [gdrive] remote (type = drive, scope = drive, token), both git-ignored. The owner copies it to ~/kvitt/secrets/rclone.conf on the Pi, mode 0600 (§10.2, D-125); the folder id stays in .env as RCLONE_FOLDER_ID, not in the conf. The Drive destination root folder id is 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV (OQ-4, answered); it is the value of RCLONE_FOLDER_ID in the Pi's .env.
Restore (step by step in infra/RESTORE.md, run on the Pi in ~/kvitt as the owner, D-125). The files in data/ and backups/ are the owner's (both containers run as the owner's uid), so they are moved on the host with plain mv, cp, gunzip and tar; only rclone (listing and downloading from Drive, on a temporary copy of rclone.conf) and sqlite3 (PRAGMA integrity_check) run in the running backup container. Nothing is deleted. Not around 03:30, when the nightly backup runs.
docker compose stop server.- Download the chosen
kvitt-*.sqlite.gzandpictures-*.tar.gzfrom Drive intobackups/restore/(rclone in thebackupcontainer, or the browser andscpfrom the Mac). gunzipon the host;docker compose exec -T backup sqlite3 /backups/restore/<file> "PRAGMA integrity_check"must printok.- Move the current
data/kvitt.sqlite,-wal,-shmanddata/picturesaside intodata/pre-restore-<UTC stamp>/(never delete), copy the restored file todata/kvitt.sqlite, extract the pictures intodata/. docker compose run --rm server rotate-epoch.docker compose start server;docker compose ps serverhealthy;curl -fsS http://localhost:8080/health.- Open the app on both phones; they detect the new epoch and re-push their data (§14.4.8). Check the balances match on both phones.
- Afterwards,
docker compose exec backup /backup.shonce puts the restored state on Drive.
10.10 Operations
- Where: every command on the Pi in this section is run by the owner in
~/kvitt(/home/jacobhagstedt/kvitt) with plaindocker compose(D-125, revises D-103's scripts); the owner's operations table is ininfra/README.md"Pi: what the owner does", Operations. Agents prepare and check the project on the Mac and never connect to the Pi (D-100). - Status and logs:
docker compose ps;docker compose logs --tail 100 <service>; NPM's own log files are inside NPM on the Home Assistant Pi (§10.5.2). - Disk:
df -h ~anddu -sh ~/kvitt/*. SQLite is a few MB; pictures under 1 MB; APKs about 10 MB each since R8 (§10.6), older ones removed by hand. - Deploy (upgrade), after
infra/build-images.sh v1.0.Non the Mac pushed the images (D-211; or the Gitea workflow, the fallback, pushed av1.0.Ntag):docker compose exec backup /backup.shfirst, continuing only afterbackup: ok, on every deploy, because the Pi cannot tell beforehand whether a tag carries a migration (D-103, kept by D-125); then setKVITT_TAGin.env(sed -i 's/^KVITT_TAG=.*/KVITT_TAG=v1.0.N/' .env, noting the previous tag),docker compose pull && docker compose up -d, and checkdocker compose psandcurl -s localhost:8080/health. Migrations run on start; a failed migration exits non-zero and the container restarts in a loop. - Rollback: set the previous
KVITT_TAGin.envanddocker compose pull && docker compose up -d(noprevious-tagfile and no--skip-backupsince D-125). Migrations are forward-only, so if a migration had partially applied, restore the pre-deploy backup (§10.9,infra/RESTORE.md); sqlx runs each migration in a transaction, so this should not happen. - Bootstrap and invites:
docker compose exec server kvitt-server bootstrap(first install only: the admin invite code, link and expiry of FR-1); once the admin is paired, the partner's invitedocker compose exec server kvitt-server invite-partner; an extra devicedocker compose exec server kvitt-server pair-device --user <user_id>(§10.4). - Memory check:
docker stats --no-streamafter the release milestone; server RSS must be under 100 MB with both phones syncing. - Image digests (D-117 point 5, revised by D-125): the Pi keeps no digest record. After a pull,
docker image inspect --format '{{.RepoDigests}}' gitea.lan/jacobh2/kvitt-server:<tag>(andkvitt-backup) prints thesha256:digest, which the owner compares with the workflow's "Print image digests" step for that run (§10.3); the registry is plain HTTP on the LAN (security review L-9), so a mismatch is investigated before the stack is trusted. - Client IPs (once, after the NPM proxy host and the router check, D-117, D-120, D-124): with the phone off Wi-Fi, open the app's About screen (S-22; before the app gate of D-126,
https://<KVITT_PUBLIC_HOST>/healthin Chrome does as well), then within a minute on the Kvitt Pisudo conntrack -L -p tcp --orig-port-dst 8080(packageconntrack, installed once): in every line the firstsrc=isHA_HOSTand the reply half (from the secondsrc=) hasdst=<HA_HOST>with no172.*address, so the server sees NPM's address and trusts itsX-Forwarded-For; a172.*address there means every internet client shares one per-IP limit of §8.6 (security review I-5; the commands are ininfra/README.md's operations table). - Health from outside (D-126): the app's About screen (S-22) on the phone with Wi-Fi off shows the server version, or
curl -fsS -H "X-Kvitt-App: $KEY" https://<KVITT_PUBLIC_HOST>/healthfrom outside the home network; without the header the connection is closed (§8.5). - Card purchases (D-175, K1 writes the runbook entry in
infra/README.md): the private key files live in~/kvitt/secrets/cards/(mode 0600, the folder 0700), copied frominfra/secrets/cards/on the Mac; the application ids in.env. Status:docker compose logs server | grep card_fetchshows the aggregate lines of §8.7. Every 180 days at most (CP-6) each user renews with BankID in the app; nothing on the Pi changes. - Headers through NPM (after the first setup and after every NPM upgrade):
curl -sS -D - -o /dev/null --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/healthlistscontent-security-policy,x-content-type-options,x-frame-options,referrer-policyandstrict-transport-securityonce each and nox-served-by(§10.5.2).
11. Milestones
Each milestone is one plan (PLAN-<id>.md, written by the orchestrator's plans agent from this section) run by one Opus agent in its own worktree, unless the row says otherwise. "Gate" is what must pass before landing. The Android gate is tools/gradle-serial.sh check; the server gate is cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test in server/; the web gate (D-131, §15.3) is npm run check in web/; "both gates" means the Android and server gates. HUMAN steps are what only the owner can do.
11.1 Dependency graph
M0 spec ──┬─► U1 directions ─(owner picks)─► U2 design spec ─► §9.7 colours ─► M6 design ─► M7a..M7f features ──┐
│ │
├─► M1 contracts + vectors ─┬─► M2a server skeleton + domain ─┬─► M3 server core API ───────────┐ │
│ │ ├─► M4 server sync/audit/push ─┬──┤ │
│ │ │ └──┼─► M9 importer
│ └─► M2b android skeleton + domain ┴─► M5 android data layer ───────┼───────┤
│ │ ▼
└─► M2c-dev infra for development (Mac compose, Firebase, local backup) ─────────────────────┴──► M8 app integration
│
M2c-pi infra on the Pi (Pi, TLS, Gitea, router, Drive) ◄─────┤
M10 first release ◄── M2c-pi, M8 ◄──┘
M11 import real history ◄── M9, M10
Category contract v2 (D-054), after SPEC v0.8:
M12a contracts + :domain suggestCategory (landed aa765bb) ─┬─► M12b server (migration 0002, importer table; landed 6eaccec)
└─► M12c android (:core:model, :domain, :data, fakes; landed b09f322) ─► M6, M7c
M12d UI contract additions (D-059: frequentCategoryIds, deletable; landed 557af7e) ─► M7a..M7f (ran in parallel with M6, landed 7730a31)
After the M7 wave (v0.11):
M7a..M7f (landed) ─► M6b core:design fixes (landed b24c8b5) ─┬─► M8 app integration (landed 3d9eea8)
└─► M7g feature cleanups (landed 3dda414)
After M8 (v0.13):
M8, M7g (landed) ─► M12e contract pass (landed 8c60715 and b7b7483; D-086 to D-095, server schema 3)
After the first phone test (v0.15):
M8 phone test (2026-10-18) ─► M8b UI fixes (landed 328c466, D-097) ─► M2c-pi bundle (D-100), last, after the app is proven ─► M10
After v0.15 (v0.16):
M8b ─► M8c goldens and About (landed 75b549f, a0c25b0, 7c453d8; D-098, D-099) ─► M8d editor rows (landed bacbd18, D-102) ─► M8e (D-107)
R8 release build (landed c30a49e)
After v0.16 (v0.17):
M2c-pi bundle (landed 2476667, D-103 to D-106) ─► owner runs the bundle on the Kvitt Pi ─► M10
M8e S-19 option rows (landed a59abeb, D-107) ─► chips cleanup (landed 1328afe)
M8f instant navigation (landed 956908a, D-108)
After v0.17 (v0.18):
history rewrite and first push (18c527f) ─► infra overhaul (2fac1ec..e70fe0d) ─► server gap pass (landed 8660b7f, D-113)
security review (18c527f) ─┬─► infra security (landed 7fb86f5, D-117)
AC-coverage audit (956908a) ─┼─► M12f Android follow-ups (landed dc7a296..a0259ab, D-109 to D-112, D-116)
└─► server overhaul (landed 8103dae..b985ce9; D-114, D-115) ─► router forward ─► M10
After v0.18 (v0.19):
M12f ─► Android overhaul (landed e4e6e5d..c582363, D-118) ─► push to origin (c582363) ─► owner runs the M2c-pi bundle ─► M10
Web client programme (v0.28, D-129 to D-134):
SPEC v0.28 ─┬─► W0 web foundation (web/**) ──────────┬─► W1 home, accounts ─┐
│ ├─► W2 expenses, search, export
│ ├─► W3 settle up ├─► image build and deploy (§10.10)
│ ├─► W4 analytics, insights (W-11 waits for W7)
│ └─► W5 settings ───────┘
└─► W7 server: /app/ serving, /analytics/* (D-135..D-138, v0.29) ─┬─► W4's W-11
└─► W6 Android Insights
Card purchases (v0.35, D-158 to D-176; K0 done, v0.36; K1 landed `671c00d`, its report recorded in v0.38, D-181 to D-188; K2 landed `a8f91ee`, its report recorded in v0.39, D-191 to D-200; K3 landed `faeb752`, its report recorded in v0.40, D-201 to D-207):
SPEC v0.35 ─► owner steps (Enable Banking account, ToS, application, key, linked cards) ─► K0 local test app on the Mac
─► K0 report ─► SPEC v0.36 (K0's answers, D-177 to D-180) ────────┬─► K1 server (schema 4, adapter, E-36..E-44, poller, NPM text) ──┐ lands first
├─► K2 Android (:feature:cards, Room 2, Kortköp tab) ──────────┼─► lands after K1
└─► K3 web (W-21..W-23) ────────────────────────────────────────┘ lands after K2 (card strings)
After M1, M2a and M2b land, these run in parallel without touching each other's files (§14.7): M3, M4 (lands after M3, D-015), M5, M2c-dev, M6 (once U2 is approved and the §9.7 colour change from U2's proposal has landed in this spec), M7a to M7f (once M6 lands), M9 (once M4 lands). M2c-pi runs after M8 and before M10 (D-001), as the last step once the app is proven on the phone against the development server (D-100). Group M12 "Category contract v2" (D-049, D-054): M12a first; then M12b and M12c in parallel; M6 and M7c wait for M12c (all three landed: aa765bb, 6eaccec, b09f322). M12d (D-059) ran in parallel with M6 (landed 557af7e and 7730a31); M7a to M7f ran in parallel and landed on 2026-10-18 (1ea752c, 0e1d99d, 0b140aa, 7380e50, ea7d494, c4ab35f), followed by M6b, the :core:design fix pass (b24c8b5). M8 and M7g (feature cleanups) ran in parallel (disjoint paths: android/app/** and android/adapter/fcm/** against android/feature/**) and landed on 2026-10-18 (3dda414; cb832a7 and 3d9eea8). The contract pass M12e (D-086 to D-095) ran after them and landed on 2026-10-18 (8c60715 contract pass, b7b7483 launcher icon). The M2c-pi bundle (2476667, D-103 to D-106), M8e (a59abeb, D-107) and M8f (956908a, D-108) landed on 2026-10-18; what remains of M2c-pi is the owner running the bundle, and M10 waits for it.
11.2 Milestone table
| Id | Name | Deliverable | Gate | HUMAN | Depends on |
|---|---|---|---|---|---|
| M0 | Spec v0.1 | SPEC.md, filled CLAUDE.md, repository bootstrap |
none (docs only) | Review the spec; answer §12 | none |
| U1 | UX directions | Landed 6da6b1b (direction 3 "Kalas" muted picked, D-047). §13.4 phase 1 files in docs/ux/ |
Files exist and open; each direction shows the six phase-1 screens of §13.4 (S-4, S-7, S-5, S-9, S-11, S-8) in light and dark; Swedish copy, no placeholder text | Review 2-3 directions, pick one, give comments | M0 |
| U2 | UX design spec | Landed 6da6b1b, approved by the owner: docs/ux/design-spec.md (binding, §13.4), strings.csv (558 rows), screens/; UX-9 (D-059). §13.4 phase 2 files in docs/ux/ |
Every string key has en and sv; every screen has every state that design-spec.md lists for it (§13.3) |
Approve the design spec | U1 and the owner's pick |
| M1 | Contracts and vectors | contracts/openapi.yaml, contracts/schema/server-v1.sql, contracts/test-vectors/*.json, contracts/examples/**/*.json, tools/contracts-check.py |
python3 tools/contracts-check.py passes: all JSON parses, every vector file matches §14.5.5, a stdlib-only Python reference of §9.1, §9.3, §9.4 reproduces every expected value, every example validates against the shapes of §14.3 by key set |
none | M0 |
| M2a | Server skeleton and kvitt-domain |
Cargo workspace, three crates, config, DB pool, migration 0001_init.sql byte-identical to contracts/schema/server-v1.sql, error type with every §14.3.2 code, auth extractor (§8.2), all routes of §7 registered with handlers returning 501 not_implemented, utoipa document equal to contracts/openapi.yaml, GET /health and GET /setup/status implemented, kvitt-domain passing all vectors plus next_occurrence_date, server/Dockerfile; shell files in M3, M4 and M9 paths (D-004); the seams of §6.3 (D-015) |
Server gate (includes the OpenAPI match test, the migration identity test, the vector tests) | none | M1 |
| M2b | Android skeleton and :domain |
Gradle project in android/ with every module of §6.2, version catalog, architecture check, :core:model types and repository interfaces of §14.2.4, :domain passing all vectors (including analytics), :core:fakes with the empty scenario (§6.2, D-018) for each repository, the Room 3 entities of §14.2.1 and §14.2.2 and KvittDatabase version 1 in :data with the exported schema (D-012), screenshot tests with Roborazzi (D-010), module shells for every module with one empty entry composable per feature (D-004), the feature convention of §6.2 in build-logic (D-017), :app launching to an empty themed screen, :app string files with the app name only in both languages, lint MissingTranslation as an error in every module |
Android gate | Plug the phone in once to confirm installDebug works |
M1 |
| M2c-dev | Infra for development (D-001, D-022) | Landed c885aba. infra/dev/compose.yaml (server built from server/Dockerfile, no Caddy, no TLS, bind-mounted data, infra/secrets/ mounted read-only at /secrets, backup runnable on demand), infra/dev/.env.example, the dev scripts in infra/dev/ (build, up, logs, backup, copy-google-services.sh), infra/README.md (dev stack and Firebase), infra/backup/{Dockerfile, backup.sh} (Drive upload skipped when RCLONE_FOLDER_ID is empty), Firebase project and apps through the REST API (§10.7), service-account key and google-services.json in infra/secrets/ |
docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example config succeeds; shellcheck $(find infra -name '*.sh') clean; on the Mac (with the M2a image): GET /health answers 200 from the Mac; one local backup run produces a snapshot that passes PRAGMA integrity_check |
gcloud auth login; on the phone on the home Wi-Fi, open the dev server's /health and answer yes/no that it shows "status":"ok" |
M0 (uses the M2a image for the health check when available) |
| M2c-pi | Infra on the Pi (D-001, D-100, D-120, D-125) | Bundle landed 2476667 2026-10-18 (D-103 to D-106); the NPM rework (D-120: 4f69ae0 workflow login, d05f13a stack without Caddy and cert-sync, 62f5279 and 9231a1d README) landed 9231a1d 2026-10-19; port 443 (D-124: 4852803 bundle) landed 2026-10-19; the sparse-checkout workflow (3820f9c, 2026-10-19), the .env without the informational keys (200c99f, 2026-10-20) and the self-contained compose project (D-125, 6e5981d, 2026-10-20) landed. The NPM proxy host for kvitt.suorra.duckdns.org is Online with its Let's Encrypt certificate (owner, 2026-10-19); the Gitea workflow built and pushed v1.0.0 (run 69) and v1.0.1 (§10.3); the owner is installing on the Kvitt Pi (2026-10-20). Deliverable: a self-contained compose project in infra/pi/ (compose.yaml, .env.example, .gitignore) that the owner copies to /home/jacobhagstedt/kvitt on the Kvitt Pi and runs with plain docker compose commands (D-100, D-125): server and backup as the owner's uid, the seven .env keys, relative bind mounts ./data, ./backups, ./import (§10.2); a README checklist for the owner (infra/README.md "Pi: what the owner does": prerequisites, .env and secrets on the Mac and the copy, start, the NPM proxy host and its Custom Nginx Configuration of §10.5, the router check (external 443 → Home Assistant Pi 443, no new rule, D-124), from outside, first backup and bootstrap, deploy), infra/RESTORE.md, Drive backups to folder id 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV, Gitea Actions image build from the owner's example workflow in .gitea/workflows/ |
On the Mac: docker compose -f infra/pi/compose.yaml config succeeds with the env template (D-103); shellcheck $(find infra -name '*.sh') clean. Green at D-125 (2026-10-20): compose config valid; a Podman rehearsal of the project folder as uid 501 (not 10001): both containers healthy with read-only roots, /health 200 with db_ok: true, the database files and data/apk owned by 501 on the host, kvitt-server bootstrap through exec, an APK and latest.json copied into data/apk/ served at once, /backup.sh in three modes (no folder id: files kept, last-success written; a local remote: uploaded, confirmed, local copies deleted; a failing remote: files kept, last-success unchanged), the RESTORE.md steps ending with /health 200. Still valid from the NPM rework: nginx -t of the §10.5.2 block against NPM v2.16.0's templates and the 21 checks of the server behind nginx (§10.5.2). Not verifiable from the Mac: NPM's UI and Let's Encrypt for the Kvitt host name, the router forward, pulls from gitea.lan, Docker Compose itself (create_host_path: false, which Podman ignores), Drive. On the Pi, run by the owner from the checklist: docker compose ps shows both services healthy and curl localhost:8080/health answers ok; https://<KVITT_PUBLIC_HOST>/health answers from the phone on mobile data without a certificate warning; one backup pair appears in the Drive folder |
Fill infra/pi/.env on the Mac (HA_HOST; check KVITT_UID/KVITT_GID and KVITT_TAG), copy compose.yaml, .env and secrets/ to ~/kvitt on the Kvitt Pi and run the seven steps of infra/README.md "Pi: what the owner does" (prerequisites, Mac, start, NPM, router check, from outside with first backup and bootstrap, deploy), answering yes/no per check. Done: the Drive token (rclone.conf), the example workflow, the Gitea secret kvitt_cicd (renamed by the owner 2026-10-19; run 69 succeeded), the Kvitt host name kvitt.suorra.duckdns.org and its NPM proxy host (2026-10-19), Docker and the registry trust on the Pi (prerequisites). No allowances for agents on the Pis, Home Assistant, NPM, Gitea or Drive (D-100) |
M2c-dev, M8; runs last, after the app is proven (D-100) |
| M3 | Server core API | Landed 7869ec4 (D-030 to D-034). E-3 to E-30 and E-33 implemented (§14.3; E-2 is M2a's, M3 tests it), invite landing page, pictures, rate limiting (src/api/rate_limit.rs), bootstrap, pair-device, the recurring generation function of §6.3 (D-015) |
Server gate; integration tests per endpoint and per error code listed for it; every generation case of §9.5 (catch-up, clamping, split resolution, idempotency, resume without backfill, next_occurrence_date) |
none | M2a |
| M4 | Server sync, audit, push, scheduler | Landed 15056dc (D-035 to D-039). E-31, E-32, the real AuditWriter (writer and diff) and PushQueue (FCM sender), src/tasks/** starting them, sync_mutations pruning daily at 04:00 (D-021), scheduler, rotate-epoch |
Server gate; integration tests for every sync rule of §14.4 and the scheduler cases of §9.5 (run at start, daily 06:00 on DST days, catch-up through M3's generation function); FCM sender tested against a local stub HTTP server | none | M2a; lands after M3 (D-015) |
| M5 | Android data layer | Landed 8f0e156 (D-026 to D-029, D-040 to D-043). DAOs on M2b's Room 3 entities and KvittDatabase v1 (§14.2, D-012; any change to the entities or the exported schema is a contract change, §14.8), Ktor client and DTOs, token store, sync engine and workers, real repositories, APK updater |
Android gate; DTO round-trip tests over every file in contracts/examples/; sync engine tests with Ktor MockEngine covering §14.4 including the 14-day offline case (AC-15.6) |
none | M1, M2b |
| M12a | Category contract v2: contracts | Landed aa765bb. contracts/category-keywords.csv (148 entries), contracts/schema/migrations/0002_categories_v2.sql (§14.1.1), contracts/test-vectors/suggest_category.json (65 cases), E-1 example schema_version 2, tools/contracts-check.py (keyword-table rules, 0002 loaded on a fresh and an in-use v1 database, Python reference of §9.11, new icon and colour tokens), and :domain's suggestCategory with UnicodeText passing the vectors (§14.5.6). The OpenAPI DefaultCategoryKey extension and the new category examples were held back for M12b |
python3 tools/contracts-check.py passes |
none | M1, SPEC v0.8 |
| M12b | Category contract v2: server | Landed 6eaccec (D-057, D-058). Migration server/crates/kvitt-server/migrations/0002_categories_v2.sql byte-identical to the contract file with its identity test; SCHEMA_VERSION 2; seed test of the fourteen defaults (ids, keys, icons, colours, sort orders, server_version from the sequence) on a fresh database and on a v1 database holding expenses (rows and references kept); icon and colour lists of §9.7 in validate.rs; DefaultCategoryKey with the fourteen keys in contracts/openapi.yaml (held back by M12a) and in the utoipa wire types, so the OpenAPI match test passes; the category examples M12a held back; importer table splitwise_categories.csv per §10.8 with its spec-parity test; importer schema check 2 (D-054); E-31 and other examples only where they enumerate the defaults |
Server gate; python3 tools/contracts-check.py passes |
none | M12a, M3, M4, M9 |
| M12c | Category contract v2: Android | Landed b09f322 (D-055 to D-057). :core:model: DefaultCategoryKey (14), ColorToken (SLATE, AMBER), CategoryIcons.ALL (33), ExpenseRepository.suggestCategory; :data: the UnicodeText binding (java.text.Normalizer, Character), suggestCategory from Room, DataStore and the keyword table copied from contracts/category-keywords.csv at build time (D-055; a test asserts byte identity and the §14.8 rules), parsed by :domain's parseCategoryKeywords (D-056); :core:fakes: fourteen defaults in the empty scenario and a fake suggestCategory; default-name string resources for the four new categories in English and Swedish (AC-7.2) where default names already live |
Android gate | none | M12a, M2b, M5 |
| M12d | UI contract additions (D-059) | Landed 557af7e (D-060 to D-062). :core:model: ExpenseEditorOptions.frequentCategoryIds and CategoryRepository.deletable(id) (§14.2.4 "Changes in v0.9"); :data: both computed from Room, with repository tests; :core:fakes: both computed from each scenario's own data; ContractTest updated |
Android gate | none | M12c, M5 |
| M6 | Design system | Landed 7730a31 (D-063 to D-066). Built from docs/ux/design-spec.md §2 and §3 and docs/ux/strings.csv. :core:design theme, tokens, typography (Material 3 default typeface, D-020), shared components including the ledger rows and expense editor fields (D-019), charts (Compose Canvas, D-063), formatters (§9.8), shared strings (including the notification copy) per docs/ux/design-spec.md and docs/ux/strings.csv, screenshot tests of every component in light and dark, Swedish and English |
Android gate | Look at the component screenshots the agent opens and posts (yes/no) | U2, M2b, M12c, the §9.7 colour change |
| M6b | Design-system fixes after M7 | Landed b24c8b5 (D-075 to D-079). :core:design fixes the M7 agents asked for: viewerPercentOf reads basis points, editor_split_full_on_you, AmountInput.of with both decimals and AmountInput.percent (D-078), quickRowCategories (D-075), two-line category labels with an ellipsis (D-076), DateFormatter.monthName and dayRangeLong (D-077), resource-backed snackbars in KvittScaffold, optional ConfirmDialog body, KvittButton loading form, InviteCodeField wrapping, EmptyState icon tile, AnalyticsFilterBar period row, screen tokens in KvittDimens, common_ok and common_undo, the module keys of M7b to M7f recorded in docs/ux/strings.csv (D-079) |
Android gate | none | M7a to M7f |
| M7a | Feature: onboarding | Landed 1ea752c 2026-10-18 (D-071). :feature:onboarding screens S-1, S-2, S-3, S-18 |
Android gate with screenshot tests of every state, sv and en, light and dark | none | M6, M12d |
| M7b | Feature: home and account | Landed 0e1d99d 2026-10-18 (D-071). :feature:home, :feature:account screens S-4, S-5, S-6, S-16, S-17 |
as M7a | none | M6, M12d |
| M7c | Feature: expense | Landed 0b140aa 2026-10-18 (D-074, D-080). :feature:expense screens S-7, S-8, with the category suggestion of §9.11 (CS-2, CS-3, CS-7) |
as M7a | none | M6, M12c, M12d |
| M7d | Feature: settle | Landed 7380e50 2026-10-18 (D-074). :feature:settle screens S-9, S-10 |
as M7a | none | M6, M12d |
| M7e | Feature: analytics | Landed ea7d494 2026-10-18 (D-069 to D-071). :feature:analytics screens S-11, S-12 |
as M7a | none | M6, M12d |
| M7f | Feature: settings | Landed c4ab35f 2026-10-18 (D-071 to D-073). :feature:settings screens S-13, S-14, S-15, S-19, S-20, S-21, S-22 |
as M7a | none | M6, M12d |
| M7g | Feature cleanups | Landed 3dda414 2026-10-18. Cleanups in android/feature/** after M6b; scope in its brief |
as M7a | none | M7a to M7f, M6b |
| M8 | App integration | Landed cb832a7 and 3d9eea8 2026-10-18 (D-081 to D-085). :app navigation and deep links (§13.2, D-071; each top-level screen draws its own bar), session gate (S-1 for Unpaired and Unrecognised, S-2, S-18 for Revoked, back on S-18 closes the app; Home shows S-3 while partner == null), deletion of the debug fakes module (both variants bind :data, D-081), :adapter:fcm with registration by Firebase Installation ID (D-083), push work and notification building (AC-13.5, D-085), invite links ignored while paired (D-084), ProvideHousehold as LocalKvittHousehold, LocalKvittClock from :data's Clock, LocalAvatarPictures handing the User to :data's Coil mapper, notification permission after the first successful sync after pairing (AC-2.9), update check in release only, revoke wipe, network security configs (§8.5, D-082), manifest hardening (§8.8), release signing and version properties (§10.6; unsigned without keystore keys), google-services.json copied with infra/dev/copy-google-services.sh |
Android gate (green at landing: BUILD SUCCESSFUL, 710 actionable tasks); on the owner's phone against the development server on the Mac (D-001): pair, add an expense offline, reconnect, see it synced, receive a push (run on 2026-10-18 on the owner's S26 against the development server: pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired with kvitt-server pair-device; Home showed the account; the offline expense showed the pending marker and synced after reconnect, pending 0, row on the server; the device registered fcm_token and app_version_name; a push by fid arrived and its tap worked). Follow-up M8b (landed 328c466, D-097) fixed what the test showed: the invite-code field, the quick-row badge, the status-bar inset of KvittTopAppBar, the chart corners; FcmMessageBody.fid in :data followed (6eb3f85, D-096) |
Tap through the pairing, offline and push test as relayed by the orchestrator | M3, M4, M5, M7a-M7f, M2c-dev (Firebase, development server) |
| M12e | Contract pass | Landed 8c60715 and b7b7483 2026-10-18 (D-086 to D-095). The §11.3 contract requests of M5 to M8 taken together on both sides: E-3 invite_kind and Pairing (D-086); the admin's name in E-12 links, passed on by E-4 (D-087); SyncSummary.lastAttemptFailed (D-088); Rejection.actor (D-089); X-Kvitt-App-Version-Name, devices.app_version_name by migration 0003, server schema 3 (D-090); NewRecurringTemplate (D-091); UpdateCheck (D-092); SettleState.untouched (D-093); the D-027 re-stamp in :data (D-094); PushEvent in :core:model, snackbar hosts in six screens, snackbar resource ids from S-7 and S-8, the adaptive launcher icon (D-095). Goldens re-recorded: S-20 devices (version name); new s1_pairing_link_admin and s22_about_check_failed |
Both gates (green at landing: Android BUILD SUCCESSFUL, 710 actionable tasks, 906 tests; server 253 tests, the OpenAPI equality, vector and migration byte-identity tests included) | none (the development server must be rebuilt before the new APK is installed, §14.8) | M8, M7g, SPEC v0.12 |
| M12f | Audit and security follow-ups (Android) | Landed dc7a296, 5b2ed37, 2bd6b58, c366fbd, a0259ab 2026-10-18 (D-109 to D-112, D-116; no prompt file, orchestrator brief). S-16 read-only lost version (Rejection.lost, D-109); S-22 versions from /health (AboutState.schemaVersion, D-110); SettlementRepository without throws (D-111); E-17 retry (D-112); invite links dropped while paired (AC-2.6); private notifications (AC-13.9, D-116); localeFilters en, sv (AC-19.1); :data tests for the 429 retry, D-040 refusals and WorkManager unique work (work-testing, §6.2); the :feature:home and :feature:account screenshot harness fix (§6.2); the 19 string keys R8 found unused removed (strings.csv 562 rows, §13.4) |
Android gate (green at landing, 941 tests); release APK 10.0 MB | Look at S-16 and S-22 on the next install (HANDOFF §3) | M8f, the AC-coverage audit and the security review (§11.4) |
| M9 | Splitwise importer | Landed 50339ae (D-044 to D-046). kvitt-import per §10.8 with tests over synthetic CSV fixtures (crates/kvitt-import/tests/import.rs), reconciliation against the totals rows, the committed category table |
Server gate | Provide the CSV files (kept outside git) when OQ-2 is answered | M2a, M4 |
| M10 | First release | Release build signed, published, both phones paired, push working both ways, nightly backup on Drive, restore drill report, memory check | docs/validation/first-release.md and docs/validation/restore-drill.md complete; server RSS under 100 MB |
Create the release keystore with the keytool command of §10.6 at the repository root, or allow an agent to (D-101), and keep a copy and the password in the password manager; run the Pi steps of the M2c-pi compose project (D-100, D-125); install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive |
M2c-pi (the owner's run of the bundle), M3, M4, M8 |
| M11 | Import history | Rehearsed on 2026-10-18: the real run reconciled on both files in a throwaway database (Vardag 1746 rows, Hemma 37 rows; 21 rows unmapped before D-044). Dry run of 2026-10-18 with the M12b mapping (D-053): Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled. Real import run on the Pi per §10.8 "Production run" (D-025), docs/validation/splitwise-import.md with per-account totals compared to Splitwise's balances (aggregates only) |
Imported balances equal Splitwise's "Total balance" rows per group | Provide the CSVs; confirm the balances on the phone match Splitwise | M9, M10 |
| W0 | Web foundation (D-131, D-134) | Landed 2ae65a5 (2026-10-21). web/: Vite, React 19, TypeScript strict, React Router 7, TanStack Query 5, openapi-typescript types in web/src/api/schema.d.ts and openapi-fetch, Vitest and Testing Library, ESLint and Prettier; design tokens from docs/ux/design-spec.md §2 as CSS custom properties; layout shell (sidebar, bottom bar under 720 px), router with every route of §15.3, keyboard shortcuts and W-20; login (W-1), token store and 401 handling; API client and household snapshot (§15.5); i18n (sv, en); formatting (§9.8); shared components; the money functions of §14.5.2 passing their vectors; npm run check |
Web gate | none | SPEC v0.28 |
| W1 | Web: home and accounts | Landed 934eee7 (2026-10-21, D-144, D-145, SPEC v0.32). W-2, W-3, W-4 in web/src/features/{home,accounts}/ |
Web gate | Look at Home and an account in a browser against the development stack (yes/no); done on the Pi 2026-10-24 (D-211) | W0 |
| W2 | Web: expenses, search and export | Landed a65ab71 (2026-10-21, D-146 to D-148, SPEC v0.32; the shared editor fields in web/src/components/editor/). W-5 with the §9.11 suggestion (suggest_category.json, in the vector test since 83f5293), W-6 with the timeline, W-12 with the CSV export, in web/src/features/{expenses,search,export}/ |
Web gate | Add, edit and delete an expense in a browser; open an exported CSV in Numbers (yes/no); done 2026-10-24 (D-211) | W0 |
| W3 | Web: settle up | Landed f9a9bfb (2026-10-21, D-144, D-149, SPEC v0.32). W-7, W-8 in web/src/features/settle/ |
Web gate | Record and delete a settlement in a browser (yes/no); done 2026-10-24 (D-211) | W0 |
| W4 | Web: analytics and insights | Landed 1aecf51 (2026-10-21, D-151, D-152, SPEC v0.32). W-9, W-10 with the D-128 line chart, W-11 on the D-133 endpoints (E-34, E-35); resolveRange and spendSeries passing their vectors (features/analytics/domain/analytics.vectors.test.ts); in web/src/features/analytics/ |
Web gate | Look at the analytics pages in a browser (yes/no); done 2026-10-24, Insights included (D-211) | W0; W-11 needs W7 |
| W5 | Web: settings | Landed 3ebe57d (2026-10-21, D-153, D-154, SPEC v0.32). W-13 to W-19 in web/src/features/settings/ |
Web gate | Pair a browser, see it on the phone's Devices, revoke it from the phone (yes/no); done 2026-10-24 (D-211) | W0 |
| W6 | Android: Insights | Landed 1c9856b (2026-10-21, D-141 to D-143, SPEC v0.31; Android gate green, 40 s11_insights_* goldens). An Insights section on S-11 from the D-133 endpoints, online only (§9.9): a UI-contract change (§14.8) in :core:model, :data, :core:fakes and :feature:analytics, with goldens |
Android gate | Look at S-11's Insights on the phone (yes/no); done 2026-10-24 against v1.0.3 (D-211) |
W7 landed, its image deployed |
| K0 | Card purchases spike: local test app (D-176) | The test app landed 2ce317f: docs/spikes/k0/app.py and its README.md, one command (python3 docs/spikes/k0/app.py, the standard library only, the RS256 JWT signed with openssl), a page on http://localhost:8765/ that runs the real Enable Banking flow (bank list, BankID, paste-back of the redirect address, accounts, "Uppdatera" with PSU headers, "Bakgrundshämtning" without, balances, the purchases, an aggregate "Analys", "Koppla från"), key and state in the git-ignored docs/spikes/k0/.local/. Done 2026-10-24: run with the owner on his card; report docs/spikes/K0-enable-banking.md (aggregates only), its answers recorded in SPEC v0.36 (D-177 to D-180); the partner's card and the next-day checks were not run (owner's decision, D-177) and move to K1's owner steps |
The flow worked end to end with the owner's card (owner's yes); the report answers every K0 item; git status shows nothing from .local/; no gate (not part of any gate) |
Done 2026-10-24: Enable Banking account and Terms of Service, the restricted production application, the key in .local/, the owner's card linked with BankID, the test app run (PLAN-K0 §8) |
SPEC v0.35 |
| K1 | Card purchases: server (D-158 to D-175) | Landed 671c00d (2026-10-24; D-181 to D-188, SPEC v0.38). Migration 0004 with its contract text and migration test, server schema 4; src/cards/ (registry, Enable Banking adapter, the windowed fetch and de-duplication of §9.12 CP-7 and CP-8, poller, callback page); E-36 to E-44 in the server and contracts/openapi.yaml with examples; new error codes; the link clearing in §14.4.4 step 7; contracts/test-vectors/match_card_purchase.json and its Python reference in tools/contracts-check.py; config (§10.4), infra/pi/compose.yaml, .env.example, infra/dev/**, infra/npm/kvitt.conf, infra/README.md (step 4 block, card runbook); web/src/api/schema.d.ts regenerated |
Server gate; python3 tools/contracts-check.py; the web gate (the regenerated types); an integration test suite against a local Enable Banking stub. At landing: server 356 tests (34 in tests/cards_api/), contracts-check 157 (17 match_card_purchase cases), web 609 (match_card_purchase listed as not ported until K3), the nginx check and both compose configs ok; the Android gate red by design until K2 lands (VectorFilesTest, ExampleRoundTripTest pin the contract's vector and example sets), so the commit bypassed the pre-commit hook (contract-first landing) |
Copy the key (already on the Mac from K0) to ~/kvitt/secrets/cards/, set the application id in .env, paste the new NPM block, deploy; once a client with the card screens runs: the partner's card, the next-day fetch without BankID and pending→booked (D-177) (PLAN-K1 §8) |
SPEC v0.36 (K0's answers) |
| K2 | Card purchases: Android (D-169, D-170, D-172, D-173) | Landed a8f91ee (2026-10-24; D-191 to D-200, SPEC v0.39). :feature:cards (S-23 to S-27 and the sheet), Room version 2 with its migration test, the CardRepository UI contract in :core:model, matchCardPurchase in :domain passing its vectors, :data (DTOs, card sync, queued link changes, Custom Tab, deep link), fakes and scenarios, the fourth bottom destination, S-5's "Från kortköp", S-7's prefill and link, S-8's card line, the committed card artwork mapped (D-180), goldens; strings in docs/ux/strings.csv (77 rows); out-of-path edits ratified by the orchestrator: ArchitectureRules.kt knows cards, :core:design gains ic_credit_card / KvittIcon.CreditCard, the card measures in KvittDimens (D-200), the nav-label shrink-to-fit (D-197), StringsCatalogueTest takes cards_ as a feature prefix |
Android gate. At landing: BUILD SUCCESSFUL, 791 actionable tasks, 1154 tests, 0 failures; the 11 card examples (E-36 to E-44) round-trip, the 17 match_card_purchase cases pass, VectorFilesTest and ExampleRoundTripTest green again on main; 150 goldens added (:feature:cards 135 for every state of S-23 to S-27 with _sv_light_200 for the long ones; S-5-account-menu-card, s7_from_card, s7_from_card_foreign, s8_from_card, s13_hub_card_connected) and 147 re-recorded for the fourth bar destination (S-3, S-4, S-11, S-13, S-17 and the navigation-bar component; only the bar changed) |
Connect the card on the phone with BankID; create an expense from a purchase; link one; disconnect and reconnect (PLAN-K2 §8 steps 1 to 7, pending: the phone was not attached; the release APK with the card screens is installed by the orchestrator once K1's image runs on the Pi) | SPEC v0.36; may start then on the spec and fakes; lands after K1 |
| K3 | Card purchases: web (D-174) | Landed faeb752 (2026-10-24; D-201 to D-207, SPEC v0.40). W-21 to W-23 in web/src/features/cards/ (every §13.3 state but offline, the S-23 sheet actions, ?attempt= polling, ?account= as S-27), the Kortköp entry after Översikt in the sidebar and bottom bar, W-5's ?cardPurchase= prefill (CP-9) and link on save, W-6's card line with "Ta bort koppling", W-3's "Från kortköp", W-13's "Anslut kort" row, the committed card artwork mapped (D-180), the card snapshot in web/src/api/cards.ts, matchCardPurchase in TypeScript passing its 17 vectors |
Web gate. At landing: web 70 files, 684 tests, build ok; contracts-check 157 | Pending after image v1.0.5 is deployed: connect the card from the laptop browser with BankID QR; create and link from W-21 (PLAN-K3 §8, three steps) |
SPEC v0.36; lands after K1 and K2 (it takes the card strings K2 adds to docs/ux/strings.csv) |
| W7 | Server: web serving and analytics endpoints (D-132, D-133) | Landed 50a0d67 (2026-10-21, D-135 to D-138, SPEC v0.29; 299 server tests, contracts-check 136). /app/ from KVITT_WEB_DIR with the SPA fallback, cache headers and the /app/ CSP; the /analytics/* endpoints (D-135 to D-138, SPEC v0.29) in contracts/openapi.yaml and the server; the Node stage in server/Dockerfile with the repository root as build context (workflow, infra/build-images.sh, infra/dev/**) |
Server gate; python3 tools/contracts-check.py; the web gate when web/ exists and the contract changed (web/src/api/schema.d.ts regenerated) |
Paste the v0.28 NPM block (infra/README.md step 4); deploy the new image (§10.10); deployed 2026-10-24 as v1.0.3 (D-211) |
SPEC v0.28 |
11.3 Notes per milestone
-
M1 writes the OpenAPI file by hand from §14.3 (no code exists yet). M2a then annotates handlers so the generated document matches it; the equality test compares parsed values, not text (D-016), after the normalisations of §14.8 (D-005). M2a may change
contracts/openapi.yamlonly representationally (key order,$refnaming, description wording), listed in the plan report, with no spec bump (§14.8). Any semantic change follows the contract-change rule (§14.8).tools/contracts-check.pychecks the file's header: OpenAPI 3.1.0, titleKvitt, version1and one serverhttps://{host}whose variablehosthas the defaultkvitt.example.invalid(v0.23, D-124;https://{host}:8443in v0.22, D-123; before that the literalhttps://icehack.asuscomm.com:8443). -
M2a owns the router and registers every route up front so that M3 and M4 only fill in handler bodies in their own files. The same for the utoipa
ApiDoc. M2a creates the shell files of the later server milestones (D-004, §14.7) and the seams of §6.3 (D-015).server/rust-toolchain.tomlpins the exact current stable Rust release and the Dockerfile uses the same version (§10.3). -
M2b writes the
*Stateclasses and repository interfaces of §14.2.4 so that M5 and M7 work against the same interfaces in parallel. It creates a module shell for every module of §6.2, with one empty entry composable per feature module (D-004), puts the feature convention of §6.2 (D-017) into the feature convention plugin, and fixes howresolveRangesignals an invalidCUSTOMrange (§14.5.4, D-018:IncompleteCustomRangeException, D-011). Its screenshot-test choice is D-010 (Roborazzi). It also created the Room 3 entities andKvittDatabasev1 in:data(D-012), so M5 builds on them. -
M5 owns
:dataand builds on the entities,KvittDatabasev1 and the exported schemaandroid/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.jsonthat M2b created (D-012); it adds DAOs, the API client, sync and the repositories. Room 3 migrations take anSQLiteConnection(§14.2.3). -
M7x features use
:core:fakesonly; they never depend on:data. Each feature agent adds its fake scenarios inandroid/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/<feature>/and nowhere else in that module. Each fills in its own module shell from M2b, follows the feature convention of §6.2 and takes shared UI from:core:design(D-019). All six landed on 2026-10-18 (1ea752cM7a,ea7d494M7e,7380e50M7d,0e1d99dM7b,0b140aaM7c,c4ab35fM7f); their readings are D-069 to D-074 and D-080, the shared fixes they asked for went into M6b (b24c8b5, D-075 to D-079). M7g (landed3dda414) did the feature-side cleanups after M6b. -
M6b (landed
b24c8b5) is the:core:designfix pass after the M7 wave, in M6's paths (§14.7); see the §11.2 row. -
M12e (landed
8c60715andb7b7483on 2026-10-18, D-086 to D-095) took the contract requests that M5, M6, M7 and M8 had collected here, as one contract change on both sides (§14.8): the invite kind frompair()(D-086), the admin's name in invite links (D-087),SyncSummary.lastAttemptFailed(D-088),Rejection.actor(D-089), the device version name with server schema 3 (D-090),NewRecurringTemplatewithout an id (D-091),UpdateCheck(D-092),SettleState.untouched(D-093), the D-027 re-stamp in:data(D-094), andPushEventin:core:model, the six screen-hosted snackbar hosts, snackbar resource ids from S-7 and S-8 and the launcher icon (D-095). The 128 px picture size was left unchanged by assumption::datakeeps fetching E-11 at 512 and no size parameter was added, since v1 does not need one (the owner confirmed on 2026-10-18 that it is not needed: single 512 size, §14.2.4). Room stays at schema 1 (the phone stores no devices). A phone running the M12e APK needs a server at schema 3 (§14.8 "Compatibility"). -
Remaining contract requests (none is a contract change in v0.14; it needs the §14.8 rule when taken):
- None open. S-22's "Licenser för öppen källkod" did nothing (no licences screen and no strings for one); the owner answered OQ-5 on 2026-10-18: no licences screen in v1, the row is removed (implemented by M8c,
a0c25b0, D-099).
The
fidrequest was taken on 2026-10-18 (f56dcbe, D-096): the server sends the installation id in FCM HTTP v1message.fid(§14.6). - None open. S-22's "Licenser för öppen källkod" did nothing (no licences screen and no strings for one); the owner answered OQ-5 on 2026-10-18: no licences screen in v1, the row is removed (implemented by M8c,
-
M2c-dev / M2c-pi (D-001, D-100): M2c-dev builds only what development needs on the Mac (landed); M2c-pi builds everything that concerns the Pi (registry trust, TLS through the owner's Nginx Proxy Manager since D-120, router, Pi deploy, APK publishing to the Pi, Drive upload, Gitea Actions) as the
infra/pi/bundle the owner runs, and runs last, after M8 and the phone test, before M10. -
M4 (landed
15056dc) addedinto_make_service_with_connect_info::<SocketAddr>()toserveso the per-IP rate limits of §8.6 see the peer address (D-032). Its tests are three binaries,tests/sync_api/,tests/push_fcm/andtests/scheduler_daily/(§14.7); the scheduler lives insrc/tasks/scheduler.rs. -
M8 (landed
cb832a7and3d9eea8, D-081 to D-085) is the only plan that edits:appafter M2b. As built: both variants bind:datathroughdi/AppDataModule.kt, which includesDataBindingsModule; the debug fakes module is deleted (D-081, revising D-041). The session gate shows S-1 for Unpaired and Unrecognised, S-2, and S-18 for Revoked (back on S-18 closes the app); Home shows S-3 whilepartner == null; each top-level screen draws its own bar (D-071). M6's:apprequests are met:ProvideHouseholdis implemented asLocalKvittHousehold,LocalKvittClockis provided from:data's Clock, andLocalAvatarPicturespasses theUsermodel to:data's Coil mapper. M5's requests are met:AppBuildInfofromBuildConfig; WorkManager withKvittWorkerFactoryand the default initializer disabled;SyncTriggers.onAppStarton process start andonForegroundon later returns; push handled by an expedited WorkManager job callingsyncForPush(D-085);UpdateRepository.checkon every start and resume, in release builds only (AC-16.5); Coil's singleton is:data'sImageLoader. The notification permission is asked after the first successful sync after pairing (AC-2.9). Network security configs per §8.5 (D-082); the merged release manifest exports onlyMainActivityand permission-guarded library receivers (§8.8);:app:assembleReleasebuilds unsigned whenlocal.propertieshas no keystore keys (§10.6); FCM registration by Firebase Installation ID (D-083); invite links ignored while paired (D-084). The end-to-end test ran on 2026-10-18 on the owner's S26 against the development server: pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired withkvitt-server pair-device(the CLI had no partner-invite subcommand then;kvitt-server invite-partnerfollowed in8660b7f, D-113); Home showed the account; an expense added offline showed the pending marker and synced after reconnect (pending 0, the server has the row); the owner's device registeredfcm_tokenandapp_version_name; a push byfidwas delivered and the notification tap worked. M8b (landed328c466) fixed what the owner saw: the invite-code field holds the raw code and draws the hyphens as aVisualTransformationwith an exact offset mapping (cursor bug fixed, unit testInviteCodeFieldTest); the quick-row and grid selected badge is drawn above the ripple clip (it was cut off);KvittTopAppBarpads for the status-bar inset through awindowInsetsparameter andKvittScaffoldfollows; chart bar corners top 4, bottom 2 dp (D-097); 221 goldens re-recorded.FcmMessageBody.fidin:datafollowed in6eb3f85(D-096). Still unverified on the phone: the status-bar fix (next install) and the design-spec §2.7 typeface check (the owner looked and had no complaint). Its contract requests were taken by M12e (D-086 to D-095); thefidfield followed inf56dcbe(D-096). M8e (landeda59abeb, D-107) turned S-19's account, payer and split chips intoEditorOptionRows; the unusedAccountChip,PayerChipandSplitChipwere removed in1328afe. M8f (landed956908a, D-108) made navigation instant (NavTransitionsin:app). -
M10: the orchestrator relays every HUMAN step in chat. The keystore is created with the command of §10.6 at the repository root and referenced from
android/local.properties(D-024, D-101). -
M2c-pi (D-100, revises D-023): no SSH from the Mac to the Pis and no allowances for agents on the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. The agent writes and checks the
infra/pi/bundle on the Mac; the owner copies it to the Kvitt Pi, fills the.envplaceholders (OQ-3) and runs the README checklist, answering yes/no per check. It runs as the last step before M10, after the app is proven on the phone. The bundle landed2476667on 2026-10-18 (D-103 to D-106: layout and scripts, image names and the Gitea workflow, the backup uid, the cert-sync and backup refinements); the NPM rework (D-120,4f69ae0to9231a1d, 2026-10-19) replaced Caddy and cert-sync with a proxy host in the owner's Nginx Proxy Manager (§10.5);PLAN-M2c-pi.mdcarries a note that it is superseded there. D-125 (6e5981d, 2026-10-20) made the bundle a self-contained compose project in/home/jacobhagstedt/kvittwith no scripts and nothing outside that folder (§10.2, §10.10); the plan's note names it too. The remaining part is HUMAN, the owner installing the project on the Kvitt Pi (in progress 2026-10-20), and M10 depends on it. -
M9 (landed
50339ae) reuses the server's own push validation (kvitt_server::api::validate,rows) and theSqlAuditWriter, so imported rows pass the same checks as synced ones (§14.4.4). Its tests are one binary,crates/kvitt-import/tests/import.rs, over synthetic fixtures only; one test checks the §10.8 category table againstdata/splitwise_categories.csv. -
M12a, M12b, M12c (D-049 to D-054) carry the owner's UX-session decisions into the contracts and the landed code: M3, M5 and M9 were built with ten defaults, so this is a contract change against
main, not only the spec. M12a (landedaa765bb) wrotecontracts/, the checker and:domain'ssuggestCategory; M12b (landed6eaccec) and M12c (landedb09f322) ran in parallel (disjoint paths, §14.7); their outcomes are D-055 to D-058. The Room schema stays at version 1: category rows reach the phone through sync. The colour hex values ofslateandamberare not needed by M12 (:core:modelholds token names only); they arrive with U2 and M6. -
M12d (D-059, landed
557af7e) added the two UI-contract members UX-9 found (ExpenseEditorOptions.frequentCategoryIds,CategoryRepository.deletable) to:core:model,:dataand:core:fakes, in parallel with M6 (disjoint paths); its readings are D-060 to D-062 (§14.2.4 "Readings fixed in v0.10"). M7c and M7f consume them; the design spec's fallbacks (docs/ux/design-spec.md§3.7 and §6) are no longer needed. -
M6 (landed
7730a31) built:core:designwith charts on ComposeCanvas(D-063), the Material 3 Expressive fallbacks of D-064, the formatting tables of D-065 and the golden layout of D-066. Its contract requests for M8 (no change now)::appprovidesLocalAvatarPictureswith anAvatarPicturesthat builds the authenticated E-11 Coil request from the user id andpictureVersion(§14.3.4);:appwraps every screen inProvideHousehold(me, partner);:appprovidesLocalKvittClock(tests and previews pass a fixed clock);SyncSummarygains a "last attempt failed" field soSyncStatusPillcan drop itsfailedparameter (§13.3 S-4); done by M12e:SyncSummary.lastAttemptFailed, andSyncStatusPill,syncPillVariantandsyncLineTextno longer takefailed(D-088). The design-spec §2.7 open point (thatFontFamily.Defaultkeeps tabular figures and distinct weights 500/600/700 under every One UI font style on the S26) is checked at the M8 install on the owner's phone; on 2026-10-18 the owner looked and had no complaint (not checked under every One UI font style). -
M12f (landed
dc7a296toa0259ab, D-109 to D-112, D-116) took the Android findings of the AC-coverage audit and the security review (§11.4); see the §11.2 row. The S-1 confirm step for an unusual server (security review L-6) was not built: it needs a design state (OQ-6). -
Server gap pass (landed
8660b7f, D-113):kvitt-server invite-partner(§10.4), which closes the ops request of the M8 test; tests forinvite-partner,migrate(schema atSCHEMA_VERSION, idempotent),healthcheck(200, 503, nothing listening) and the SIGTERM drain ofserve; the push sender'spush sentinfo line (§8.7);tools/contracts-check.pyfollowsinvite_kind(D-086),app_version_name(D-090) andfid(D-096), 134 checks pass. -
Server overhaul (landed
8103daetob985ce9, D-114, D-115): shared stored-row reads and inserts (src/api/rows.rs,src/sync/rows.rs), shared invite creation and CLI plumbing, one test harness (tests/common/, §14.7), clippy pedantic wins, synchronoustasks::start(§6.3),not_implementedno longer produced (§14.3.2); the security fixes H-1 (D-115), M-3 and L-4 (D-114). Gate at landing: 268 server tests; all crates at their latest versions. -
Infra overhaul (landed
2fac1ectoe70fe0d, no decision): one Gradle lock helpertools/gradle-lock.shfortools/gradle-serial.sh,tools/clean-builds.shandinfra/build-images.sh; the pre-commit hook runs the Android tests throughtools/gradle-serial.shwith JDK 25 onPATH,JAVA_HOMEuntouched;install.shkeeps the deployedKVITT_TAG(moot since D-125: noinstall.sh);--provenance=falseon buildx pushes and the workflow token through the environment (§10.3; reversed by D-120 point 7);RCLONE_REMOTEdropped from the development env (§10 intro). Infra security (landed7fb86f5, D-117): Caddy log redaction and response headers (§8.7, §10.5.2), the non-root Caddy container (§8.9, §10.2), the development stack's single secret (§10 intro), the digest record (§10.3, §10.10; dropped by D-125 fordocker image inspect),kvitt_cicdwithwrite:packageonly (§10.3), the restricted cert-sync key (§10.5.1). D-120 carries the log redaction and the headers over to NPM's Custom Nginx Configuration (§10.5.2) and removes the Caddy container, the digest of its image and the cert-sync key. -
Overhaul outcomes (2026-10-18). Server: landed through
b985ce9(see the server overhaul above). Infra: landed throughe70fe0d(see the infra overhaul above). Android overhaul (landede4e6e5dtoc582363, D-118, no prompt file, orchestrator brief): one screenshot and test harness in:core:design's test fixtures and the convention pluginkvitt.android.screenshots(§6.2); the shared failure texts, limits andsavedSplitLabelin:core:design(§6.2); dead code removed (KvittMotiondown to the seesaw spring,KvittIcon.Description, unused dimens and shapes, catalogue and fake samples); the catalog entriesroborazzi-junit-rule,roborazzi-compose,androidx-hilt-navigation-composeandandroidx-lifecycle-viewmodel-composeremoved; oneCopyCategoryKeywordstask inbuild-logicshared by:dataand:core:fakes(D-055 holds); oneconfigureAndroidModulefor the application and library plugins; S-7's state types inExpenseEditorState.kt. Gate at landing: 941 tests, no golden changed; release APK 10.46 MB. Pushed tooriginatc582363. -
M11 runs the import per §10.8 "Production run" (D-025). The rehearsal on 2026-10-18 ran both real files against a throwaway database and reconciled on both (Vardag 1746 rows, Hemma 37 rows); 21 rows were unmapped before D-044. The dry run of 2026-10-18 with M12b's mapping confirmed it: Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled.
-
W0 to W7 (D-134, §15.9): the web client programme. W0 and W7 run first in parallel (disjoint paths); W1 to W5 run in parallel once W0 has landed; W4's W-11 and W6 wait for W7. W0 owns
web/**except the feature folders, registers every route with a placeholder page per feature folder (as M2a registered every route, D-004), and lists its module paths in its report; W1 to W5 build only inside their feature folders and take everything shared from W0's modules (a missing shared piece is a request to the orchestrator, as M7x's requests went to M6b). A second agent to land that changedweb/package.jsonresolvesweb/package-lock.jsonwithnpm installon rebase (asCargo.lock, §14.7). A plan that changescontracts/openapi.yamlregeneratesweb/src/api/schema.d.ts(§15.3). Web agents do not use the phone ortools/gradle-serial.sh; a manual check runs against a throwaway server, never the owner's development or Pi data (§15.4). The orchestrator adds the web gate toCLAUDE.md"Build and verify" (D-131). W1 to W5 landed 2026-10-21 (934eee7,a65ab71,f9a9bfb,1aecf51,3ebe57d; D-144 to D-154, D-150 unused) with the web gate green onmain(59 test files, 532 tests at1aecf51); a web fix (83f5293) followed (598 tests). Pieces built twice or three times in feature folders and other loose ends are listed for a W0b plan indocs/HANDOFF.md"Web follow-ups". W0b landed5305f37(v0.34). All of W0 to W7 are landed and verified:v1.0.3(built on the Mac from8fa9198, D-211) runs on the Kvitt Pi and the owner checked the web client and the phone's Insights against it on 2026-10-24 (§11.4). -
K0 to K3 (D-176, §11.1): the card-purchase programme. K0 runs first, on the Mac only, with the owner (a local test app for fast feedback before any Android work, owner's refinement 2026-10-24; the app landed
2ce317f); K0 is done (run 2026-10-24, reportdocs/spikes/K0-enable-banking.md) and its answers are in SPEC v0.36 (D-177 to D-180), so K1 to K3 can start. The reports of K1 to K3 propose spec text and decisions that a docs agent records: K1's in SPEC v0.38 (landed671c00d, D-181 to D-188), K2's in v0.39 (landeda8f91ee, D-191 to D-200), K3's in v0.40 (landedfaeb752, D-201 to D-207) (decisions from D-191 to D-210, disjoint ranges per plan). Then K1, K2 and K3 run in parallel in their own worktrees (disjoint paths, §14.7): K1 owns the contract (contracts/**, the migration text, the vectors) and lands first; K2 and K3 build against the spec and fakes, and rebase on K1 before their gates (K2's DTO tests and K3's generated types need K1's contract); K3 lands after K2, whosedocs/ux/strings.csvrows it imports (npm run i18n:import). K2 uses the phone only when its brief names it; K1 and K3 never use the phone. No plan connects to the owner's real card except through the owner's own BankID step; no plan stores, prints or commits a real purchase.
11.4 Validation reports
Reports in docs/validation/ are deliverables (CLAUDE.md); they carry aggregates only and record the commit they were taken at. The release reports first-release.md and restore-drill.md (M10, AC-18.2) are still to be written.
docs/validation/ac-coverage.md, the AC-coverage audit taken at956908a(ed1b130before the history rewrite; SPEC v0.16), read-only: 270 rows, 260 Done, 5 Partial, 1 Missing, 4 not verifiable without a device. Verdict: feature complete in code; not yet complete against §4's acceptance rule. Its two mismatches in shipped behaviour are fixed by M12f: S-16 opens the lost change read-only (D-109) and S-22 reads the server version from/health(D-110); M12f also added tests for the §9.2 effect cases, AC-2.9, the 429 retry, D-040 refusals and WorkManager unique work. Still open: the AC-18.2 restore drill (Missing), AC-20.1 observed on the Pi (M10), and aKvittWorkerFactorytest. Observed 2026-10-24 (D-156): AC-18.1, the first backup on the Pi with its snapshot pair in the Drive folder (verified by the owner); AC-16.3, the realPackageInstallerinstall on both phones (release 140), once Samsung Auto Blocker was off; the editor keyboard fix (S-19, S-15, S-6,05ec3f3) on the device. Observed 2026-10-24 (D-211), the web client's first use (FR-21) and W6: imagev1.0.3on the Kvitt Pi,serverandbackuphealthy;https://kvitt.suorra.duckdns.org/app/paired from the laptop browser with a device invite; the owner reports "All seems to work" for Home and an account, add, edit and delete of an expense, the CSV export, recording and deleting a payment, analytics with a category drill-down and Insights, every settings page, revoking the browser from the phone, and the Android Insights section on S-11 against this server.docs/validation/security-review.md, the security review taken at18c527f(SPEC v0.17), read-only: Critical 0, High 1, Medium 3, Low 10, Info 8; the design matches §8 and is sound. Status at v0.18: L-6 kept as built and L-7 fixed (D-116); L-1, L-2, L-5, L-9 and M-2 fixed in infra (D-117); L-8 fixed (e70fe0d); H-1 (the in-memory rate limiter,725901c), M-3 (picture decoding memory,baa5806) and L-4 (fcm_tokenlength,b985ce9) were fixed by the server overhaul (D-114, D-115); L-10 (a member getsforbiddenrather thannot_foundfor another user's device): owner answer 2026-10-24,not_found(D-156), fixed in07c36d2(E-16). Owner answers of 2026-10-19: M-1 (backup encryption) no, closed as an accepted risk (§8.1, §8.7); L-3 (APK download rate) accepted and fixed (96e4cbd, D-119: 10 per minute per IP for E-6); the 16 megapixel picture cap and the 512-characterfcm_tokenlimit (D-114) confirmed. H-1 had to be fixed before the server was opened to the internet (fixed,725901c). D-120 (2026-10-19): L-1's redaction and D-117's headers now live in NPM's Custom Nginx Configuration (§10.5.2); L-2 and M-2 are moot (no Caddy container, no Home Assistant SSH key); the L-8 fix is reversed (the token is interpolated in the run line again, masked by act_runner, §10.3); I-5's check is the conntrack check of §10.10. The review carries dated notes under those findings.
12. Open questions
Only things that cannot be decided without the owner. Everything else in this spec is decided.
| Id | Question | Needed by | Default if unanswered |
|---|---|---|---|
| OQ-1 | Answered (D-001): the registry is a Gitea instance on another Pi at http://gitea.lan, plain HTTP, reachable only on the home Wi-Fi; images are built by Gitea Actions from the owner's example workflow (provided; .gitea/workflows/build-images.yaml, D-104). |
M2c-pi | answered |
| OQ-2 | Answered (D-003): two exports, English and Swedish header variants, member columns in either order, ISO dates, point decimal, double-quoted fields, trailing empty line and Total balance / Totalsumma row, payment category Payment / Betalning, all rows SEK. Group "hemma" becomes account "Hemma", the general group becomes account "Vardag". Details §10.8 and PLAN-M9. |
M9, M11 | answered |
| OQ-3 | Answered by placeholders (D-100, revised by D-120): the LAN address of the Kvitt Pi (<kvitt-pi-address>; its .env key KVITT_PI_HOST was dropped in 200c99f) and the LAN IPv4 address of the Home Assistant Pi, where NPM runs (HA_HOST); with D-120 also the Kvitt host name (KVITT_PUBLIC_HOST, owner-chosen, recorded in HANDOFF). fullchain.pem and privkey.pem there; which SSH add-on is usedHA_HOST in the Pi's .env (D-125) and uses the Kvitt Pi's address in NPM and in scp; no agent needs them. |
M2c-pi | answered |
| OQ-4 | Answered: the Google Drive backup folder id is 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV (§10.9). |
M2c-pi | answered |
| OQ-5 | Answered 2026-10-18 and implemented (D-099, a0c25b0): no licences screen in v1; S-22 has no "Licenser för öppen källkod" row. |
M10 | answered |
| OQ-6 | Answered 2026-10-24 (D-156): no confirmation step on S-1 for an invite link that prefills a server other than the release default (RELEASE_SERVER_URL, AC-2.6) (security review L-6); the default stands: S-1 opens its server row expanded for a non-default server so the address is visible (AC-2.6, D-116). |
M10 | answered |
| OQ-7 | Should a new card purchase raise a notification on the holder's phone (a local one, built from the holder's own data; never an FCM payload with content)? | K2 | Not in this version: purchases appear on the next sync without a notification (D-171) |
| OQ-8 | Should several purchases be turned into expenses at once ("Lägg till i konto…", one expense each with the account's default split)? | after K2 | Not in this version (D-171); one purchase at a time through S-7 |
| OQ-9 | CardPurchase.linked (§14.2.4) is null both when a purchase is unlinked and while its linked expense is not yet on the phone (for example a link made in the web client to an expense the phone has not pulled yet), so the purchase is listed under "Kopplade" without its "Kopplad till" line and its sheet briefly offers "Skapa utgift" and the matches. Add linkedExpenseId to CardPurchase so the sheet can say "Kopplad" without the expense's details (a UI-contract change, §14.8)? |
next Android card pass | Not now: the window lasts until the next household sync brings the expense |
13. UX sprint
A brief for one UX agent (Opus) who has read this spec and nothing else. It runs right after M0, in parallel with M1, and before any UI work (M6, M7). It produces proposals; the owner chooses; the UX agent then writes the binding design spec. The UX agent writes no Kotlin.
13.1 Context the UX agent must design for
- Two adults in Sweden, one household, using Samsung Galaxy S26 (about 411 × 891 dp viewport, 6.3") and S26+ (about 411 × 891 dp at a higher density, 6.7"). Portrait only. Android 16, One UI on top; the app uses Material 3 (Expressive where stable) and must not imitate One UI.
- Most sessions are 10 seconds: add an expense right after paying. The add flow is the most important flow; it must need at most: open app, tap add, type amount, type description, tap save (category and account defaulted, AC-8.6).
- The second most important view is "who owes whom" on Home.
- Swedish is the primary language (both users' phones are Swedish), English must fit too. Swedish words are long ("Utgiftskonto", "Återkommande"); designs must survive them at 200 % font scale.
- Light and dark theme follow the system. Dynamic colour (Material You wallpaper colours) is offered as an option per direction only if the direction still works with it; the default is the direction's own palette.
- Money must be legible at a glance: amounts in tabular figures, direction ("owes you", "you owe") always in words plus colour, never colour alone.
- Accessibility: touch targets ≥ 48 dp, contrast ≥ 4.5:1 for text, TalkBack labels for every icon-only control, nothing conveyed by colour only, layouts that reflow at 200 % font scale.
- Category icons are Material Symbols from the set in §9.7; category colours are the tokens of §9.7 (the UX agent may change hex values, not token names, and must give light and dark values with contrast checked against both surfaces).
- Users are shown by avatar (picture or initials, AC-4.5) and display name. The viewer is always "you" / "du".
13.2 Navigation model (binding, D-048)
- Destination changes are instant, with no transition animation (D-108); motion stays inside screens (design-spec §2.11).
Bottom navigation with three destinations: Home (Översikt), Analytics (Statistik), Settings (Inställningar).Revised by D-172 (owner, 2026-10-24): bottom navigation with four destinations, in this order: Home (Översikt), Card purchases (Kortköp, S-23), Analytics (Statistik), Settings (Inställningar). Kortköp sits next to Översikt because turning purchases into expenses is daily work beside the add flow, while Statistik is visited now and then and Inställningar stays last. Both users always see the tab; without a connection it shows its empty state with "Anslut kort". An "Aktivitet" feed (the other person's changes across accounts) was considered as a fourth destination and not taken for v1 (D-048).- A floating action button "Add expense" (Lägg till utgift) on Home and on Account detail.
- Account detail, expense editor, expense detail, settle up, settlement detail and all settings sub-screens are pushed screens with a top app bar and back.
- The expense editor is a full-screen screen, not a bottom sheet (the numeric keyboard needs the room).
- Wiring (D-071, as built by M7a to M7f and connected by M8): the top-level screens S-3, S-4, S-11 and S-13 render their own
KvittNavigationBarand takeonNavigate: (KvittDestination) -> Unit; the:appscaffold draws no second bar, and theNavHostswitches top-level destinations from those lambdas. Since D-172 S-23 is the fourth top-level screen that draws the bar, andKvittDestination(:core:design) gainsCardsbetweenHomeandAnalytics(as built by K2,a8f91ee:enum class KvittDestination { Home, Cards, Analytics, Settings }, iconcredit_card, labelnav_cards; a label that does not fit its slot at a large font scale shrinks to fit, down to 70 %, D-197). Navigation-argument keys are constants in each feature (ARG_EXPENSE_IDandARG_ACCOUNT_ID;SettleArgs.SETTLEMENT_IDandSettleArgs.ACCOUNT_ID;DrillDownArgs.KEYS; settings"categoryId"and"templateId"), read throughSavedStateHandle. - Result snackbars (D-071): the result of S-7 ("Utgiften sparades"), of S-9 and of archiving an account is shown by the screen returned to; the account-archived snackbar has an undo action (
common_undo, "Ångra"). As built by M12e (D-095): six screens host:app's sharedsnackbarHostStatein their own scaffold (Home, AccountDetail, ExpenseDetail, SettlementDetail, DrillDown, SyncStatus), so a snackbar never overlaps a FAB or the bottom bar;:apphas no overlay host. Features hand:appstring resource ids, never resolved strings:ExpenseEditorScreen.onSaved(ExpenseId, @StringRes Int?), andonDeleted()on S-7 and S-8 takes no argument,:appthen showssnackbar_expense_deleted. - Session gate (as built by M8): S-1 for an unpaired or unrecognised session, S-2 for the profile step after a
partnerpairing only (AC-2.10, D-086; a member pairing a second phone with adeviceinvite goes to Home), S-18 for a revoked device (back on S-18 closes the app). Home shows S-3 while the household has no partner (partner == null).
13.3 Screens and what each must show
Each screen must be designed in these states where they apply: loaded, empty, offline (with pending changes), loading (first sync only; afterwards data is local), error (a failed online action), and, for lists, long content (long Swedish names, 7-digit amounts). Which states apply to which screen is listed per screen in U2's docs/ux/design-spec.md; that list is the binding one for U2's gate and for the feature milestones' screenshot tests.
| Id | Screen | Must show | Actions |
|---|---|---|---|
| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled; in release builds with RELEASE_SERVER_URL, which :feature:onboarding receives through the Hilt qualifier @ReleaseServerUrl for the prefill and for the non-default-server check of AC-2.6, D-121); invite code field (12 characters, shown in groups of 4); display name field ("Ditt namn", 1 to 40 code points, §9.6), required on a first pairing and hidden in the "Para ihop telefonen igen" variant (D-050); explanation of where the code comes from (onboarding_code_help with the admin's name when the invite link carries from, else the generic help line onboarding_code_help_generic; PairingScreen(initialAdminName) and PairingViewModel.prefill carry the name from :app's AppLink.Invite, and a pasted link fills it too, D-087); progress while pairing; errors invite_invalid, network error, rate_limited |
Pair; paste code |
| S-2 | Partner profile step | Display name field (prefilled; a blank name shows onboarding_error_name_required "Skriv ditt namn"), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob"). Shown only after a partner pairing (Pairing.inviteKind, D-086) |
Continue |
| S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any; error state when fetching the invite failed (snackbar with "Försök igen") | Invite partner (goes to S-21); Settings |
| S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16 ("Sync failed" when SyncSummary.lastAttemptFailed; SyncStatusPill takes no failed parameter, D-088); update banner (S-17) when available ("Later" hides it for the ViewModel's lifetime only, so it returns on the next start); list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") |
Add expense (FAB); Settle up; open account; New account; Pull to refresh (sync now, D-127) |
| S-5 | Account detail | Account name, default split with names (ratios written "60/40", common_ratio), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend; month headers are not sticky), each expense row per AC-8.10, each settlement row per AC-11.7 (its title may wrap to 2 lines, 3 at 200 % font scale, and its second line is the type label ledger_settlement_label "Betalning" from :core:design, D-068); archived badge if archived (an archived account hides the add-expense FAB, AC-6.4, and keeps "Settle this account", D-052); pending-sync marker per row |
Add expense (not on an archived account); Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed); Pull to refresh (sync now, D-127); "Från kortköp" (overflow menu, first item, only while the viewer has a card connection and not on an archived account, D-199; opens S-27, AC-22.9) |
| S-6 | Account editor | Name field; default split control (one slider or stepper plus two percent fields that always sum to 100, two decimals allowed); preview "Of 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr"; "Needs connection" state; the discard dialog has a title and no body text (D-080) | Save; Cancel |
| S-7 | Expense editor | Amount entry (large, numeric keyboard, kr); description; account picker (active accounts); category picker (grid of icon + colour + name, active categories; for a new expense the category is selected from the description per §9.11, with no marker); date (today default, date picker); payer toggle (two avatars: "Paid by"); account, date, payer and split shown as four fixed full-width rows in that order, which do not move when a value changes (D-102); split section: mode chooser (default "Account default 60/40", ratio "Custom ratio", exact "Exact amounts", full "All on one person") and its controls; live preview of each person's share; notes (collapsed by default); validation errors inline; delete in edit mode; opened from a card purchase (S-23's sheet or S-27, CP-9, D-169) it starts as a new expense prefilled from the purchase, and "Spara" also links the purchase (a foreign-currency purchase leaves the amount empty); after saving, the snackbar "Utgiften sparades" without "Ångra" (an undo would be an audited create and delete that also notifies the partner, D-052), shown by the screen returned to (D-071), onSaved handing :app the string resource id (D-095); the quick row is filled by quickRowCategories (D-075) and category labels take up to two centred lines, a longer custom name ending the second line in "…" (D-076); the discard dialog has no body text (D-080) |
Save; Cancel; Delete (edit mode) |
| S-8 | Expense detail | Amount, description, category, account, date, payer, each user's share and the split mode, notes, "Recurring: " when generated; for the card holder only, when a purchase of theirs is linked to it, the line "Från kortköp: , " (expense_detail_from_card) with "Ta bort koppling" (AC-22.10, D-169); timeline (AC-12.4, AC-12.5); "deleted" state when opened from a notification of a deletion; "missing" state (not yet on this phone) with "Synka nu" (expense_detail_sync_now), online only (§9.9, D-080); after a delete :app shows snackbar_expense_deleted on the screen returned to (D-095)
Edit; Delete
S-9
Settle up
Direction ("Anna pays Jacob" with a swap control); amount; allocation list: account name, account debt, editable allocated amount, over-settle warning per row (AC-11.5); remaining-to-allocate indicator; "Add account"; date; note; per-account variant with a single fixed row, which keeps the direction swap (§9.4: per-account settle-ups go either way) and an editable amount (AC-11.4 "defaults to") (D-052); when editing, the saved rows count as edited and "Föreslå igen" re-proposes; the info line on untouched accounts reads SettleState.untouched (nets without the settlement being edited, empty for a per-account settle-up, D-093, revising D-074's reading of Home balances); the discard dialog has no body text (D-080)
Confirm; Cancel
S-10
Settlement detail
Payer, recipient, amount, date, note, allocations per account; timeline; "missing" state with "Synka nu" (settle_detail_sync_now), online only (§9.9, D-080)
Edit; Delete
S-11
Analytics
Range selector (presets of AC-14.2 plus custom); granularity toggle month/week; measure toggle household / my share; group-by toggle category/account; total for range; chart 1 stacked bars over time with legend; chart 2 category breakdown; chart 3 account breakdown; empty state ("No expenses in this period"); a chart whose range has no expenses shows only the 0 baseline, with no gridline values (D-068). Total card (D-069): caption " · Hushållet" for the household measure and " · N veckor" in week granularity; for my share the label is "Totalt · Min del" so the caption does not repeat it; month presets show whole-month ranges ("maj–okt 2026"), custom ranges and week granularity show days (§9.8, D-077). Legend and stacking order: categories by sortOrder, accounts in AccountRepository.all() order (AnalyticsState.groups order is unspecified, §14.2.4). Loading means SyncSummary.lastSuccessAt == null and shows skeleton cards; offline looks like loaded; no error state (analytics are local, AC-14.1). Insights section (D-142, D-143), online only.
Change filters; tap a bar or a row to open S-12; pick a category in Insights; "Försök igen"
S-12
Analytics drill-down
Title naming the filter ("Mat & hushåll, oktober 2026"; a week as in docs/ux/strings.csv, "Vardag, vecka 41"; a category or account over the whole range ", ") (D-070); total; expense rows as in S-5. A category or account over the range adds, above the total, its own range selector (AC-14.2 presets plus custom) and month/week toggle, both starting from S-11's values and changing S-12 only; below the total, the line chart of AC-14.11; then the rows. A range with no expenses reads "No expenses in this period" (D-128). No loading and no error state (D-070)
Open expense; change range or granularity
S-13
Settings hub
Own avatar and name; partner's avatar and name; rows: Profile, Categories, Recurring expenses, Anslut kort ("Connect card", fourth row of the first group, subtitle "Ansluten · giltig till ", "Har upphört" for an expired connection (D-199) or "Läs in dina kortköp", D-172), Devices, Invite partner or Pair a device (admin), Sync status, About
Navigate
S-14
Profile
Avatar with change/remove; display name; "Needs connection" state
Save
S-15
Categories
List: icon, colour, name, archived section; editor: name (with "Reset name" for defaults), icon grid (33 icons of §9.7), colour swatches (14 tokens); delete or archive with the reason when delete is not allowed
Add; Edit; Reorder (drag); Archive; Delete
S-16
Sync status
Last successful sync time; pending changes count with a list (the shared ledger rows of :core:design, D-019, not a separate 36 dp row); "Changes not synced" list from sync_rejections with what was lost and why (copy per §14.4.6 reasons); clock-skew warning; "Sync failed" when online and SyncSummary.lastAttemptFailed (D-088); the stale-change copy names Rejection.actor, the partner when it is null (D-089); "Sync now", online only (§9.9, D-080); "Öppna" on a rejection shows the phone's lost version read-only (D-109): a dialog "Din version" (home_sync_lost_title, body home_sync_lost_body) with the ledger row it would have been (read-only: the row's onClick is null), the account, payer, split and notes (Rejection.lost, §14.2.4), the badge "Du tog bort den här" (home_sync_lost_deleted) when the refused change was a deletion, and "Visa nuvarande version" (home_sync_lost_show_current), which opens S-8 or S-10; when lost is null (unreadable payload, or its category or a user is not on the phone) "Öppna" opens the current version directly
Sync now; open a rejected change (read-only); dismiss it
S-17
Update available
Banner on Home with version name; dialog explaining "Install unknown apps" the first time; download progress; failure (download, or the install refused or failed with its reason, AC-16.7, D-155)
Update; Later
S-18
Device removed
Explanation that this phone's access was removed and local data deleted
Pair again (goes to S-1)
S-19
Recurring expenses
List: description, amount, account, "Every month on day 25", next date, paused badge; editor with the expense fields of S-7 (no date); account, payer and split as three fixed full-width rows (Konto, Betalt av, Delning; D-102, D-107) plus day of month (a new template defaults to today's day, AC-9.8, D-072), first month, optional last month, pause switch; a new template is sent as NewRecurringTemplate (RecurringForm.newTemplate()), an edit as RecurringTemplate (RecurringForm.template(original, …)), D-091; the discard dialog has no body text (D-080)
Add; Edit; Pause/Resume; Delete
S-20
Devices
Rows per AC-3.1; the app version shows Device.appVersionName ("Kvitt 1.1"), else the version code (D-090)
Revoke (with confirmation)
S-21
Invites
For the admin: "Invite partner" (only before the partner joins) and "Pair another device" for either user; a created invite shows the code in groups of 4, the link, expiry, and a share action (Android share sheet); list of pending invites with cancel
Create; Share; Cancel
S-22
About
App version name and code, server URL (the paired server; before pairing the build's release server, AppBuildInfo.releaseServerUrl, D-122), server version from E-1 /health, fetched when S-22 opens while online, shown as " (schema )" (settings_about_server_version_schema, "1.1.0 (schema 3)"); offline or when the call fails, the X-Kvitt-Server-Version header of the last response without the schema (AboutState, §14.2.4, D-110); no open-source licences row (OQ-5, D-099); after "Check for update": UpdateCheck.UpToDate shows "Du har den senaste versionen" (settings_about_up_to_date), UpdateCheck.Failed shows "Kunde inte nå servern" (common_could_not_reach_server) (AC-16.6, D-092); the button calls UpdateRepository.checkNow(), which always asks the server (D-140)
Check for update
S-23
Card purchases (Kortköp; :feature:cards, top-level, D-172)
Top app bar "Kortköp"; a header card with the card's artwork (small, CardArt), its name, "Bara du ser dina kortköp" and "Uppdaterad " (§9.8 relative form) with an "Uppdatera" button (a spinner and "Uppdaterar…" while E-42 runs); filter chips "Att hantera" (default), "Kopplade", "Ignorerade", "Alla", each with its count; the purchases of the filter, newest first, grouped by day (day headers in the §9.8 list form), each row: merchant (one line, ellipsis), amount (MoneyText, or " " with "Utländsk valuta"), "Preliminär" chip when pending, "Insättning" for a credit (only under "Alla"), and under "Att hantera" the match chip of CP-10 ("Koppla till " or " möjliga utgifter"); under "Kopplade" the line "Kopplad till · ". States: no connection (empty: large artwork, "Anslut ditt kort", "Se dina köp här och gör om dem till utgifter med några tryck. Bara du ser dem.", "Anslut kort" → S-24); first fetch ("Hämtar dina köp…" with a progress indicator, while connected and no fetch has finished); loaded; empty filter ("Inget att hantera" / "Nya kortköp visas här." for "Att hantera", "Inga köp här" for the others); offline (the stored list, "Uppdatera" disabled with "Kräver anslutning"; linking and ignoring still work, CP-9); expiring (warning banner "Kortanslutningen upphör " with "Förnya med BankID", CP-6); expired (banner "Kortanslutningen har upphört" with "Förnya med BankID"; list kept); fetch failed (banner "Kunde inte hämta köp. Senast uppdaterad |