From 407574a72b4aa1bfa7df02d938ea6d55ab759a19 Mon Sep 17 00:00:00 2001 From: Ricardo Canales Date: Tue, 16 Jun 2026 18:17:50 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(simplefin):=20bridge-aggregator=20mode?= =?UTF-8?q?l=20=E2=80=94=20one=20token=20in=20Settings,=20accounts=20disco?= =?UTF-8?q?vered=20on=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/rules/providers.md | 4 +- docs/simplefin-integration.md | 40 ++++- internal/admin/providers.go | 144 +++++++++++++++--- internal/db/queries/accounts.sql | 20 +++ internal/db/queries/bank_connections.sql | 14 ++ internal/provider/provider.go | 10 ++ internal/provider/simplefin/simplefin_test.go | 33 ++++ internal/provider/simplefin/sync.go | 36 ++++- internal/sync/engine.go | 48 ++++++ internal/sync/engine_integration_test.go | 71 +++++++++ .../components/pages/connection_new.templ | 71 +++++---- .../components/pages/providers.templ | 104 ++++++++----- .../components/pages/providers_types.go | 17 ++- static/js/admin/components/connection_new.js | 71 +-------- 14 files changed, 506 insertions(+), 177 deletions(-) diff --git a/.claude/rules/providers.md b/.claude/rules/providers.md index d7cb1845f..a0b70c5dd 100644 --- a/.claude/rules/providers.md +++ b/.claude/rules/providers.md @@ -50,11 +50,13 @@ Connection storage uses generic columns: `external_id` + `encrypted_credentials` - No SDK. Token-paste, poll-only protocol (`internal/provider/simplefin/`). Full details in `docs/simplefin-integration.md`. - **Connect = claim.** `ExchangeToken` receives the pasted base64 *setup token*, decodes it to a one-time claim URL, `POST`s for the **access URL** (`https://user:pass@host/path`), and stores the encrypted access URL as the credential. `CreateLinkSession` returns `ErrNotSupported`. +- **Singleton, managed in Settings.** The token is pasted in **Settings → Providers → SimpleFIN** (side drawer), not the per-bank Add-connection flow. At most one active SimpleFIN connection per household: `ProvidersSaveSimpleFINHandler` creates it on first claim or **rotates the credential in place** (`GetActiveConnectionByProvider` resolves the singleton), so re-pasting can't orphan accounts. The Add-connection flow shows SimpleFIN as a non-selectable row linking out to the bridge + Settings. - **`external_id` is minted** (`internal/shortid`) — SimpleFIN exposes no stable upstream id and a single access URL spans many banks. Reauth keeps the row and rotates only `encrypted_credentials` (`UpdateBankConnectionCredentials`). - **Sign is inverted vs Plaid**: SimpleFIN positive = money in; negate uniformly (no per-account-type rule, unlike Teller). - **Sync**: date-range polling chunked into **≤90-day windows** (bridge cap). Cursor = last-sync RFC3339 timestamp. `ReconcilesPendingByPolling()` is true. New connections default to a **daily** `sync_interval_override_minutes` (bridge expects ≤24 req/day). +- **Account discovery on sync.** Every sync returns the full account set in `SyncResult.Accounts`; the engine upserts it (metadata only, via `UpsertAccountMetadata` — never balances) before processing transactions, so banks linked at the bridge after connect flow in automatically. Fixed-account-set providers (Plaid, CSV) leave `SyncResult.Accounts` nil. - `HandleWebhook`/`CreateReauthSession` → `ErrNotSupported`; `RemoveConnection` → `nil` (no revoke endpoint). Account type defaults to `depository` (not exposed by SimpleFIN). -- **Opt-in**: gated by `SIMPLEFIN_ENABLED` env / `simplefin_enabled` app_config (no server-level credential). Admin-page connect/relink only in v1 — no REST endpoint, not in the hosted-link allowlist. +- **Enablement**: connecting in Settings sets `simplefin_enabled` app_config; `SIMPLEFIN_ENABLED=false` in env hard-disables. No manual on/off toggle. Admin-page connect/relink only in v1 — no REST endpoint, not in the hosted-link allowlist. ## CSV diff --git a/docs/simplefin-integration.md b/docs/simplefin-integration.md index 7199d1a13..d0ca23991 100644 --- a/docs/simplefin-integration.md +++ b/docs/simplefin-integration.md @@ -14,17 +14,34 @@ unsupported methods) and Teller (real HTTP client + date-range polling). | Aspect | SimpleFIN | |---|---| -| Connect | **Token paste.** User gets a one-time base64 *setup token* from their bridge's `/create` page and pastes it. No SDK popup, no `CreateLinkSession`. | +| Connect | **Token paste, in Settings.** The single bridge token is pasted in **Settings → Providers → SimpleFIN** (a side drawer), not the per-bank Add-connection flow. No SDK popup, no `CreateLinkSession`. | | Credential | The claimed **access URL** `https://user:pass@host/path` (HTTP Basic creds embedded), stored AES-GCM encrypted. | | Fetch | **Poll only.** `GET {accessURL}/accounts?start-date&end-date&pending=1` returns accounts with nested transactions. No cursor, no webhooks. | -| Scope | **One access URL spans every bank** the user linked at the bridge (multi-bank aggregator) → one Breadbox connection, many accounts. | +| Scope | **One access URL spans every bank** the user linked at the bridge (multi-bank aggregator) → **one singleton** Breadbox connection, many accounts. | +| Account growth | Banks added at the bridge **after** connect are discovered **automatically on the next sync** — no new token. The engine upserts the re-discovered account set before processing transactions (see "Sync" below). | | Amount sign | **Inverted.** SimpleFIN positive = money in; Breadbox positive = debit (money out). The mapper negates uniformly. | -| Reauth | Paste a **new** setup token; the old access URL 403s once revoked. | -| Enablement | **Opt-in toggle** (`SIMPLEFIN_ENABLED` env / `simplefin_enabled` app_config). No server-level credential. | +| Reauth / rotate | Paste a **fresh** setup token in the Settings drawer; it rotates the existing connection's credential in place (the old access URL 403s once revoked). | +| Enablement | **Connecting in Settings enables it** (`simplefin_enabled` app_config is set on first claim). `SIMPLEFIN_ENABLED=false` in env hard-disables. No manual on/off toggle. | + +## Where the token lives (Settings, not Add-connection) + +SimpleFIN is an **aggregator bridge**, not a per-bank login like Plaid/Teller, so +its single token is managed in **Settings → Providers → SimpleFIN** (a side +drawer), and the household has **at most one** active SimpleFIN connection (the +"singleton"). The drawer's form claims a pasted token and either creates that one +bridge connection (first time) or **rotates the stored credential in place** when +one already exists — there's never a second SimpleFIN connection, so re-pasting a +token can't strand accounts on an orphan row. + +The **Add a connection** flow shows SimpleFIN as a *special, non-selectable row* +that links out to the bridge (to manage which banks are included) and to Settings +(to paste/rotate the token) — reinforcing that you don't add SimpleFIN banks one +at a time here. `internal/admin/providers.go::ProvidersSaveSimpleFINHandler` owns +the connect/rotate POST; `GetActiveConnectionByProvider` resolves the singleton. ## The connect flow (claim) -1. User pastes the setup token in the admin **Add connection** screen. +1. User pastes the setup token in **Settings → Providers → SimpleFIN**. 2. `ExchangeToken` base64-decodes it to a one-time **claim URL** and `POST`s to it (unauthenticated, empty body). A `200` returns the **access URL**; a `403` means the token was already used or is invalid. @@ -58,6 +75,19 @@ window each sync, the engine soft-deletes stale pending rows via the `ReconcilesPendingByPolling()` capability (shared with Teller — see `.claude/rules/providers.md`). +### Account discovery on every sync + +Each `/accounts` response carries the **full current account set**, so +`SyncTransactions` returns it in `SyncResult.Accounts` (deduped across windows). +The sync engine upserts that set — **metadata only, never balances** (the +`UpsertAccountMetadata` query; balances are owned by the balance-refresh path) — +onto the connection *before* processing transactions, then seeds its account +cache. The effect: a bank the user links at the bridge after connecting shows up +on the next sync, and its transactions resolve instead of being dropped on a +missing-account lookup. Existing accounts already on the connection are skipped +(no per-sync metadata write). Providers whose account set is fixed at connect +(Plaid, CSV) leave `SyncResult.Accounts` nil and the engine skips the step. + ### Rate limits The SimpleFIN Bridge expects **≤ 24 requests/day** ("daily updates"). New diff --git a/internal/admin/providers.go b/internal/admin/providers.go index 78d13ad69..f55497070 100644 --- a/internal/admin/providers.go +++ b/internal/admin/providers.go @@ -4,11 +4,11 @@ package admin import ( "encoding/base64" + "errors" "fmt" "io" "net/http" "os" - "strconv" "strings" "breadbox/internal/app" @@ -22,6 +22,7 @@ import ( "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -76,6 +77,31 @@ func ProvidersGetHandler(a *app.App, svc *service.Service, sm *scs.SessionManage SyncIntervalMinutes: a.Config.SyncIntervalMinutes, ProviderHealth: providerHealth, } + + // SimpleFIN bridge connection status drives the drawer's connect-vs-rotate + // form. One access URL spans every bank, so there's at most one active + // SimpleFIN connection per household. + if conn, err := a.Queries.GetActiveConnectionByProvider(ctx, db.ProviderTypeSimplefin); err == nil { + props.SimpleFINConnected = true + props.SimpleFINInstitution = conn.InstitutionName.String + props.SimpleFINAccounts = conn.AccountCount + props.SimpleFINConnShortID = conn.ShortID + } else if !errors.Is(err, pgx.ErrNoRows) { + a.Logger.Error("look up simplefin connection", "error", err) + } + + // Household members for the first-time connect form's owner - -

-
- - -
+ +} + +// connectSimplefinRow is the special non-radio entry in the connect picker. +// SimpleFIN isn't a per-bank login like Plaid/Teller — it's an aggregator +// bridge where one token spans every bank you link there — so instead of a +// selectable provider it points out to the bridge (to manage which banks are +// included) and to Settings (to paste the single token once). New accounts +// then flow in automatically on the next sync. +templ connectSimplefinRow() { +
+
+ @components.BrandIcon("simplefin", "w-5 h-5 shrink-0 mt-0.5") +
+
SimpleFIN
+

+ A bridge that reaches every bank you link with a single token. Add or + remove banks at the SimpleFIN bridge, then paste your token once in + Settings — new accounts sync automatically. +

+
- } +
} diff --git a/internal/templates/components/pages/providers.templ b/internal/templates/components/pages/providers.templ index e045be26c..6e74fe546 100644 --- a/internal/templates/components/pages/providers.templ +++ b/internal/templates/components/pages/providers.templ @@ -50,8 +50,8 @@ templ Providers(p ProvidersProps) { ID: "simplefin", Brand: "simplefin", Name: "SimpleFIN", - Desc: "Paste a setup token — multi-bank, poll-only", - Configured: p.SimpleFINEnabled, + Desc: "One token, every bank at your bridge — poll-only", + Configured: p.SimpleFINConnected, Health: p.ProviderHealth["simplefin"], }) } @@ -79,9 +79,11 @@ templ Providers(p ProvidersProps) { } -// providersSimplefinDrawer holds the SimpleFIN opt-in. SimpleFIN has no -// server-level credential — the access token is pasted per connection — so the -// only setting is whether the provider is offered when adding a connection. +// providersSimplefinDrawer manages the household's single SimpleFIN bridge +// connection. SimpleFIN has no server-level credential — one pasted setup token +// is claimed for an access URL spanning every bank linked at the bridge — so the +// drawer is a connect / rotate-token form, not an on/off toggle. Banks added at +// the bridge later are discovered automatically on the next sync. templ providersSimplefinDrawer(p ProvidersProps, h *service.ProviderHealthSummary) { @components.Drawer(components.DrawerProps{ ID: "provider-simplefin", @@ -92,15 +94,15 @@ templ providersSimplefinDrawer(p ProvidersProps, h *service.ProviderHealthSummar @components.DrawerHeader(components.DrawerHeaderProps{ BrandIcon: "simplefin", Title: "SimpleFIN", - Subtitle: "Token-paste, poll-only bank aggregation", + Subtitle: "One token — every bank you link at your bridge", }) - if p.SimpleFINFromEnv { + if p.SimpleFINFromEnv && !p.SimpleFINEnabled {
- @providerHealthBlock(h, p.SimpleFINEnabled) + @providerEnvNotice()

- SimpleFIN is { simplefinEnabledWord(p.SimpleFINEnabled) } via the + SimpleFIN is disabled via the SIMPLEFIN_ENABLED environment - variable and can't be changed here. + variable and can't be connected here.

@providerDrawerCloseFooter(false) @@ -110,48 +112,82 @@ templ providersSimplefinDrawer(p ProvidersProps, h *service.ProviderHealthSummar action="/settings/providers/simplefin" autocomplete="off" class="flex flex-col h-full" - x-data="providerCard" - data-provider="simplefin" + x-data="{ saving: false }" @submit="saving = true" >
+ if p.SimpleFINConnected { + @simplefinConnectedStatus(p) + }

- SimpleFIN connects to many banks through a one-time setup token you - paste when adding a connection — no app credentials to configure - here. Enable it to offer SimpleFIN on the connect screen. + SimpleFIN reaches every bank you link at your bridge through a single + access token. Add or remove banks at the bridge — Breadbox + discovers new accounts automatically on the next daily sync.

- @providerField(providerFieldProps{Label: "Offer SimpleFIN", For: "simplefin_enabled"}) { - + if !p.SimpleFINConnected && len(p.SimpleFINUsers) > 0 { + @providerField(providerFieldProps{Label: "Owner", For: "simplefin_user", Hint: "Who these accounts belong to. Leave as Whole household for a shared bridge."}) { + + } + } + @providerField(providerFieldProps{Label: simplefinTokenLabel(p.SimpleFINConnected), For: "simplefin_token", Hint: "Setup tokens are single-use — paste a fresh one each time."}) { + }

- New SimpleFIN connections sync once a day to stay within the bridge's - request budget. Get a token from + Get a token from bridge.simplefin.org - or your own SimpleFIN server. + or your own SimpleFIN server's /create page. + New connections sync once a day to stay within the bridge's request budget.

- @providerDrawerSaveFooter(p.SimpleFINEnabled) + @components.DrawerFooter() { + + + } } } } -// simplefinEnabledWord renders the env-managed state as a word for the -// read-only SimpleFIN drawer copy. -func simplefinEnabledWord(enabled bool) string { - if enabled { - return "enabled" +// simplefinConnectedStatus surfaces the existing bridge connection at the top +// of the drawer so a token paste reads as "rotate" rather than "add another". +templ simplefinConnectedStatus(p ProvidersProps) { +
+
+ Connected + if p.SimpleFINInstitution != "" { + { p.SimpleFINInstitution } + } +
+
+ { fmt.Sprintf("%d account(s) across your bridge", p.SimpleFINAccounts) } + · + Manage connection +
+
+} + +func simplefinTokenLabel(connected bool) string { + if connected { + return "Replace setup token" + } + return "Setup token" +} + +func simplefinSubmitLabel(connected bool) string { + if connected { + return "Replace token" } - return "disabled" + return "Connect bridge" } // --------------------------------------------------------------------- diff --git a/internal/templates/components/pages/providers_types.go b/internal/templates/components/pages/providers_types.go index b5223441d..f44047320 100644 --- a/internal/templates/components/pages/providers_types.go +++ b/internal/templates/components/pages/providers_types.go @@ -27,11 +27,22 @@ type ProvidersProps struct { TellerCertConfigured bool TellerWebhookConfigured bool - // SimpleFIN state. SimpleFIN has no server-level credential — it's an - // opt-in toggle deciding whether the provider is offered at connect time - // (the access token is pasted per connection). + // SimpleFIN state. SimpleFIN has no server-level credential — a single + // pasted setup token is claimed for an access URL that spans every bank the + // user links at their bridge. The drawer manages that one bridge connection + // (connect / rotate token) rather than offering a per-bank credential. SimpleFINEnabled bool SimpleFINFromEnv bool + // SimpleFINConnected is true when an active SimpleFIN bridge connection + // already exists; the drawer then shows status + a token-rotation form + // instead of the first-time connect form. + SimpleFINConnected bool + SimpleFINInstitution string // institution label of the bridge connection + SimpleFINAccounts int64 // number of accounts under the bridge connection + SimpleFINConnShortID string // short_id for the "view connection" link + // SimpleFINUsers is the household-member list for the first-time connect + // form's owner . - if users, err := a.Queries.ListUsers(ctx); err != nil { - a.Logger.Error("list users for simplefin connect form", "error", err) - } else { - for _, u := range users { - props.SimpleFINUsers = append(props.SimpleFINUsers, pages.ConnectionNewUser{ - ID: pgconv.FormatUUID(u.ID), - Name: u.Name, - }) - } - } - renderProviders(w, r, sm, tr, data, props) } } @@ -366,115 +340,39 @@ func readTellerCertFiles(r *http.Request) (certPEM, keyPEM []byte, err error) { } // ProvidersSaveSimpleFINHandler serves POST /admin/settings/providers/simplefin. -// -// SimpleFIN has no server-level credential: the user pastes a one-time setup -// token, which is claimed for a long-lived access URL spanning every bank they -// linked at their bridge. This handler claims that token and either creates the -// household's single SimpleFIN bridge connection (first time) or rotates the -// stored credential on the existing one (token refresh / reconnect). Banks the -// user later adds at the bridge flow in automatically on the next sync — no new -// token needed — because the sync engine re-discovers the account set. +// SimpleFIN has no server-level credential, so this only toggles whether the +// provider is offered at connect time. func ProvidersSaveSimpleFINHandler(a *app.App, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - if strings.EqualFold(strings.TrimSpace(os.Getenv("SIMPLEFIN_ENABLED")), "false") { - FlashRedirect(w, r, sm, "error", "SimpleFIN is disabled via the SIMPLEFIN_ENABLED environment variable.", "/settings/providers") - return - } - - token := strings.TrimSpace(r.FormValue("simplefin_token")) - if token == "" { - FlashRedirect(w, r, sm, "error", "Paste a SimpleFIN setup token to connect.", "/settings/providers") - return - } - - // Ensure the provider is initialized so we can claim. SimpleFIN needs no - // server credential beyond the encryption key, so the first connect - // bootstraps the enabled flag + provider instance before claiming. - if a.Providers["simplefin"] == nil { - if err := a.Queries.SetAppConfig(ctx, db.SetAppConfigParams{ - Key: "simplefin_enabled", - Value: pgconv.Text("true"), - }); err != nil { - a.Logger.Error("enable simplefin", "error", err) - FlashRedirect(w, r, sm, "error", "Failed to enable SimpleFIN.", "/settings/providers") - return - } - a.Config.SimpleFINEnabled = true - a.Config.ConfigSources["simplefin_enabled"] = "db" - if err := a.ReinitProvider("simplefin"); err != nil { - a.Logger.Error("reinit simplefin provider", "error", err) - } - } - - prov, ok := a.Providers["simplefin"] - if !ok { - FlashRedirect(w, r, sm, "error", "SimpleFIN provider unavailable — check that ENCRYPTION_KEY is set.", "/settings/providers") - return - } - - // Claim the token → access URL (+ account discovery). Setup tokens are - // single-use, so a stale or already-claimed token fails here. - conn, accounts, err := prov.ExchangeToken(ctx, token) - if err != nil { - a.Logger.Error("simplefin claim token", "error", err) - FlashRedirect(w, r, sm, "error", "Couldn't claim that setup token: "+err.Error(), "/settings/providers") + if os.Getenv("SIMPLEFIN_ENABLED") != "" { + FlashRedirect(w, r, sm, "error", "SimpleFIN is configured via environment variables and cannot be changed here.", "/settings/providers") return } - // Rotate in place when a bridge connection already exists (singleton — - // one access URL spans every bank, so there's never more than one). - existing, err := a.Queries.GetActiveConnectionByProvider(ctx, db.ProviderTypeSimplefin) - switch { - case err == nil: - if err := a.Queries.UpdateBankConnectionCredentials(ctx, db.UpdateBankConnectionCredentialsParams{ - ID: existing.ID, - EncryptedCredentials: conn.EncryptedCredentials, - }); err != nil { - a.Logger.Error("simplefin rotate credentials", "error", err) - FlashRedirect(w, r, sm, "error", "Failed to update the SimpleFIN token.", "/settings/providers") - return - } - FlashRedirect(w, r, sm, "success", "SimpleFIN token updated. Banks at your bridge sync on the next run.", "/settings/providers") + val := strings.ToLower(strings.TrimSpace(r.FormValue("simplefin_enabled"))) + enabled := val == "on" || val == "true" || val == "1" || val == "yes" + if err := a.Queries.SetAppConfig(ctx, db.SetAppConfigParams{ + Key: "simplefin_enabled", + Value: pgconv.Text(strconv.FormatBool(enabled)), + }); err != nil { + a.Logger.Error("save simplefin config", "error", err) + FlashRedirect(w, r, sm, "error", "Failed to save SimpleFIN setting.", "/settings/providers") return - case !errors.Is(err, pgx.ErrNoRows): - a.Logger.Error("look up simplefin connection", "error", err) - FlashRedirect(w, r, sm, "error", "Failed to read SimpleFIN connection state.", "/settings/providers") - return - } - - // First time: create the household's single bridge connection. user_id is - // nullable — an unset owner means a household-level (unattributed) bridge. - var userID pgtype.UUID - if uid := strings.TrimSpace(r.FormValue("user_id")); uid != "" { - if err := userID.Scan(uid); err != nil { - FlashRedirect(w, r, sm, "error", "Invalid household member.", "/settings/providers") - return - } } - result, err := a.Service.RegisterNewConnection(ctx, service.RegisterNewConnectionParams{ - UserID: userID, - Provider: "simplefin", - InstitutionID: "simplefin", - InstitutionName: conn.InstitutionName, - Conn: conn, - Accounts: accounts, - }) - if err != nil { - a.Logger.Error("register simplefin connection", "error", err) - FlashRedirect(w, r, sm, "error", "Failed to save the SimpleFIN connection.", "/settings/providers") - return + a.Config.SimpleFINEnabled = enabled + a.Config.ConfigSources["simplefin_enabled"] = "db" + if err := a.ReinitProvider("simplefin"); err != nil { + a.Logger.Error("reinit simplefin provider", "error", err) } - // SimpleFIN's bridge expects ≤24 requests/day, so put the connection on a - // shared daily schedule rather than the household default. - if err := a.Service.AssignConnectionToManagedSchedule(ctx, pgconv.FormatUUID(result.ID), simplefinScheduleName, simplefinScheduleCron); err != nil { - a.Logger.Warn("assign simplefin daily schedule", "error", err, "connection_id", pgconv.FormatUUID(result.ID)) + msg := "SimpleFIN disabled." + if enabled { + msg = "SimpleFIN enabled. It's now available when adding a connection." } - - FlashRedirect(w, r, sm, "success", fmt.Sprintf("SimpleFIN connected — %d account(s) discovered.", len(accounts)), "/settings/providers") + FlashRedirect(w, r, sm, "success", msg, "/settings/providers") } } diff --git a/internal/db/queries/bank_connections.sql b/internal/db/queries/bank_connections.sql index b1fa6b1cd..a9a2b7b48 100644 --- a/internal/db/queries/bank_connections.sql +++ b/internal/db/queries/bank_connections.sql @@ -82,20 +82,6 @@ WHERE id = $1; -- name: GetBankConnectionByExternalID :one SELECT * FROM bank_connections WHERE provider = $1 AND external_id = $2; --- name: GetActiveConnectionByProvider :one --- Returns the most recent non-disconnected connection for a provider plus its --- account count and owner name. Used for singleton-style providers like --- SimpleFIN, where one access URL (one connection) spans every bank the user --- links at their bridge — so the settings drawer manages that single --- connection (connect / rotate token) rather than creating one per bank. -SELECT bc.*, u.name AS user_name, - (SELECT COUNT(*) FROM accounts a WHERE a.connection_id = bc.id) AS account_count -FROM bank_connections bc -LEFT JOIN users u ON bc.user_id = u.id -WHERE bc.provider = $1 AND bc.status != 'disconnected' -ORDER BY bc.created_at DESC -LIMIT 1; - -- name: CountConnectionsNeedingAttention :one SELECT COUNT(*) FROM bank_connections WHERE status IN ('error', 'pending_reauth'); diff --git a/internal/templates/components/pages/connection_new.templ b/internal/templates/components/pages/connection_new.templ index 46decf3a3..ff479360d 100644 --- a/internal/templates/components/pages/connection_new.templ +++ b/internal/templates/components/pages/connection_new.templ @@ -71,11 +71,11 @@ templ connectWizard(p ConnectionNewProps) { if p.HasTeller { @connectProviderRow("teller", "teller", "", "Teller", "Fast, developer-first US banks", !p.HasPlaid) } - @connectProviderRow("csv", "", "file-spreadsheet", "CSV", "Import a statement file manually", !p.HasPlaid && !p.HasTeller) + if p.HasSimpleFin { + @connectProviderRow("simplefin", "simplefin", "", "SimpleFIN", "Paste a token · privacy-first", !p.HasPlaid && !p.HasTeller) + } + @connectProviderRow("csv", "", "file-spreadsheet", "CSV", "Import a statement file manually", false) - if p.HasSimpleFin { - @connectSimplefinRow() - } if !p.HasPlaid || !p.HasTeller || !p.HasSimpleFin { @components.LucideIcon("settings", "w-3 h-3") @@ -125,38 +125,41 @@ templ connectWizard(p ConnectionNewProps) { - -} - -// connectSimplefinRow is the special non-radio entry in the connect picker. -// SimpleFIN isn't a per-bank login like Plaid/Teller — it's an aggregator -// bridge where one token spans every bank you link there — so instead of a -// selectable provider it points out to the bridge (to manage which banks are -// included) and to Settings (to paste the single token once). New accounts -// then flow in automatically on the next sync. -templ connectSimplefinRow() { -
-
- @components.BrandIcon("simplefin", "w-5 h-5 shrink-0 mt-0.5") -
-
SimpleFIN
-

- A bridge that reaches every bank you link with a single token. Add or - remove banks at the SimpleFIN bridge, then paste your token once in - Settings — new accounts sync automatically. -

-
- - @components.LucideIcon("external-link", "w-3.5 h-3.5") - Manage banks at SimpleFIN - - - @components.LucideIcon("key-round", "w-3.5 h-3.5") - Set up your token in Settings - + if p.HasSimpleFin { + -
+ }
} diff --git a/internal/templates/components/pages/providers.templ b/internal/templates/components/pages/providers.templ index 6e74fe546..e045be26c 100644 --- a/internal/templates/components/pages/providers.templ +++ b/internal/templates/components/pages/providers.templ @@ -50,8 +50,8 @@ templ Providers(p ProvidersProps) { ID: "simplefin", Brand: "simplefin", Name: "SimpleFIN", - Desc: "One token, every bank at your bridge — poll-only", - Configured: p.SimpleFINConnected, + Desc: "Paste a setup token — multi-bank, poll-only", + Configured: p.SimpleFINEnabled, Health: p.ProviderHealth["simplefin"], }) } @@ -79,11 +79,9 @@ templ Providers(p ProvidersProps) {
} -// providersSimplefinDrawer manages the household's single SimpleFIN bridge -// connection. SimpleFIN has no server-level credential — one pasted setup token -// is claimed for an access URL spanning every bank linked at the bridge — so the -// drawer is a connect / rotate-token form, not an on/off toggle. Banks added at -// the bridge later are discovered automatically on the next sync. +// providersSimplefinDrawer holds the SimpleFIN opt-in. SimpleFIN has no +// server-level credential — the access token is pasted per connection — so the +// only setting is whether the provider is offered when adding a connection. templ providersSimplefinDrawer(p ProvidersProps, h *service.ProviderHealthSummary) { @components.Drawer(components.DrawerProps{ ID: "provider-simplefin", @@ -94,15 +92,15 @@ templ providersSimplefinDrawer(p ProvidersProps, h *service.ProviderHealthSummar @components.DrawerHeader(components.DrawerHeaderProps{ BrandIcon: "simplefin", Title: "SimpleFIN", - Subtitle: "One token — every bank you link at your bridge", + Subtitle: "Token-paste, poll-only bank aggregation", }) - if p.SimpleFINFromEnv && !p.SimpleFINEnabled { + if p.SimpleFINFromEnv {
- @providerEnvNotice() + @providerHealthBlock(h, p.SimpleFINEnabled)

- SimpleFIN is disabled via the + SimpleFIN is { simplefinEnabledWord(p.SimpleFINEnabled) } via the SIMPLEFIN_ENABLED environment - variable and can't be connected here. + variable and can't be changed here.

@providerDrawerCloseFooter(false) @@ -112,82 +110,48 @@ templ providersSimplefinDrawer(p ProvidersProps, h *service.ProviderHealthSummar action="/settings/providers/simplefin" autocomplete="off" class="flex flex-col h-full" - x-data="{ saving: false }" + x-data="providerCard" + data-provider="simplefin" @submit="saving = true" >
- if p.SimpleFINConnected { - @simplefinConnectedStatus(p) - }

- SimpleFIN reaches every bank you link at your bridge through a single - access token. Add or remove banks at the bridge — Breadbox - discovers new accounts automatically on the next daily sync. + SimpleFIN connects to many banks through a one-time setup token you + paste when adding a connection — no app credentials to configure + here. Enable it to offer SimpleFIN on the connect screen.

- if !p.SimpleFINConnected && len(p.SimpleFINUsers) > 0 { - @providerField(providerFieldProps{Label: "Owner", For: "simplefin_user", Hint: "Who these accounts belong to. Leave as Whole household for a shared bridge."}) { - - } - } - @providerField(providerFieldProps{Label: simplefinTokenLabel(p.SimpleFINConnected), For: "simplefin_token", Hint: "Setup tokens are single-use — paste a fresh one each time."}) { - + @providerField(providerFieldProps{Label: "Offer SimpleFIN", For: "simplefin_enabled"}) { + }

- Get a token from + New SimpleFIN connections sync once a day to stay within the bridge's + request budget. Get a token from bridge.simplefin.org - or your own SimpleFIN server's /create page. - New connections sync once a day to stay within the bridge's request budget. + or your own SimpleFIN server.

- @components.DrawerFooter() { - - - } + @providerDrawerSaveFooter(p.SimpleFINEnabled) }
} } -// simplefinConnectedStatus surfaces the existing bridge connection at the top -// of the drawer so a token paste reads as "rotate" rather than "add another". -templ simplefinConnectedStatus(p ProvidersProps) { -
-
- Connected - if p.SimpleFINInstitution != "" { - { p.SimpleFINInstitution } - } -
-
- { fmt.Sprintf("%d account(s) across your bridge", p.SimpleFINAccounts) } - · - Manage connection -
-
-} - -func simplefinTokenLabel(connected bool) string { - if connected { - return "Replace setup token" - } - return "Setup token" -} - -func simplefinSubmitLabel(connected bool) string { - if connected { - return "Replace token" +// simplefinEnabledWord renders the env-managed state as a word for the +// read-only SimpleFIN drawer copy. +func simplefinEnabledWord(enabled bool) string { + if enabled { + return "enabled" } - return "Connect bridge" + return "disabled" } // --------------------------------------------------------------------- diff --git a/internal/templates/components/pages/providers_types.go b/internal/templates/components/pages/providers_types.go index f44047320..b5223441d 100644 --- a/internal/templates/components/pages/providers_types.go +++ b/internal/templates/components/pages/providers_types.go @@ -27,22 +27,11 @@ type ProvidersProps struct { TellerCertConfigured bool TellerWebhookConfigured bool - // SimpleFIN state. SimpleFIN has no server-level credential — a single - // pasted setup token is claimed for an access URL that spans every bank the - // user links at their bridge. The drawer manages that one bridge connection - // (connect / rotate token) rather than offering a per-bank credential. + // SimpleFIN state. SimpleFIN has no server-level credential — it's an + // opt-in toggle deciding whether the provider is offered at connect time + // (the access token is pasted per connection). SimpleFINEnabled bool SimpleFINFromEnv bool - // SimpleFINConnected is true when an active SimpleFIN bridge connection - // already exists; the drawer then shows status + a token-rotation form - // instead of the first-time connect form. - SimpleFINConnected bool - SimpleFINInstitution string // institution label of the bridge connection - SimpleFINAccounts int64 // number of accounts under the bridge connection - SimpleFINConnShortID string // short_id for the "view connection" link - // SimpleFINUsers is the household-member list for the first-time connect - // form's owner