Requirements and tests
Each requirement and the backend test that proves it.
Every requirement for the app, with the test in msaving/backend/tests/ that proves it. A new requirement gets a row here (in a docs PR) and a test (in the code PR). Run the tests: Tests. They need a superuser on a throwaway local Postgres 17, never a database you care about.
| ID | Requirement | Test |
|---|---|---|
| Accounts and money | ||
| R-ACC-1 | Users have several accounts: cash, checking, savings, investment, credit card, loan, money lent, other asset, other liability | test_balances_and_ledger_rules, test_lending_and_borrowing, test_people_and_lending |
| R-ACC-2 | Balances and net worth come from transaction history, never typed in | test_balances_and_ledger_rules |
| R-ACC-4 | An account can be left out of net worth; it still shows with its balance and the flag, and its spending still counts (from the old app) | test_features_from_the_old_app |
| R-ACC-5 | A new account can start with an opening balance (negative for what’s owed); retrying the request doesn’t add it twice | test_accounts_with_opening_balances |
| R-ACC-3 | A book has a currency chosen at setup; new accounts use it unless another is picked; an account’s currency is locked once used; changing the book’s currency converts its budgets | test_account_currency_defaults_and_locks, test_exchange_rates |
| Transactions | ||
| R-TXN-1 | Transactions are spending, income or transfers between accounts (plus refunds, opening balances, adjustments) | test_balances_and_ledger_rules, test_dashboard_and_budgets |
| R-TXN-2 | A transfer can’t create or destroy money; an expense is money out | test_balances_and_ledger_rules |
| R-TXN-3 | Each transaction can have a user-made category and an optional note | test_balances_and_ledger_rules, test_dashboard_and_budgets, test_csv_export |
| R-CAT-1 | Categories can be archived: hidden, history kept, their budgets stop showing | test_archived_categories, test_categories |
| R-CAT-2 | A category can have a colour (#rrggbb), exported with it (from the old app) |
test_features_from_the_old_app |
| R-CAT-3 | A new book can start with starter categories and sub-categories in the user’s language, only in a book the user can edit (from the old app) | test_features_from_the_old_app |
| R-CAT-4 | Transfers can carry a transfer category, and only transfers can; it never counts as spending or takes a budget (from the old app, owner’s choice) | test_features_from_the_old_app, test_categories |
| R-CAT-5 | Categories form a tree that can’t loop; deleting one with transactions archives it instead and says so; one with sub-categories can’t be deleted | test_categories |
| R-TXN-4 | Transactions can be edited, with full edit history and a modified date | test_edit_history_and_conflicts, test_transactions_and_two_browsers |
| R-TXN-5 | The transaction list filters by account (with a running balance), category (sub-categories included), date range, type and text in payee or note, newest first, a page at a time | test_transactions_and_two_browsers |
| Books and users | ||
| R-BOOK-1 | A user can have several books, each a separate set of accounts | test_books_are_isolated, test_books |
| R-BOOK-2 | Books are separate: no transfers between books, and so none between users | test_books_are_isolated, test_sharing_roles |
| R-USER-1 | Every user has a unique username | test_usernames_are_unique_and_lowercase |
| R-SHARE-1 | A book can have several users, added by username, with owner/editor/viewer roles; nobody becomes a member without accepting | test_sharing_roles, test_shared_books |
| R-SHARE-2 | Nobody sees a book they aren’t an accepted member of, including a creator who has left; deleting a book deletes everything in it | test_sharing_roles, test_audit_log_is_owner_only, test_shared_books |
| R-SHARE-3 | Two users keep one book together; a viewer reads it and can’t change it; the last owner can’t leave or step down | test_shared_books |
| R-SHARE-4 | Inviting by username is rate limited, and an unknown username and an existing member get the same answer | test_book_invites_are_rate_limited, test_shared_books |
| Several devices | ||
| R-DEV-1 | Signed in on several devices: no lost edits, no duplicates from retries, balances stay right | test_edit_history_and_conflicts, test_parallel_adds_all_count, test_pooled_connection_does_not_leak_user, test_books, test_accounts_with_opening_balances, test_transactions_and_two_browsers |
| People | ||
| R-LEND-1 | Loans and money lent are tied to a named person, with who owes whom | test_lending_and_borrowing, test_people_and_lending |
| Exchange rates | ||
| R-FX-1 | Rates live in their own table and update automatically for everyone, free, from a second no-key source if the first fails | test_fx_updater_job, test_exchange_rates, test_dashboard_converts_at_todays_rate, test_fx_sources_and_fallback |
| R-FX-2 | Users can set their own rate, applying only to that book | test_exchange_rates |
| R-FX-3 | Every currency in use (ISO 4217) can be a book’s or an account’s currency, with the right number of decimals | test_full_currency_list |
| R-FX-4 | A book’s net worth includes accounts in other currencies at that day’s rate; accounts with no rate are left out and counted | test_dashboard_converts_at_todays_rate |
| Dashboard and budgets | ||
| R-DASH-1 | The home screen is a spending overview | test_dashboard_and_budgets |
| R-BUD-1 | Budgets per category: daily, weekly, monthly, quarterly, yearly | test_budget_periods, test_dashboard_and_budgets, test_budgets |
| R-BUD-2 | A budget counts its category’s sub-categories, one per category and period, only on expense categories; the dashboard shows each one’s progress | test_budgets |
| Your data | ||
| R-EXP-1 | Users can download all their data as CSV files | test_csv_export, test_profile_and_export |
| R-EXP-2 | The export is readable, not a copy of the database tables (no internal ids) | test_csv_export |
| R-DEL-1 | Users can delete their account, restore it within 30 days, then it’s deleted for good with its password, two-factor secret, recovery codes and sessions (and again after a backup restore) | test_account_deletion_restore_and_purge, test_purge_removes_sign_in, test_close_and_restore |
| R-DEL-2 | In shared books, a deleted user shows as “Deleted user”, each keeping their own id; their username can be taken by a new account with a new id | test_account_deletion_restore_and_purge |
| R-DEL-4 | Every account closure and restore is logged outside the database (ids and times only), after it’s committed | test_close_and_restore |
| R-LANG-1 | Vietnamese first, English second: each user has a language (Vietnamese by default) and the export follows it | test_csv_export |
| Getting in and signing in | ||
| R-AUTH-1 | A new server’s first account needs the setup code from the server’s log and becomes the admin; only once | test_setup_creates_the_admin_once, test_migrate_builds_a_server_and_is_safe_to_repeat |
| R-AUTH-2 | Sign-up is by invite only unless the admin opens it; invites work once, expire and can be revoked; the database refuses an account with no way in | test_invite_only_server_needs_an_invite, test_revoked_and_expired_invites, test_admin_can_open_sign_up, test_temp_tables_cannot_shadow_real_ones |
| R-AUTH-3 | Sign-in by username and password (10+ characters, argon2id); a wrong username and a wrong password look the same | test_sign_in_and_wrong_credentials, test_sign_up_rejects_bad_input, test_username_taken |
| R-AUTH-4 | Sessions are stored only as hashes, end after 30 days unused, on sign-out, sign-out-others and a password change | test_tokens_are_stored_hashed, test_expired_sessions_are_refused, test_sign_out_and_sign_out_others, test_change_password_signs_out_other_devices, test_bad_tokens_are_refused |
| R-AUTH-5 | Optional two-factor sign-in with one-time recovery codes; turning it off needs a current code | test_two_factor_with_recovery_codes |
| R-AUTH-6 | No email: the admin’s one-time reset link (or the operator’s command) sets a new password, turns two-factor off and ends every session | test_admin_disables_and_resets |
| R-AUTH-7 | Only the admin uses the admin tools; disabling an account signs it out at once; the admin can’t disable themselves | test_only_the_admin_has_admin_tools, test_admin_disables_and_resets |
| R-AUTH-8 | Sign-in, sign-up, setup, resets, codes and username changes are rate limited | test_rate_limits, test_username_changes_are_rate_limited |
| Running an instance | ||
| R-INST-1 | A server describes itself without sign-in (name, versions, sign-up mode, setup needed) | test_instance_needs_no_token |
| R-INST-2 | The server builds its database and logins on start, safe to repeat, with a backup before applying new migrations to an existing one | test_migrate_builds_a_server_and_is_safe_to_repeat, test_launcher_builds_login_urls |
| R-INST-3 | The server serves the web app with a strict Content-Security-Policy; unknown API paths don’t fall through to it | test_web_app_files_and_routes, test_unknown_api_paths_are_404_not_the_web_app |
| R-INST-4 | Daily jobs run at the configured hour | test_scheduler_runs_daily_at_the_hour |
| R-INST-5 | The admin sees the newest release; one-click update only through the operator’s updater helper | test_update_info_and_one_click_update, test_versions_compare |
| R-INST-6 | A nightly backup restores the app with its data, grants, policies and migration list, with the documented commands, and never shows the database password | test_backup_restores_everything, test_restore_commands_documented, test_password_goes_to_pg_dump_only_as_pgpassword, test_errors_never_show_the_password, test_old_dumps_are_deleted_after_a_good_one, test_failed_backup_leaves_nothing_and_hides_the_password |
| R-INST-7 | Timezone aliases a browser reports become names the database knows; unknown names are refused | test_timezone_aliases_become_names_the_database_knows |
| Security | ||
| R-SEC-1 | The database itself refuses access outside your books, even if app code has a bug | every test that uses a second user |
| R-SEC-2 | Background jobs (rates, deletion) can only do their one job; no login is superuser or bypasses RLS | test_fx_updater_job, test_account_deletion_restore_and_purge, test_migrate_builds_a_server_and_is_safe_to_repeat |
| R-SEC-3 | Supabase’s browser roles (on an operator’s own Supabase) can’t reach any table or function, including ones later migrations add | test_supabase_api_roles_have_no_access |
| R-SEC-4 | A session’s own temporary tables can’t stand in for real ones inside the database’s checks | test_temp_tables_cannot_shadow_real_ones |
| R-SEC-5 | Who created, edited or invited something, and who is the server’s admin, is set by the database; the app can’t forge it | test_sharing_roles, test_nobody_can_make_themselves_admin |
| R-SEC-6 | In a browser only the server’s own web app can use the API; phones (no Origin) connect freely; no CORS | test_only_this_servers_web_app_and_any_phone, test_no_cors_by_default |
| R-SEC-7 | One user’s profile and sign-in data are never visible to another | test_me_returns_only_my_profile, test_sharing_roles |
Not covered by these tests: the live exchange-rate call, HTTPS through Caddy, Tailscale or Cloudflare Tunnel, and the updater’s Docker commands (checked by hand before a release; see Releases). The web app’s string files are checked by its own tests.