feat: Multiple Bank Accounts (IBAN/BIC) by contract - #511
Open
aaronspring wants to merge 12 commits into
Open
Conversation
Replace the single 1:1 bank account with a list owned by the user: - User.bank_accounts (new relationship) replaces the bank_account_id column; is_default flags the account used when a contract names none. - Contract gains bank_account_id so a contract can invoice into a specific account; e-invoice payee and rendered invoices resolve contract account first, then the user's default. - Migration backfills ownership (bankaccount.user_id) and marks the previously linked account as default, dropping user.bank_account_id. - Settings profile gains a multi-account editor (default radio, add/ remove). Contract form offers an Invoicing Bank Account dropdown. - Removing an account a contract still invoices from is refused. - Demo user gets Giro (default) + Wise USD accounts; the USD contract invoices into the Wise account. Closes tuttle-dev#510
aaronspring
force-pushed
the
feat/510-multiple-bank-accounts
branch
from
August 10, 2026 18:11
19f1475 to
6639a02
Compare
Contributor
Author
UI verification — multiple bank accountsFocused Electron smoke on PR #511 using the Harry Tuttle demo user. Settings → Profile — multiple accountsThe new Bank Accounts card shows the default-account radio, account details, and the add-account flow. Contracts → New — per-contract account selectionThe contract form exposes “Invoicing Bank Account”, with “Use my default account” as the safe fallback. Invoice — non-default accountThe Harry Tuttle demo’s USD invoice for Ductwork Security Inc. renders with the dedicated Wise USD account rather than the default Giro account. Invoice Checks
|
Contributor
Author
|
Note: Account name shouldn't be set to giro or wise USD (internal account names) but to Account Owner/Business name. |
aaronspring
commented
Aug 10, 2026
tuttle/demo.py names the demo accounts "Harry Tuttle" (the field is the account holder / bank name); the test fixtures still said "Giro". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
update_profile accepted a single ``bank_account`` dict as a partial update alongside the ``bank_accounts`` list. Nothing sends it: the settings view sends the list and onboarding goes through users.create, which keeps its own ``bank_account`` handling. Dropping the branch also retires the _ACCOUNTS_UNCHANGED sentinel that only existed to tell it apart from an absent key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_validate_bank_account re-queried the account before every save to produce a friendly error. The per-user engine runs with PRAGMA foreign_keys = ON (abstractions.py), so SQLite already rejects a bank_account_id that no longer exists and _describe_save_error turns the IntegrityError into the same message. Widen that message to name the bank account, since contract now has two foreign keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both User helpers were loops that a generator expression states in one line, and the invoice payee lookup wrapped one attribute access in a bare `except Exception`. Invoice.contract is lazy="subquery", so it is loaded with the invoice and cannot raise on access; a None check is enough. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- bank_accountsError was declared, initialised and rendered but never assigned: update_profile's refusal to remove an in-use account already arrives as res.error and shows up in profileStatus. - The .map() after the remove .filter() was a no-op — with one account it mapped over an empty array, otherwise it re-set is_default to itself. The backend re-picks a default when none is flagged. - Three identical setProfile spreads collapse into patchAccount(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ui/driver.mjs was a local Playwright REPL used to smoke-test this branch: no package.json script, no CI job, no test references it, and it imports playwright-core while the project depends on playwright. Keep that kind of scratch tooling out of the tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f87515d1d068 rewrites data, and nothing pinned that behaviour: the existing chain tests only prove row counts survive. Seed the old 1:1 link at the previous head and assert the linked account becomes the owned default, an unreferenced account is kept but not promoted, user.bank_account_id is gone, and both new FKs come back from the batch rebuild with CASCADE / SET NULL intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the _ACCOUNTS_UNCHANGED sentinel in 702c626 collapsed the two-line separator before _normalize_logo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




Closes #510
What
Support multiple bank accounts (IBAN/BIC) per user, with a default account used for invoicing and the option to pin a specific account onto a contract.
User.bank_accounts(list) replaces the singlebank_account_idcolumn;is_defaultflags the account used when a contract names none.Contract.bank_account_idselects the account invoices for that contract pay into.f87515d1d068: backfillsbankaccount.user_idfrom the old 1:1 link and marks the previously linked account as default, then dropsuser.bank_account_id. FKs:bankaccount.user_id→ CASCADE,contract.bank_account_id→ SET NULL.update_profileand surfaced in the profile status bar.Verification
uv run pytest— 558 passed, 1 skipped.cd ui && npm run build(tsc + vite) — clean.9cad5ae77a79) with the old 1:1 link, an unreferenced leftover account, and a contract:user_id=1, is_default=1; the unreferenced one is kept asuser_id=NULL, is_default=0(not deleted);user.bank_account_idis dropped with no row loss,PRAGMA foreign_key_checkis empty, no_alembic_tmp_*residue;CASCADE/SET NULLintact.tuttle_tests/test_migrations.pypin all of the above; they were mutation-checked (breaking the backfill fails them).Notes
tuttle/migrations/README.md; the generated revision was reviewed (no rename-as-drop traps, data backfill preserved).user_id=NULLand are not shown in the UI. They are preserved in the DB, not deleted.delete-orphanonUser.bank_accounts, so the in-use check inupdate_profileis what protects contracts from losing their account.