Skip to content

feat(accounts): per-account owner override#1909

Merged
canalesb93 merged 9 commits into
mainfrom
feat/account-owner-override
Jun 24, 2026
Merged

feat(accounts): per-account owner override#1909
canalesb93 merged 9 commits into
mainfrom
feat/account-owner-override

Conversation

@canalesb93

@canalesb93 canalesb93 commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Per-account owner override

Follow-up to #1877 (SimpleFIN sync-time account discovery). A single SimpleFIN bridge can pull in accounts belonging to different household members — but until now an account's owner was inherited from its bank connection and was immutable. This adds a nullable, provider-agnostic accounts.owner_user_id that overrides the connection owner for a single account.

How it works

Ownership resolves at read time:

COALESCE(t.attributed_user_id, a.owner_user_id, bc.user_id)

Precedence: per-transaction override (account links) > per-account owner (new) > connection owner. Because it's resolved at read time, reassigning an account's owner re-routes its existing transactions across every per-user total with no backfill.

Scope

  • Edit surface: account-detail Settings section only (an editable "Owner" select, editors only).
  • API reach: PATCH /api/v1/accounts/{id} gains owner_user_id; reads updated everywhere (transactions, summaries, category aggregation, account lists → MCP read tools inherit it).

Owner select in the account-detail Settings section

Changes

  • Migration — additive owner_user_id FK + partial index (mirrors transactions.attributed_user_id).
  • Queries — owner columns joined on the account SELECTs; ListAccountsByUser filters on the effective owner; new UpdateAccountOwner; account-links GetDependentUserID resolves the effective owner too. These account SELECTs LEFT JOIN bank_connections so an override still resolves when a connection was removed (connection_id SET NULL, history preserved).
  • ServiceUpdateAccountParams.OwnerUserID (set via short_id, clear via ""); owner fields on AccountResponse; COALESCE chain threaded through transactions / summaries / categories.
  • REST + CLIowner_user_id on the PATCH body (+ openapi); internal/client Account/AccountPatch parity.
  • Admin UIPOST /-/accounts/{id}/owner; inline Owner select replacing the old read-only "Family Member" field.
  • Docs — data-model, api-reference/endpoints, simplefin-integration (incl. a "Mixed-owner bridges" note), CLAUDE.md attribution gotcha.

Not changed (deliberate)

  • WipeUserData stays connection-scoped (deletes the connections you own + everything physically under them). Folding owner_user_id in would let "wipe Alice" delete accounts living under Bob's still-live bridge — strictly worse. The override is an attribution concept, not connection ownership.

Tests

  • Attribution re-routing (summary + list filter by effective owner), effective-owner account listing, set/clear roundtrip, and the connection-less override edge (connection_id NULL + owner set still surfaces under the override owner).
  • go build / vet / go test ./... clean; integration green across service / db / api / sync / admin.

🤖 Generated with Claude Code

canalesb93 and others added 9 commits June 16, 2026 18:17
…ounts discovered on sync

SimpleFIN is a bridge: one access URL spans every bank linked at the
bridge. The prior integration modeled it like a per-bank provider — it
prompted for a setup token in the Add-connection flow and never
re-discovered accounts after connect, so banks added at the bridge later
never appeared. This reworks it to reflect what SimpleFIN actually is.

Sync-time account discovery (the real backend gap):
- Provider.SyncResult gains an Accounts field. SimpleFIN populates the
  full deduped account set each sync; fixed-set providers (Plaid, CSV)
  leave it nil.
- The sync engine upserts discovered accounts (metadata only, never
  balances) via a new UpsertAccountMetadata query before processing
  transactions, refreshing its account caches. Banks linked at the
  bridge after connect now flow in automatically.

Settings-managed singleton token:
- The token is pasted in Settings → Providers → SimpleFIN (side drawer),
  not the per-bank flow. At most one active connection per household:
  GetActiveConnectionByProvider resolves the singleton, so the handler
  creates on first claim or rotates the credential in place — re-pasting
  can no longer orphan accounts.
- Add-connection shows SimpleFIN as a non-selectable informational row
  linking out to the bridge and to Settings, reinforcing the one-token
  mental model.

Tests: unit coverage for deduped account discovery across sync windows;
integration coverage for mid-sync account creation + transaction
resolution onto the new account. Docs and provider rules updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ect path + collapse dedup

Addresses two review findings on the bridge-model PR:

1. Singleton enforced in one place. ProvidersSaveSimpleFINHandler creates
   or rotates the household's single SimpleFIN connection, but the generic
   /-/exchange-token handler still accepted provider=simplefin and called
   RegisterNewConnection unconditionally — a second creation path that
   bypassed the GetActiveConnectionByProvider singleton and left duplicate
   connections the Settings drawer (LIMIT 1) couldn't see or manage. Reject
   simplefin there (Settings is the one connect surface; relink rotates in
   place via reauthSimplefin). This also lets us delete the now-unreachable
   SimpleFIN-specific branches in ExchangeTokenHandler (institution-name
   override + daily-schedule assignment).

2. One dedup authority. The provider re-collected the full account set on
   every sync window and deduped it with a map, duplicating the engine's
   generic cross-result dedup. The bridge returns the full set on every
   window (the date range only bounds nested transactions), so capture it
   once and let the engine remain the single dedup point.

Adds a unit test asserting /-/exchange-token rejects SimpleFIN with 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er-connection + sync-discovery

Reverses the singleton bridge model from this PR's earlier commits and
returns SimpleFIN to the normal per-connection flow (like Plaid/Teller):
each pasted setup token creates its own connection, attributed to a
household member, and a household can have multiple SimpleFIN bridges.
The singleton bought nothing that per-connection didn't already give —
one token already covers every bank at a bridge — while blocking
multi-bridge households and fighting the connections model.

Kept (the genuinely valuable half): sync-time account discovery. Each
SimpleFIN connection re-reads its full account set every sync and the
engine upserts new accounts (metadata only, never balances) before
processing transactions, so banks added at the bridge after connect flow
in automatically. This is model-agnostic and works the same per
connection.

Reverted to pre-PR state: the Settings token drawer + singleton
create-or-rotate handler, the GetActiveConnectionByProvider singleton
query, the /-/exchange-token SimpleFIN reject, and the connect-flow UI
(restores the inline token-paste step). Docs updated to describe
per-connection + sync discovery.

Per-account owner editing (for a single bridge shared across members) is
tracked as a separate, provider-agnostic follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address code-review findings on the discovery path:
- engine: reset pendingAccounts + seen-set on ErrSyncRetryable so a
  retried sync rebuilds the discovery buffers from scratch
- simplefin: restore union-across-windows account dedup (a single
  window can omit accounts; capture-once dropped them)
- integration test: assert a pre-existing account stays exactly one
  row after discovery, guarding against duplication

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtFunz2hhqiMwRnWk6gdvE
Add a nullable, provider-agnostic accounts.owner_user_id that overrides
the bank connection's owner for a single account. Needed when one
connection — e.g. a SimpleFIN bridge — spans accounts belonging to
different household members.

Attribution resolves at read time via
COALESCE(t.attributed_user_id, a.owner_user_id, bc.user_id), so
reassigning an account's owner re-routes its existing transactions
across every per-user total with no backfill. Precedence: per-transaction
override (account links) > per-account owner > connection owner.

- migration: additive owner_user_id FK + partial index
- queries: join owner columns on the account SELECTs; ListAccountsByUser
  now filters on the effective owner; new UpdateAccountOwner; account-links
  GetDependentUserID resolves the effective owner too
- service: UpdateAccountParams.OwnerUserID (set/clear), owner fields on
  AccountResponse; COALESCE chain threaded through transactions /
  summaries / category aggregation
- REST: owner_user_id on PATCH /accounts/{id} (+ openapi)
- admin UI: editable Owner select in the account-detail Settings section
  (editors only), POST /-/accounts/{id}/owner
- docs: data-model, api-reference/endpoints, simplefin-integration, CLAUDE.md
- tests: attribution re-routing, effective-owner account listing, set/clear

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtFunz2hhqiMwRnWk6gdvE
…erride

# Conflicts:
#	docs/simplefin-integration.md
The "Inherit from connection (<name>)" option overflowed the narrow
settings cell — daisy 5's appearance:base-select ignores text-overflow
on the <select>, so a long selected value bled over the dropdown arrow
and out of the card (verified: select scrollWidth 286 vs clientWidth
214; card gained 52px of horizontal overflow). Drop the appended
connection-owner name; "Inherit from connection" fits cleanly (214/214).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtFunz2hhqiMwRnWk6gdvE
@canalesb93
canalesb93 merged commit 1b48cca into main Jun 24, 2026
8 checks passed
@canalesb93
canalesb93 deleted the feat/account-owner-override branch June 24, 2026 06:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant