Skip to content

Commit 8fa9659

Browse files
aaronspringclaudeclstaudtcursoragent
authored
feat: Multiple Bank Accounts (IBAN/BIC) by contract (#511)
* feat: support multiple bank accounts with a per-contract default 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 #510 * fix: preserve partial legacy bank account updates * demo.py aktualisieren * demo.py aktualisieren * chore(tests): name fixture bank accounts after the account holder 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> * refactor(users): drop the unused legacy bank account payload branch 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> * refactor(contracts): let the FK reject a stale bank account _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> * refactor: shorten the payee account lookups 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> * fix(ui): drop dead bank account form state - 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> * chore: drop the ad-hoc electron driver harness 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> * test: cover the bank account ownership backfill 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> * style: restore the blank line ruff wants after the constants Removing the _ACCOUNTS_UNCHANGED sentinel in 702c626 collapsed the two-line separator before _normalize_logo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tests): update SEPA QR tests for the multi-account bank model user.bank_account is now a read-only property over bank_accounts, so these QR tests still assigned to a setter that no longer exists. Also update TestGeneratePaymentQR for generate_payment_qr's new signature, which now takes the resolved payee account instead of the user, so it respects the account named on the invoice's contract. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Christian Staudt <mail@clstaudt.me> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 50ca8dd commit 8fa9659

21 files changed

Lines changed: 519 additions & 131 deletions

File tree

templates/invoice-anvil/invoice.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@
127127
<tr>
128128
<td class="payment-info">
129129
<div>
130-
{% if user.bank_account %}IBAN: <strong>{{ user.bank_account.IBAN }}</strong><br>{% endif %}
130+
{% if bank_account %}IBAN: <strong>{{ bank_account.IBAN }}</strong><br>{% endif %}
131131
</div>
132132
</td>
133133
<td class="bold">{{ invoice.effective_due_date }}</td>
@@ -154,8 +154,8 @@
154154

155155
<div class="footer">
156156
<div class="footer-info">
157-
{% if user.bank_account %}<span>IBAN: {{ user.bank_account.IBAN }} </span> |
158-
<span>BIC: {{ user.bank_account.BIC }}</span> |{% endif %}
157+
{% if bank_account %}<span>IBAN: {{ bank_account.IBAN }} </span> |
158+
<span>BIC: {{ bank_account.BIC }}</span> |{% endif %}
159159
{% if seller_tax_id %}<span>{{ seller_tax_id_label }}: {{ seller_tax_id }}</span>{% endif %}
160160
</div>
161161
</div>

templates/invoice-bold/invoice.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,11 @@
101101
<div class="bottom-section">
102102
<div class="payment">
103103
<span class="label">{{ l.payment }}</span>
104-
{% if user.bank_account %}
104+
{% if bank_account %}
105105
<table class="payment-table">
106-
{% if user.bank_account.name %}<tr><td class="payment-key">{{ l.account_holder }}</td><td class="payment-val">{{ user.bank_account.name }}</td></tr>{% endif %}
107-
<tr><td class="payment-key">IBAN</td><td class="payment-val">{{ user.bank_account.IBAN }}</td></tr>
108-
{% if user.bank_account.BIC %}<tr><td class="payment-key">BIC</td><td class="payment-val">{{ user.bank_account.BIC }}</td></tr>{% endif %}
106+
{% if bank_account.name %}<tr><td class="payment-key">{{ l.account_holder }}</td><td class="payment-val">{{ bank_account.name }}</td></tr>{% endif %}
107+
<tr><td class="payment-key">IBAN</td><td class="payment-val">{{ bank_account.IBAN }}</td></tr>
108+
{% if bank_account.BIC %}<tr><td class="payment-key">BIC</td><td class="payment-val">{{ bank_account.BIC }}</td></tr>{% endif %}
109109
</table>
110110
{% endif %}
111111
{% if qr_code_data_uri %}
@@ -152,8 +152,8 @@
152152

153153
<div class="footer">
154154
<span>{{ user.name }}</span>
155-
{% if user.bank_account %}<span>IBAN: {{ user.bank_account.IBAN }}</span>
156-
{% if user.bank_account.BIC %}<span>BIC: {{ user.bank_account.BIC }}</span>{% endif %}{% endif %}
155+
{% if bank_account %}<span>IBAN: {{ bank_account.IBAN }}</span>
156+
{% if bank_account.BIC %}<span>BIC: {{ bank_account.BIC }}</span>{% endif %}{% endif %}
157157
{% if seller_tax_id %}<span>{{ seller_tax_id_label }}: {{ seller_tax_id }}</span>{% endif %}
158158
</div>
159159

templates/invoice-classic/invoice.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,8 @@
9393
<div class="payment">
9494
<span class="party-label">Payment Details</span>
9595
<div class="payment-value">
96-
{% if user.bank_account %}IBAN: {{ user.bank_account.IBAN }}
97-
{% if user.bank_account.BIC %}<br>BIC: {{ user.bank_account.BIC }}{% endif %}{% endif %}
96+
{% if bank_account %}IBAN: {{ bank_account.IBAN }}
97+
{% if bank_account.BIC %}<br>BIC: {{ bank_account.BIC }}{% endif %}{% endif %}
9898
</div>
9999
</div>
100100
<div class="totals">
@@ -134,8 +134,8 @@
134134

135135
<div class="footer">
136136
<span>{{ user.name }}</span>
137-
{% if user.bank_account %}<span>IBAN: {{ user.bank_account.IBAN }}</span>
138-
{% if user.bank_account.BIC %}<span>BIC: {{ user.bank_account.BIC }}</span>{% endif %}{% endif %}
137+
{% if bank_account %}<span>IBAN: {{ bank_account.IBAN }}</span>
138+
{% if bank_account.BIC %}<span>BIC: {{ bank_account.BIC }}</span>{% endif %}{% endif %}
139139
{% if seller_tax_id %}<span>{{ seller_tax_id_label }}: {{ seller_tax_id }}</span>{% endif %}
140140
</div>
141141

templates/invoice-grayshades/invoice.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,9 @@ <h1 class="invoice-title">{{ l.invoice_no }} {{ invoice.number }}</h1>
137137
<span class="footer-name">{{ user.name }}</span>
138138
</div>
139139
<div class="footer-center">
140-
{% if user.bank_account %}<span>IBAN: {{ user.bank_account.IBAN }}</span>{% endif %}
140+
{% if bank_account %}<span>IBAN: {{ bank_account.IBAN }}</span>{% endif %}
141141
{% if user.phone_number %}<span>Tel. {{ user.phone_number }}</span>{% endif %}
142-
{% if user.bank_account and user.bank_account.BIC %}<br><span>BIC: {{ user.bank_account.BIC }}</span>{% endif %}
142+
{% if bank_account and bank_account.BIC %}<br><span>BIC: {{ bank_account.BIC }}</span>{% endif %}
143143
{% if user.website %}<span>{{ user.website }}</span>{% endif %}
144144
</div>
145145
<div class="footer-right">

templates/invoice-minimal/invoice.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@
129129
<div class="payment">
130130
<span class="field-label">{{ l.payment }}</span>
131131
<div class="payment-value">
132-
{% if user.bank_account %}<div>IBAN: {{ user.bank_account.IBAN }}{% if user.bank_account.BIC %}&emsp;BIC: {{ user.bank_account.BIC }}{% endif %}</div>{% endif %}
132+
{% if bank_account %}<div>IBAN: {{ bank_account.IBAN }}{% if bank_account.BIC %}&emsp;BIC: {{ bank_account.BIC }}{% endif %}</div>{% endif %}
133133
</div>
134134
{% if qr_code_data_uri %}
135135
<img class="payment-qr" src="{{ qr_code_data_uri }}" alt="{{ l.payment }} QR code">
@@ -152,8 +152,8 @@
152152

153153
<div class="footer">
154154
<span>{{ user.name }}</span>
155-
{% if user.bank_account %}<span>IBAN: {{ user.bank_account.IBAN }}</span>
156-
{% if user.bank_account.BIC %}<span>BIC: {{ user.bank_account.BIC }}</span>{% endif %}{% endif %}
155+
{% if bank_account %}<span>IBAN: {{ bank_account.IBAN }}</span>
156+
{% if bank_account.BIC %}<span>BIC: {{ bank_account.BIC }}</span>{% endif %}{% endif %}
157157
{% if seller_tax_id %}<span>{{ seller_tax_id_label }}: {{ seller_tax_id }}</span>{% endif %}
158158
</div>
159159

templates/invoice-modern/invoice.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,11 @@
102102
<div class="bottom-section">
103103
<div class="payment">
104104
<span class="label">{{ l.payment }}</span>
105-
{% if user.bank_account %}
105+
{% if bank_account %}
106106
<table class="payment-table">
107-
{% if user.bank_account.name %}<tr><td class="payment-key">{{ l.account_holder if l.account_holder else "Account" }}</td><td class="payment-val">{{ user.bank_account.name }}</td></tr>{% endif %}
108-
<tr><td class="payment-key">IBAN</td><td class="payment-val">{{ user.bank_account.IBAN }}</td></tr>
109-
{% if user.bank_account.BIC %}<tr><td class="payment-key">BIC</td><td class="payment-val">{{ user.bank_account.BIC }}</td></tr>{% endif %}
107+
{% if bank_account.name %}<tr><td class="payment-key">{{ l.account_holder if l.account_holder else "Account" }}</td><td class="payment-val">{{ bank_account.name }}</td></tr>{% endif %}
108+
<tr><td class="payment-key">IBAN</td><td class="payment-val">{{ bank_account.IBAN }}</td></tr>
109+
{% if bank_account.BIC %}<tr><td class="payment-key">BIC</td><td class="payment-val">{{ bank_account.BIC }}</td></tr>{% endif %}
110110
</table>
111111
{% endif %}
112112
{% if qr_code_data_uri %}
@@ -154,8 +154,8 @@
154154

155155
<div class="footer">
156156
<span>{{ user.name }}</span>
157-
{% if user.bank_account %}<span>IBAN: {{ user.bank_account.IBAN }}</span>
158-
{% if user.bank_account.BIC %}<span>BIC: {{ user.bank_account.BIC }}</span>{% endif %}{% endif %}
157+
{% if bank_account %}<span>IBAN: {{ bank_account.IBAN }}</span>
158+
{% if bank_account.BIC %}<span>BIC: {{ bank_account.BIC }}</span>{% endif %}{% endif %}
159159
{% if seller_tax_id %}<span>{{ seller_tax_id_label }}: {{ seller_tax_id }}</span>{% endif %}
160160
</div>
161161

templates/invoice/invoice.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ <h1>{% if is_reminder %}{{ reminder_title }}{% else %}Invoice No. {{ invoice.num
108108
<tbody>
109109
<tr>
110110
<td>{{ invoice.effective_due_date }}</td>
111-
<td>{% if user.bank_account %}{{ user.bank_account.IBAN }}{% endif %}</td>
111+
<td>{% if bank_account %}{{ bank_account.IBAN }}{% endif %}</td>
112112
<td>{{ invoice.total | as_currency }}</td>
113113
</tr>
114114
</tbody>
@@ -136,7 +136,7 @@ <h1>{% if is_reminder %}{{ reminder_title }}{% else %}Invoice No. {{ invoice.num
136136
</td>
137137
<td>
138138
{% if seller_tax_id %}{{ seller_tax_id_label }}: {{ seller_tax_id }}<br>{% endif %}
139-
{% if user.bank_account %}IBAN: {{ user.bank_account.IBAN }}{% endif %}
139+
{% if bank_account %}IBAN: {{ bank_account.IBAN }}{% endif %}
140140
</td>
141141
</tr>
142142
</tbody>

tuttle/app/contracts/intent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def _describe_save_error(exc) -> str:
189189
if "NOT NULL" in detail:
190190
return "A required field is missing."
191191
if "FOREIGN KEY" in detail or "foreign key" in detail:
192-
return "The selected client is invalid."
192+
return "The selected client or bank account is invalid."
193193
return "Failed to save the contract."
194194

195195
toggle_complete_status = CrudIntent.toggle_completed

tuttle/app/users/intent.py

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,14 @@ def create(self, params: Dict[str, Any], **_kw) -> IntentResult:
294294
)
295295
bank = params.get("bank_account")
296296
if bank and any(bank.get(k) for k in ("IBAN", "BIC", "name")):
297-
user.bank_account = BankAccount(
298-
name=bank.get("name", ""),
299-
IBAN=bank.get("IBAN", ""),
300-
BIC=bank.get("BIC", ""),
301-
)
297+
user.bank_accounts = [
298+
BankAccount(
299+
name=bank.get("name", ""),
300+
IBAN=bank.get("IBAN", ""),
301+
BIC=bank.get("BIC", ""),
302+
is_default=True,
303+
)
304+
]
302305
s.add(user)
303306
s.commit()
304307
engine.dispose()
@@ -308,6 +311,54 @@ def create(self, params: Dict[str, Any], **_kw) -> IntentResult:
308311

309312
# -- profile update -------------------------------------------------------
310313

314+
def _resolve_bank_accounts(self, profile, raw):
315+
"""Resolve an incoming ``bank_accounts`` list into a full replacement.
316+
317+
Returns ``(error_msg, accounts)`` where a falsy ``error_msg`` means
318+
success.
319+
320+
Existing accounts keep their identity by id, so contracts referencing
321+
one keep pointing at it. Exactly one account ends up default (the
322+
flagged one, else the first). Removing an account a contract still
323+
invoices from is refused.
324+
"""
325+
rows = [r for r in raw if isinstance(r, dict) and any((r.get(k) or "").strip() for k in ("name", "IBAN", "BIC"))]
326+
327+
existing = {a.id: a for a in profile.bank_accounts if a.id is not None}
328+
flagged = [r for r in rows if r.get("is_default")]
329+
default_index = rows.index(flagged[0]) if flagged else (0 if rows else -1)
330+
331+
accounts = []
332+
for i, row in enumerate(rows):
333+
fields = {k: (row.get(k) or "").strip() for k in ("name", "IBAN", "BIC")}
334+
account = existing.get(row.get("id"))
335+
if account is None:
336+
account = BankAccount(**fields)
337+
else:
338+
for key, value in fields.items():
339+
setattr(account, key, value)
340+
account.is_default = i == default_index
341+
accounts.append(account)
342+
343+
removed_ids = {a.id for a in existing.values()} - {a.id for a in accounts}
344+
if removed_ids:
345+
from ...model import Contract
346+
347+
engine = sql_create_engine(f"sqlite:///{get_active_db()}")
348+
try:
349+
with SqlSession(engine) as s:
350+
referenced = s.exec(select(Contract).where(Contract.bank_account_id.in_(removed_ids))).all()
351+
finally:
352+
engine.dispose()
353+
if referenced:
354+
titles = ", ".join(c.title for c in referenced)
355+
return (
356+
"Cannot remove a bank account that is still used for invoicing "
357+
f"by: {titles}. Clear the account on the contract(s) first.",
358+
None,
359+
)
360+
return None, accounts
361+
311362
def update_profile(self, profile_data: Dict[str, Any]) -> IntentResult:
312363
"""Update the active user's profile from a dict."""
313364
ds = UserDataSource()
@@ -369,18 +420,12 @@ def update_profile(self, profile_data: Dict[str, Any]) -> IntentResult:
369420
else:
370421
profile.address = Address(**{k: v for k, v in addr.items() if k != "id" and not k.startswith("_")})
371422

372-
bank = profile_data.get("bank_account")
373-
if bank is not None:
374-
if profile.bank_account:
375-
for k in ("name", "IBAN", "BIC"):
376-
if k in bank:
377-
setattr(profile.bank_account, k, bank[k])
378-
else:
379-
profile.bank_account = BankAccount(
380-
name=bank.get("name", ""),
381-
IBAN=bank.get("IBAN", ""),
382-
BIC=bank.get("BIC", ""),
383-
)
423+
raw_accounts = profile_data.get("bank_accounts")
424+
if isinstance(raw_accounts, list):
425+
error, accounts = self._resolve_bank_accounts(profile, raw_accounts)
426+
if error:
427+
return IntentResult(was_intent_successful=False, error_msg=error)
428+
profile.bank_accounts = accounts
384429

385430
with ds.create_session() as s:
386431
s.add(profile)

tuttle/demo.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,8 @@ def create_usd_security_data(user: User) -> tuple[Project, Invoice, ClientContac
473473
volume=10,
474474
term_of_payment=14,
475475
billing_cycle=Cycle.monthly,
476+
# Harry keeps a dedicated USD account for dollar invoices.
477+
bank_account=next(a for a in user.bank_accounts if not a.is_default),
476478
)
477479

478480
project = Project(
@@ -847,11 +849,20 @@ def create_demo_user() -> User:
847849
postal_code="555555",
848850
country="Brazil",
849851
),
850-
bank_account=BankAccount(
851-
name="Giro",
852-
IBAN="BZ99830994950003161565",
853-
BIC="BANKINFO101",
854-
),
852+
bank_accounts=[
853+
BankAccount(
854+
name="Harry Tuttle",
855+
IBAN="BZ99830994950003161565",
856+
BIC="BANKINFO101",
857+
is_default=True,
858+
),
859+
BankAccount(
860+
name="Harry Tuttle",
861+
IBAN="US88589600002547441617",
862+
BIC="TRWIBEB1",
863+
is_default=False,
864+
),
865+
],
855866
)
856867
return user
857868

0 commit comments

Comments
 (0)