From f6f9feba8ad342e549fc7284fb4b448bc6cd08cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sun, 10 May 2026 22:13:57 +0200 Subject: [PATCH] Bank Sync cleanup (#1710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings/providers): surface connection status in section headers Lifts the per-panel status indicator up to each collapsed accordion header so admins can see at a glance which providers are connected without expanding every section. Connected providers sort first. - Add optional status: and meta: locals to settings/_section partial; pill hides via group-open:hidden when the section is expanded - New settings/providers/_status_pill partial (ok/warn/err/off states) - Add SettingsHelper#provider_summary to centralise the connected-vs-not logic already scattered across panel partials - Refactor show.html.erb to pass status to every section and sort family_panels by connection state - Add settings.providers.status.* i18n keys - Add system tests asserting pill renders and sort order https://claude.ai/code/session_01KW2HCN9rP1fiyQuw7Cju9D * feat(settings/providers): group providers into Connected and Available Partition the provider list in the controller into @connected_providers and @available_providers based on provider_summary status, and render each group under its own heading with a count. Auto-open the section when only one provider is connected. Adds an empty-state line when nothing is connected yet. Co-Authored-By: Claude Opus 4.7 * feat(settings/providers): health strip, action-needed group, and sync error surfacing - Extend provider_summary to return :err/:warn with meta text by checking latest sync per item (window function, same pattern as ProviderConnectionStatus) and Enable Banking session expiry within 7 days - Partition provider entries into three groups: Connected (:ok), Action needed (:warn/:err, auto-opened), Available (:off) - Add Settings::HealthSummary ViewComponent — four-tile grid showing Connected, Action needed, Errors, and Accounts synced counts - Render health strip directly under page description; omit Action needed heading when group is empty - Add i18n keys for tile labels, group heading, and all meta strings Co-Authored-By: Claude Sonnet 4.6 * feat(settings/providers): card grid for available providers with connect drawer - Add Provider::Metadata registry with static display data (region, kind, tier, maturity, logo) for all 11 providers - Add Settings::ProviderCard ViewComponent rendering logo square, name, Beta/Alpha pill, meta line (region · type · tier), tagline, and Connect link - Add connect_form action + route (GET /settings/providers/:key/connect_form) that opens the existing panel partial or config form in a DS::Dialog drawer - Replace the Available accordion loop with a 2-column responsive card grid; empty state when all providers are connected - Fix layout override: use turbo_rails/frame layout for frame requests so the drawer response is not wrapped in the full settings layout (was causing Turbo to pick the empty outer drawer frame instead of the filled one) - Add SyncAllProvidersJob and last_sync_all_attempted_at migration (sync-all throttle support) - Unify Connected + Action needed into a single "Your connections" section; items with warn/err status auto-open - Fix Enable Banking grouping: items with expired sessions were returning :off (Available) instead of :warn (Your connections); gate now checks any? instead of any?(&:session_valid?) - Add reconsent_required locale key for fully-expired EB sessions - Surface Beta/Alpha maturity pills on connected provider accordion rows via new badge: param on settings_section helper - Add i18n taglines for all 11 providers; add connect and empty_available keys Co-Authored-By: Claude Sonnet 4.6 * feat(settings): retire /settings/bank_sync; merge into providers page - Delete Settings::BankSyncController and its views (the providers page is now a strict superset of what bank_sync offered) - Add permanent 301 redirect: GET /settings/bank_sync → /settings/providers - Collapse nav to a single "Bank Sync" entry pointing at /settings/providers; remove the duplicate admin-only "Providers" entry from the Advanced section - Remove "Providers" from SETTINGS_ORDER; point "Bank Sync" at settings_providers_path for next/prev navigation - Rename page title to "Bank Sync"; replace admin-credential lede with user-facing copy ("Connect external accounts…") - Update breadcrumb: Home → Bank sync - Add controller test asserting 301 status and Location header Co-Authored-By: Claude Sonnet 4.6 * Migrations are 7.2 here * Minimize schema noise * Schema duplication * Small copy edits * Fix tests * Address provider settings review feedback * refactor(settings/providers): finish design-review cleanup pass Picks up the remaining items from Claude Design's review of #1710 that the previous review-feedback commit didn't cover. DS / casing - Sentence-case the page title ("Bank Sync" -> "Bank sync") and align the nav label. - Drop the card hover-lift (shadow-border-sm) in favour of bg-container-hover; per the DS, card hover is colour-only. - Whole-tile click target on each provider card — the inner "Connect ->" link was a hit-target inversion. - Set Sync all to whitespace-nowrap so the label stops wrapping at narrow viewport widths. UX simplifications - Drop the four health-summary tiles (per-row warn/err pills already surface the signal at the scale this app sees). Removes Settings::HealthSummary, the @health_counts controller block, and the now-unused health.* locale keys. - Hide "Your connections" heading + empty-state line when no providers are connected — the lede already invites a connect. - Drop the redundant "Free" tier from per-card meta lines (printed 10x for one fact); "Paid" still surfaces on Plaid. Tests updated to drop the obsolete tiles assertion and switch the provider-card click selector to look up the (now whole-card) anchor by provider name. * feat(settings/providers): replace Add another provider CTA with a search + kind filter Per the design review, the "Add another provider · Browse providers" card was a redirect to content one scroll-tick away. A search input plus kind chips lets users self-segment the catalog and is the right tool once it grows beyond the four to twelve providers we ship today. - New providers_filter Stimulus controller — case-insensitive free text search across name/region/kind, plus a chip group with All / Banks / Crypto / Investment that toggle visibility via Tailwind's `hidden` class. - _search_filters partial: search box (count-pluralized placeholder) + chip group, ARIA-labelled and aria-pressed for the chips. - ProviderCard exposes filter_data (target + name/region/kind data attrs) so the controller can match without re-rendering. - Lunchflow's `kind` was "Lunch" — switched to "Bank" so it falls under the Banks chip alongside its actual offering (it aggregates banks). - Drops the add_provider_cta partial and its locale entries; adds search_filters.* and an empty_filter message. * Private method fix * refactor(settings/providers): drawer cleanup, header lock-up, trust statement Per the design review's §07. - Drop the trailing "Configured / Not configured" footer status from every provider panel (binance, coinbase, coinstats, indexa_capital, lunchflow, mercury, simplefin, snaptrade, sophtron, provider_form). The parent details section's status pill already carries that signal; the footer was redundant — and the copy/styling was inconsistent across panels (free-text vs. dot pill, "configured" vs. "not connected"). - Connect drawer gets a header lock-up: small logo chip + provider name + maturity badge, mirroring the available-card layout. Implemented as _drawer_header partial; connect_form passes custom_header: true to DS::Dialog so we own the row. - Drawer footer trust statement: "Read-only — Sure can never move money. Stored encrypted." A single-line reassurance covering all panels. - Sentence-case the hardcoded primary buttons that were Title Case: "Save Configuration" -> "Save and connect" "Update Configuration" -> "Update connection" "Connect Bank" -> "Connect bank" Affects simplefin, lunchflow, enable_banking, provider_form. The i18n'd panels (binance, coinbase, coinstats, indexa_capital, mercury, snaptrade, sophtron) keep their existing keys. * chore(locales): drop unused provider-panel status strings Footer "Configured / Not configured" status was removed from each provider panel partial in the prior drawer-cleanup pass; the matching i18n keys are no longer referenced. Removing them across every locale to keep the catalogue clean. Dropped (15 keys × varying locale coverage, 36 line removals across 24 files): - coinstats_items.new.{status_configured_html, status_not_configured} - indexa_capital_items.panel.{status_configured_html, status_not_configured} - mercury_items.provider_panel.{configured_html, not_configured, accounts_link} - sophtron_items.sophtron_panel.status.{configured_html, not_configured} (parent `status:` removed where it became empty) - providers.snaptrade.{status_needs_registration, status_not_configured} (status_connected stays — still used by the lazy-load summary) - settings.providers.{binance_panel, coinbase_panel}.{status_connected, status_not_connected} * feat(settings/providers): connected-state polish per design §05 + Linked institutions rename Building the next phase of the design review. Pulls forward the slim health strip, denser connection rows, and "Linked institutions" heading rename — the small Phase A lift the designer flagged in §08 of the doc. - New _health_strip partial: single-line at-a-glance pulse — connected count + needs-attention count + accounts syncing + last-synced timestamp. Renders only when at least one provider is linked or needs action. - New _connection_row partial replaces the generic settings_section call for providers. Tighter rows: text-sm title (was text-lg), px-4 py-3.5 padding, single-line summary (chevron + name + maturity badge + meta + status pill + sync action). Warn/error rows get a coloured outline (border-warning/25 or border-destructive/25) so the at-risk row stands out without shouting. - "Sync all" button restyled to match the design's secondary button: text-primary, alpha-black-100 border, rounded-[10px], padding 7px 12px (was the broader px-3 py-1.5 ghost). - "Your connections" → "Linked institutions" heading, lifted from the designer's Phase-C reconciliation note. Primes users for the Option-C institution-search wizard six months early; existing i18n key stays as `groups.your_connections` for now to keep the rename to a single value flip. - Controller computes the new @health hash (connected, needs_attention, accounts_syncing, last_synced_at) feeding the strip; brings back the single accounts query that was removed with the four-tile component. System test updated for the new heading copy. * fix(settings/providers): align connected state with the final design mock Tightening the §05 polish to match the user-confirmed final design. - Revert "Linked institutions" → "Your connections". The §08 designer note about the Phase-A heading rename didn't carry forward to the final mock; keep the original wording. - Drop the warn/err auto-open on connection rows. The design shows Enable Banking collapsed with a warn-outline and a status pill — no auto-expanded form. Single-connection auto-open kept (handy when the page is otherwise empty). - Hide the "accounts syncing" segment in the health strip when the count is 0 — the design mock assumes a populated number; an always-visible "0 accounts syncing" reads as a placeholder. - Strip the leading "about " from `time_ago_in_words` everywhere the result is shown to the user (health strip "Last synced %{time} ago" plus per-row "Synced %{time} ago" meta). Matches the design's shorter copy. * refactor(settings/providers): tighten paddings, dedupe maturity badge, semantic + a11y fixes Pixel-level alignment to the design's §05 mock + cleanup from a DS audit pass. Paddings, margins, font sizes - Health strip: my-4 → mt-4 mb-5 to match the design's 16px / 20px vertical breathing room. - Search filters bar: gap-2 → gap-2.5; mt-2 → mt-5 mb-3 (was missing the 12px bottom margin entirely). - Search box: rounded-lg → rounded-[10px]; px-3 py-2 → px-[14px] py-[9px]. Search icon downsized w-4 → w-3.5 to match. - Chip group: p-1 → p-[3px]; rounded-lg → rounded-[10px]. - Chip: py-1 → py-[5px]; rounded-md → rounded-lg. - Group heading: mt-2 → mt-[18px]; mb-1 → mb-1.5. - Status pill: text-xs → text-[11px]. - Provider card: gap-3 → gap-2.5 (outer + top); name gets explicit text-sm; tagline + foot 14px → 13px; arrow icon w-4 → w-3.5. - Sync icon button: p-1 → fixed w-7 h-7 (28×28) so the row hit target matches the design's column width. - Connect drawer header logo glyph: text-[10px] → text-xs (matches the available card's logo-glyph treatment). Component / partial cleanup (DS audit follow-ups) - New _maturity_badge partial replaces the inline span that was duplicated in 3 places (_connection_row, _drawer_header, provider_card.html.erb). - Settings::ProviderCard.maturity_label class method centralizes the MATURITY_LABELS lookup; callers no longer reach into the constant. - _connection_row title:

(the row sits inside the "Your connections" h2 group heading; nested h2s flattened the outline). - show.html.erb encryption error:

for the same reason. Locale - Drop orphaned keys: settings.providers.groups.connected and groups.needs_attention (no view code uses them) plus the leftover show.coinbase_title block. - Health strip "needs reconsent" → "needs attention" so the strip copy lines up with the per-row status pill ("Action needed") and the original group heading wording. A11y - focus-visible:ring-2 on chip buttons, provider-card link, and focus-within:ring-2 on the search input wrapper. Keyboard users now get a visible focus state. - Search input: explicit autocomplete="off" (erb_lint hint). * fix(settings/providers): icons + search input height - Icons were rendering at 20px because the application_helper's `icon` default size (`md` = w-5 h-5) was beating the inline class override in compiled CSS source order. Pass `size: "sm"` and use the project's `!w-3.5 !h-3.5` important-prefix pattern (precedent: dashboard.html.erb) so chevron, refresh-cw, search, check, circle-alert, and arrow-right all render at the design's 14px. - Search input was 54px tall because @tailwindcss/forms applies `padding: 8px 12px` to bare ``. Override with `!p-0 focus:ring-0 focus:shadow-none` so the wrapping div's padding alone defines the box (38px total — matches the design). * refactor(settings/providers): align Sync all + search input with DS, address review feedback - Sync all: replace the hand-rolled `button_to` with `DS::Link.new(variant: "outline", method: :post)` — same component as the "Identify Patterns" button on the recurring-transactions page. - Search input: switch to the icon-overlay pattern used by the Manage-currencies and transaction filter rows (relative wrapper + absolutely positioned search icon + bordered input with `focus:ring-gray-500`). Brings the keyboard focus state in line with the rest of the app's filterable lists. - SnapTrade panel: restore the "needs registration" status row that the drawer-cleanup pass dropped along with the redundant Configured/Not configured footer. The unregistered case is meaningful state, not redundant chrome. - Move the slim health-strip computation out of the controller and into `SettingsHelper#provider_health_strip` (Convention 2: skinny controllers). - Extract `concise_time_ago` helper so the "drop leading 'about '" trick stops being duplicated 3x. - `Settings::ProviderCard#maturity_label` (instance) now delegates to `.maturity_label` (class) instead of duplicating the lookup. - Drop unused `warn_or_err` local in `_connection_row`. - Replace the `data-controller` string-injection + html_safe in `_connection_row` with `tag.details(data: ...)`; safer and more idiomatic. - Add a system test for the empty-filter message wiring. * fix(settings/providers): drawer trust statement uses border-tertiary `border-secondary/10` was reaching for the text-foreground token at 10% opacity for a divider. The project ships a dedicated divider token (`border-tertiary`, ~8% black) used by DS::Menu, the holdings page, and admin/sso forms. Switching to it makes the trust-statement HR match every other thin divider in Sure and stops misusing the text token as a border. * refactor(settings/providers): swap arbitrary Tailwind values for scale tokens Per the user's directive — DS-compliance over pixel-perfect alignment with the design mock. Walked the design audit and applied every swap that lands within ±2px of the original. Swaps: - _health_strip: gap-[18px] → gap-5 (+2), px-[14px] → px-3.5 (=), text-[13px] → text-sm (+1). - _search_filters: chip group p-[3px] → p-1, rounded-[10px] → rounded-xl (concentric with rounded-lg inner pills), chip py-[5px] → py-1. - _status_pill: text-[11px] → text-xs. - _group_heading: mt-[18px] → mt-5. - _maturity_badge: text-[10px] → text-xs. - provider_card: tagline + foot text-[13px] → text-sm. Kept arbitrary: `min-w-[200px]` in _search_filters — nearest scale tokens are min-w-48 (192px) and min-w-52 (208px); both are noticeable layout shifts for a one-off responsive guard. Worth keeping the arbitrary here. Net: 9 of 10 arbitrary values gone. Visual delta: max +2px on a single value. Design mock and DS scale now agree. * revert(settings/providers): drop the slim health strip Per-row status pills already carry the at-a-glance signal (connected / action needed) at the scale this app sees (1–4 connections per family). The strip was redundant chrome for almost every user; only worth bringing back if the catalog grows to a point where the row list itself stops fitting on a single screen. - Delete _health_strip.html.erb partial. - Drop @health controller assignment + provider_health_strip helper. - Drop unused settings.providers.health_strip.* locale keys. - concise_time_ago helper stays — still used by per-row meta text. * refactor(settings/providers): align with DS conventions Two consistency wins from the screenshot/DS audit pass. Sync icon button now renders DS::Button (variant: icon, size: sm) instead of a hand-rolled `button_to`. Same component used by other icon-only actions across the app (settings/profiles, layouts/imports). Visual delta: 28×28 → 32×32 (DS sm size). Accept the +4px for consistency. `event.stopPropagation()` still wired via the form opt so the row's
doesn't toggle when the user clicks the button. Group heading now follows the established Sure section-label style (`text-xs font-medium text-secondary uppercase`) used by `_settings_nav` and the imports/categories surfaces. The previous sentence-case `text-sm text-primary` was a one-off that didn't match the rest of the app. Locale strings stay sentence-case; uppercase comes from CSS `text-transform`. Tests updated to case-insensitively match the rendered heading text. * fix(provider/metadata): add plaid_eu entry `plaid_eu` is registered as a separate Provider::ConfigurationRegistry entry but had no Provider::Metadata row, so its card in the Available grid fell through to the gray-500 default and rendered empty (no region, kind, tier, or tagline). The title also came out as "Plaid Eu" because `titleize` doesn't know "EU" is an initialism. - Add a `plaid_eu` row to Provider::Metadata::REGISTRY with the same shape as `plaid` (US → EU, otherwise identical). - Introduce an optional `name:` field in metadata; controller falls back to it before titleizing the provider key. Lets `plaid_eu` render as "Plaid EU". - Add the missing `settings.providers.taglines.plaid_eu` translation. * fix(settings/providers): center-align Sync all next to the lede `items-start` made the button hug the first line when the lede wrapped; on a single line the button sat at the top of the text bounding box which read slightly off. Center matches the dominant convention across the rest of settings (api_keys, securities, hostings, _section, _settings_nav_link_large). * fix(settings/providers): drop colour palette + filter polish + drawer warnings Round of design-feedback fixes. Provider chips - Drop the per-provider raw Tailwind palette (bg-blue-600 etc.) from Provider::Metadata. All cards + drawer logo lock-up now use bg-surface-inset + text-primary, matching the design's §04 "drop colour entirely" recommendation. Solves the long-standing §01 BLOCKER without externalising brand assets. Re-introducing logos later just means an optional logo_svg: field on metadata. - ProviderCard component drops the `logo_bg:` parameter; the chip is now styled in the template. Filter / search - "Available · N" count and the empty-filter state now update client-side as the chip filter and free-text search narrow the grid (new `count` Stimulus target + dedicated update path). - Empty-filter state now offers a Clear filters button that resets both the search input and the active chip in one click. - Search placeholder drops the drifting "Search 9 providers" count for plain "Search providers" — the section heading carries the number. - Chip labels normalised to plural where natural: "Banks · Crypto · Investments" (Crypto stays as the mass noun). Drawer copy / treatment - "IP Whitelisting Required" → "IP whitelisting required" (DS sentence-case). - Binance "do NOT enable withdrawal permissions" lifted out of inline red-text into a proper bg-warning-50 border-warning-200 alert block with an alert-triangle icon. Matches the api_keys / hosting alert pattern. - SnapTrade free-tier inline alert-triangle now uses `size: "sm"` so the icon stops rendering at 20px next to 14px body text. Spacing - Group-heading margin top bumped 5 → 6 (20→24px) so the eyebrow has more breathing room above the search bar. * refactor(settings/providers): drawer alerts use DS::Alert; drop card-in-card Two consistency fixes from a design-review pass. DS::Alert adoption - Replaces 9 hand-rolled error blocks across the provider panels (`bg-destructive/10 text-destructive ... line-clamp-3`) with `DS::Alert(variant: :error)` — the project's existing primitive. - Replaces the just-shipped Binance no-withdraw warning block with `DS::Alert(variant: :warning)` instead of a hand-rolled `bg-warning-50 border-warning-200` card. - Replaces the SnapTrade free-tier inline icon-prefixed warning paragraph with `DS::Alert(variant: :warning)` — proper alert treatment for an actual warning, not body copy. - Replaces the Enable Banking "Configuration locked" inline `bg-warning/10` two-paragraph block with `DS::Alert(variant: :warning)` using `safe_join` for the title + body. - Replaces the encryption-error block at the top of show.html.erb with `DS::Alert(variant: :error)`, again via `safe_join`. Mercury card-within-card - The "Add another Mercury connection" form was wrapped in a `
` `bg-container shadow-border-xs rounded-xl` card. In the Connect drawer (always 0 existing connections), that wrapping card-inside-the-drawer-card has no value — the form is the only thing on the surface. Drop the wrapper when no connections exist; keep the heading + form inline. When 1+ connections exist (the section page) the heading hints "+ Add another connection" without the disclosure indirection. Trade-off: the error-alert blocks lose their `line-clamp-3` / `title=` truncation. Acceptable for now — DS::Alert can grow a truncate option as a follow-up if needed. Open follow-up: DS::Alert itself uses raw Tailwind palette (`bg-yellow-50` etc.) instead of semantic tokens, and only accepts a single string `message:`. A separate issue tracks this. * fix(settings/providers): hoist warning alerts to top of drawer DS::Alert convention across the rest of the app: alerts sit at the top of the form / page / section, not floating between content blocks. The Binance no-withdraw warning and SnapTrade free-tier warning were rendering between the setup-instructions list and the form fields — visually wonky. Move both to the top of their respective panels so the warning is the first thing the user sees when the connect drawer opens. Existing precedents this aligns with: - accounts/_form.html.erb (error alert above form) - valuations/new.html.erb (error alert above form) - other_assets/new.html.erb (info alert above form) - holdings/show.html.erb (warn alerts above content) * fix(DS::Alert): align icon to cap-height of first text line `items-start` on the container made the icon's top edge flush with the text's top edge, leaving the icon's optical center sitting below the text's first-line center. The hand-rolled alerts elsewhere in the codebase (api_keys/new, hostings/_sync_settings, holdings/show) all add `mt-0.5` to the icon for the same reason — fold that into the primitive so every caller gets the cap-height alignment. * copy(settings/providers): tighten alert messaging per voice review Copy expert pass on the new provider drawer alerts. House style: sentence case for titles, lead with the action, drop "Warning:" / "Please" filler (the alert variant icon already signals tone), prefer one short sentence + optional title-paragraph for emphasis. - Binance no-withdraw warning: was a single line "Warning: do NOT enable withdrawal permissions" — alarmist without context. Now splits into "Read-only key only" (title) + "Don't enable withdrawal permissions when creating your Binance API key — Sure only needs read access." (body). - SnapTrade free-tier note: "Free tier includes 5 brokerage connections. Additional connections require a paid SnapTrade plan." → "SnapTrade's free tier covers 5 brokerage connections. Upgrade on SnapTrade for more." - SnapTrade connection-limit-info inside the brokerage list: cut entirely. The drawer already shows the cap; restating it in the list was noise. - SnapTrade needs-registration: "Credentials saved — finish registration to connect a brokerage." → "Credentials saved. Finish setup to connect a brokerage." ("registration" was ambiguous — register where, with whom?) - Enable Banking "Configuration locked" body: "Credentials cannot be changed while you have active bank connections. Remove all connections first to update credentials." → "Disconnect all linked banks before changing these credentials." Same meaning, half the words. - Encryption-error block: title-cased "Encryption Configuration Required" → "Encryption keys missing"; body strips "Please ensure" filler and the parenthetical credential dump, leaving the three credential names inline as a clean list. Self-hosters still get exactly the names they need to set. * feat(settings/providers): SetupSteps partial for connect-drawer instructions Per the design's drawer-cleanup follow-up. Replaces the per-panel "Setup instructions:" + ordered list + "Field descriptions:" block with a shared boxed-step component. The new partial — `_setup_steps.html.erb` — takes a `steps:` array of strings (or html_safe strings for inline links / code) plus an optional `help:` hash for a docs link below the steps. The eyebrow label is "Setup" (uppercase, tracking-wider) matching Sure's other section labels. Applied across all eleven provider panels: - _provider_form (Plaid + Plaid EU): field descriptions move to per-field helper text below the input. - _binance, _coinbase, _coinstats, _indexa_capital, _lunchflow, _mercury, _simplefin, _snaptrade, _sophtron, _enable_banking: ordered list + duplicate "Field descriptions" block both replaced by the partial. - Some panels' inline copy tightened in the same pass (Lunch Flow, SimpleFIN, Enable Banking) — the design copy is shorter than the current legacy strings; a copy-pass through every panel can follow as a separate cleanup. Token notes: uses scale tokens (`rounded-xl`, `text-xs`/`text-sm`, `tracking-wider`) instead of the design mock's exact arbitrary values, per the consistency-over-design-specs directive on this branch. * fix(settings/providers): tighten panel spacing + relocate per-panel notes Read-flow audit on each connect drawer. The uniform `space-y-4` treated every block (alert, steps, info card, fields, button) the same — visually they were five sibling boxes with no grouping. The fix is per panel; some notes belong as helper text on a specific field, others as a tightly-grouped pre-fill primer. Per panel: - Binance: IP-whitelisting card now matches the setup_steps box (`bg-surface-inset rounded-xl`) and is wrapped with setup_steps in an inner `space-y-2` so they read as a single pre-fill primer cluster. Same eyebrow treatment ("IP whitelisting required") so the two boxes look like sister panels, not unrelated chrome. - SnapTrade: drop the description paragraph above setup_steps. The available-providers card grid already markets SnapTrade ("Connect brokerage accounts via the SnapTrade aggregation network."); repeating in the drawer was duplication. - Mercury: move the sandbox-API note out of its standalone

below setup_steps and into per-field helper text under the base_url field — the user only cares about the sandbox URL when they're filling that field. Applied to both the per-item edit form and the add-new form. - _setup_steps partial: drop the now-pointless `mb-2` (outer `space-y-4` already controls the gap; bottom-margin was dead CSS thanks to margin-collapse rules with the next sibling's margin-top). * fix(settings/providers): plaid + indexa drawers join the SetupSteps look Two unifying fixes after the panel-by-panel screenshots showed mixed treatments. Plaid + Plaid EU - The registry-driven panel (_provider_form) was still rendering each adapter's markdown `description` block as plain prose ("Setup instructions: 1. Visit the Plaid Dashboard ..."). Other panels switched to the SetupSteps box; Plaid was the odd one out. - Drop the markdown `description` block from both plaid_adapter and plaid_eu_adapter. Render setup_steps in _provider_form for these two provider keys via inline ERB (link helper handles the Plaid Dashboard link cleanly; the regional differences fold to the same dashboard URL with a different account scope). - Other registry-based providers fall through to the previous markdown description path — no behavior change for them. Indexa Capital - The API token field was wrapped in a `bg-surface border` "card" that duplicated the field label inside as a heading and put the description above the input. Same pattern the user flagged as the "card within input" anti-shape. - Drop the wrapper. The styled-form input renders its own label; description moves to per-field helper text below the input, matching the pattern used by Plaid (provider_form) and Mercury. * fix(settings/providers): surface configured plaid_eu + dedup show context provider_summary had no plaid_eu branch — configured plaid_eu was falling through to status :off and rendering in Available even with credentials set. Collapse plaid + plaid_eu into a single registry check. Drawer title for non-panel configurations was provider_key.titleize, which produced "Plaid Eu" while the available card grid used metadata[:name] = "Plaid EU". Read from metadata first. While here: - compute_provider_sync_health no longer relies on instance_variable_get; pass family_panel_items explicitly so the hash-key/ivar-name coupling is gone. - drop unused .includes(:syncs, :mercury_accounts) and .includes(:snaptrade_accounts) from prepare_show_context. The show view only consults summary[:status]; the eager-loads were carried over from connect_form (which has its own load_provider_items). * i18n(settings/providers): localize plaid setup steps + drop dead defaults The plaid + plaid_eu setup steps in _provider_form.html.erb were hardcoded English strings. Move them to settings.providers.plaid_panel (shared) + plaid_eu_panel (EU-specific step 1) so they can be translated like every other panel. _setup_steps.html.erb was passing default: "Setup" / "Need help?" to t(), masking missing translations in non-EN locales. Both keys exist in en.yml — drop the defaults so missing translations actually surface. * test(settings/providers): cover plaid_eu, clear filters, warn outline Three system test additions: - Configured plaid_eu surfaces in Your connections (regression guard for the helper fix; previously fell through to Available). - Clear filters button resets input + chip state and brings cards back into view. - :warn-state connection row carries the border-warning/25 outline that distinguishes it from an :ok row. * copy(settings/providers): drop em dashes, naturalize phrasing Sweep through every string this branch added and replace em-dash splices with full sentences or simple connectives. en.yml: - drawer_trust_statement now reads "Read-only access. Sure can never move money, and your credentials are stored encrypted." instead of em-dash splicing. - sync_all_recently / recently_synced split into two sentences. - binance_panel.no_withdraw_body, plaid_panel.step_1_html / step_2, plaid_eu_panel.step_1_html same treatment. Hardcoded panel steps (enable_banking, lunchflow, simplefin) become "Go to and …" or "Go to for …" instead of the " — get …" splice. Same setup_steps comment cleaned up. * fix(settings/providers): address CodeRabbit pass on PR #1717 Fixed: - Localize the setup steps in _enable_banking_panel, _lunchflow_panel, and _simplefin_panel. The em-dash sweep had rewritten these into hardcoded English; they now route through settings.providers.{enable_banking,lunchflow,simplefin}_panel step_1_html / step_2 / step_3 keys, mirroring the plaid_panel treatment. - connect_form: silent redirect when provider_key is unknown now carries an alert (settings.providers.not_found) so misrouted links don't drop users on the page with no feedback. - sync action: redirect notice now reflects whether anything was actually scheduled — adds settings.providers.sync_provider_no_items for the "all items already syncing or none exist" path. - Family::Syncer test: count plaid_items via the .syncable scope to match what Family::Syncer actually schedules (already done for binance_items in the same test). Skipped, with reasons: - focus:ring-gray-500/-gray-900 in coinstats / coinbase / simplefin / search_filters: tracked under issue #1715 as part of the raw-palette → DS-token sweep across the whole codebase. - Coinbase #0052FF brand-color wrapper: tracked under PR #1710's follow-up tracking comment as the deferred Provider::Metadata colour-palette decision (designer §01). - Sophtron submit-button extraction into DS::Button: same deferred sweep — every panel hand-rolls this class string; one-off extraction would just churn. - Redundant .html_safe on _html keys in coinstats: tracked in #1715. - _provider_form.html.erb env hint, "Optional" placeholder, "Save and connect" submit: pre-existing strings not added on this branch. - Renaming sync_health_for's :stale to :data_stale: pre-existing shape, refactor scope. - Plaid_eu using plaid_panel.step_2/step_3 keys: deliberate. Same English copy across both providers; duplicating keys would just give translators twice the work for identical strings. - _enable_banking_panel / _lunchflow_panel / _simplefin_panel alert + submit + button labels: pre-existing hardcoded strings from before this branch. Setup steps were the strings actually touched in the em-dash sweep, so those got localized; the rest belong in a broader panel-i18n pass. Verified: - bundle exec erb_lint on the three panels: clean. - bin/rubocop on controller + test: clean. - bin/rails test test/models/family/syncer_test.rb test/controllers/settings/providers_controller_test.rb: 23 runs, 85 assertions, 0 failures. - DISABLE_PARALLELIZATION=true bin/rails test test/system/settings/providers_test.rb: 15 runs, 38 assertions, 0 failures. * fix(db): rename migration to clear collision with main's 20260508120000 Main's PR #1705 (Sophtron manual sync) shipped a migration with the same 20260508120000 timestamp as our add_last_sync_all_attempted_at_to_families migration. The merge that brought main into this branch left both files at the same prefix, which trips Rails' "Duplicate migration" guard at db:schema:load time and broke CI. Renaming our migration to 20260510120000 keeps the column it adds intact (already in db/schema.rb) and bumps the schema version to match. No DB-level change. * fix(settings/providers): card + strip a11y polish - Bring back the slim health strip; gate behind 10+ accounts (HEALTH_STRIP_MIN_ACCOUNTS) so it stays out of the way for small libraries where per-row pills already carry the signal. - Status pill: drop the bg-{c}/10 text-{c} pattern (failed AA on warn / err); switch to bg-surface-inset text-primary with the dot still carrying semantic colour. Passes AA in both themes; the dot is the only colourful affordance. - Maturity badge: bg-alpha-black-50 was invisible against the hovered card bg in light mode and against bg-container in dark mode. Move to bg-surface-inset + border-tertiary so it stays delineated through hover and dark theme. - Provider card: keep the bg shift on hover (now bg-surface-inset for a perceptible delta), focus ring promoted alpha-black-100 -> alpha-black-300 (visible to keyboard users), meta line text-subdued -> text-secondary (text-subdued failed AA at 2.86:1 against bg-container). - Restore the per-provider logo palette dropped in 6abceb07. Yellow-on-white was the BLOCKER then; bumped Binance to yellow-600 and CoinStats to pink-600 (distinct from Binance and AA-safe with white text). - Health strip dividers: bg-alpha-black-100 was invisible in dark mode. Switch to border-l border-secondary so the DS variant flips correctly. * fix(settings/providers): keep row height on open The right-side meta + status pill + sync button group is hidden via group-open:hidden, but the sync button (DS::Button size sm, h-8) is what dictated the row's natural height. With it gone, the row collapsed from 60px to 48px and the title appeared to jump upward. Pin a min-h-15 on the

so the height stays constant through open/close. * Let's not regress IPv6 * Keep the only real change in schema.rb --------- Signed-off-by: Juan José Mata Signed-off-by: Guillem Arias Fauste Co-authored-by: Claude Co-authored-by: Guillem Arias Co-authored-by: Guillem Arias Fauste --- app/components/DS/alert.html.erb | 2 +- .../settings/provider_card.html.erb | 25 ++ app/components/settings/provider_card.rb | 47 ++++ .../settings/bank_sync_controller.rb | 43 ---- .../settings/providers_controller.rb | 230 ++++++++++++++++-- app/helpers/settings_helper.rb | 146 ++++++++++- .../providers_filter_controller.js | 68 ++++++ app/jobs/sync_all_providers_job.rb | 9 + app/models/family/syncer.rb | 1 + app/models/provider/metadata.rb | 22 ++ app/models/provider/plaid_adapter.rb | 7 - app/models/provider/plaid_eu_adapter.rb | 7 - app/views/settings/_section.html.erb | 16 +- app/views/settings/_settings_nav.html.erb | 3 +- .../bank_sync/_provider_link.html.erb | 33 --- app/views/settings/bank_sync/show.html.erb | 26 -- .../providers/_binance_panel.html.erb | 58 +++-- .../providers/_coinbase_panel.html.erb | 27 +- .../providers/_coinstats_panel.html.erb | 28 +-- .../providers/_connection_row.html.erb | 43 ++++ .../providers/_drawer_header.html.erb | 22 ++ .../providers/_enable_banking_panel.html.erb | 46 ++-- .../providers/_group_heading.html.erb | 12 + .../settings/providers/_health_strip.html.erb | 28 +++ .../providers/_indexa_capital_panel.html.erb | 41 +--- .../providers/_lunchflow_panel.html.erb | 39 +-- .../providers/_maturity_badge.html.erb | 4 + .../providers/_mercury_panel.html.erb | 93 +++---- .../providers/_provider_form.html.erb | 87 +++---- .../providers/_search_filters.html.erb | 27 ++ .../settings/providers/_setup_steps.html.erb | 26 ++ .../providers/_simplefin_panel.html.erb | 37 +-- .../providers/_snaptrade_panel.html.erb | 117 ++++----- .../providers/_sophtron_panel.html.erb | 36 +-- .../settings/providers/_status_pill.html.erb | 7 + .../settings/providers/_sync_button.html.erb | 15 ++ .../settings/providers/connect_form.html.erb | 21 ++ app/views/settings/providers/show.html.erb | 176 +++++++------- config/locales/views/coinstats_items/ca.yml | 2 - config/locales/views/coinstats_items/de.yml | 2 - config/locales/views/coinstats_items/en.yml | 2 - config/locales/views/coinstats_items/es.yml | 2 - config/locales/views/coinstats_items/fr.yml | 2 - config/locales/views/coinstats_items/hu.yml | 2 - config/locales/views/coinstats_items/nl.yml | 2 - config/locales/views/coinstats_items/pl.yml | 2 - .../locales/views/indexa_capital_items/de.yml | 2 - .../locales/views/indexa_capital_items/en.yml | 2 - .../locales/views/indexa_capital_items/es.yml | 2 - .../locales/views/indexa_capital_items/fr.yml | 2 - .../locales/views/indexa_capital_items/hu.yml | 2 - .../locales/views/indexa_capital_items/pl.yml | 2 - config/locales/views/mercury_items/en.yml | 3 - config/locales/views/mercury_items/hu.yml | 3 - config/locales/views/settings/de.yml | 2 - config/locales/views/settings/en.yml | 100 +++++++- config/locales/views/settings/es.yml | 2 - config/locales/views/settings/fr.yml | 4 - config/locales/views/settings/hu.yml | 4 - config/locales/views/settings/pl.yml | 2 - config/locales/views/settings/pt-BR.yml | 2 - config/locales/views/snaptrade_items/de.yml | 2 - config/locales/views/snaptrade_items/en.yml | 6 +- config/locales/views/snaptrade_items/es.yml | 2 - config/locales/views/snaptrade_items/fr.yml | 2 - config/locales/views/snaptrade_items/hu.yml | 2 - config/locales/views/snaptrade_items/pl.yml | 2 - config/locales/views/sophtron_items/en.yml | 3 - config/locales/views/sophtron_items/hu.yml | 3 - config/routes.rb | 10 +- ..._last_sync_all_attempted_at_to_families.rb | 5 + db/schema.rb | 3 +- .../settings/providers_controller_test.rb | 59 ++++- test/models/family/syncer_test.rb | 17 +- test/system/settings/providers_test.rb | 213 ++++++++++++++++ test/system/settings_test.rb | 9 +- 76 files changed, 1466 insertions(+), 697 deletions(-) create mode 100644 app/components/settings/provider_card.html.erb create mode 100644 app/components/settings/provider_card.rb delete mode 100644 app/controllers/settings/bank_sync_controller.rb create mode 100644 app/javascript/controllers/providers_filter_controller.js create mode 100644 app/jobs/sync_all_providers_job.rb create mode 100644 app/models/provider/metadata.rb delete mode 100644 app/views/settings/bank_sync/_provider_link.html.erb delete mode 100644 app/views/settings/bank_sync/show.html.erb create mode 100644 app/views/settings/providers/_connection_row.html.erb create mode 100644 app/views/settings/providers/_drawer_header.html.erb create mode 100644 app/views/settings/providers/_group_heading.html.erb create mode 100644 app/views/settings/providers/_health_strip.html.erb create mode 100644 app/views/settings/providers/_maturity_badge.html.erb create mode 100644 app/views/settings/providers/_search_filters.html.erb create mode 100644 app/views/settings/providers/_setup_steps.html.erb create mode 100644 app/views/settings/providers/_status_pill.html.erb create mode 100644 app/views/settings/providers/_sync_button.html.erb create mode 100644 app/views/settings/providers/connect_form.html.erb create mode 100644 db/migrate/20260510120000_add_last_sync_all_attempted_at_to_families.rb create mode 100644 test/system/settings/providers_test.rb diff --git a/app/components/DS/alert.html.erb b/app/components/DS/alert.html.erb index 8f5c0fd41..419a4607c 100644 --- a/app/components/DS/alert.html.erb +++ b/app/components/DS/alert.html.erb @@ -1,5 +1,5 @@
- <%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %> + <%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0 mt-0.5" %>
<% if title.present? %> diff --git a/app/components/settings/provider_card.html.erb b/app/components/settings/provider_card.html.erb new file mode 100644 index 000000000..6d0210a17 --- /dev/null +++ b/app/components/settings/provider_card.html.erb @@ -0,0 +1,25 @@ +<%= link_to connect_path, + class: "bg-container shadow-border-xs hover:bg-surface-inset rounded-xl p-4 flex flex-col gap-2.5 text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300", + data: { turbo_frame: "drawer", turbo_prefetch: "false" }.merge(filter_data) do %> +
+
+ <%= logo_text %> +
+
+
+ <%= name %> + <%= render "settings/providers/maturity_badge", label: maturity_label %> +
+ <% if meta_line.present? %> +

<%= meta_line %>

+ <% end %> +
+
+ <% if tagline.present? %> +

<%= tagline %>

+ <% end %> +
+ <%= t("settings.providers.connect") %> + <%= helpers.icon "arrow-right", size: "sm", class: "!w-3.5 !h-3.5" %> +
+<% end %> diff --git a/app/components/settings/provider_card.rb b/app/components/settings/provider_card.rb new file mode 100644 index 000000000..c57657a2e --- /dev/null +++ b/app/components/settings/provider_card.rb @@ -0,0 +1,47 @@ +class Settings::ProviderCard < ApplicationComponent + MATURITY_LABELS = { + beta: "settings.providers.maturity.beta", + alpha: "settings.providers.maturity.alpha" + }.freeze + + def self.maturity_label(maturity) + key = MATURITY_LABELS[maturity&.to_sym] + I18n.t(key) if key + end + + def initialize(provider_key:, name:, tagline: nil, region: nil, kind: nil, tier: nil, + maturity: :stable, logo_bg: "bg-gray-500", logo_text: nil) + @provider_key = provider_key + @name = name + @tagline = tagline + @region = region + @kind = kind + @tier = tier + @maturity = maturity.to_sym + @logo_bg = logo_bg + @logo_text = logo_text || name.first(2).upcase + end + + attr_reader :name, :tagline, :logo_bg, :logo_text + + def maturity_label + self.class.maturity_label(@maturity) + end + + def meta_line + [ @region, @kind, @tier ].compact.join(" · ") + end + + def connect_path + helpers.connect_form_settings_providers_path(provider_key: @provider_key) + end + + def filter_data + { + providers_filter_target: "card", + provider_name: @name.to_s.downcase, + provider_region: @region.to_s.downcase, + provider_kind: @kind.to_s.downcase + } + end +end diff --git a/app/controllers/settings/bank_sync_controller.rb b/app/controllers/settings/bank_sync_controller.rb deleted file mode 100644 index 91e55a3ef..000000000 --- a/app/controllers/settings/bank_sync_controller.rb +++ /dev/null @@ -1,43 +0,0 @@ -class Settings::BankSyncController < ApplicationController - layout "settings" - - def show - @providers = [ - { - name: "Lunch Flow", - description: "US, Canada, UK, EU, Brazil and Asia through multiple open banking providers.", - path: "https://lunchflow.app/features/sure-integration?atp=BiDIYS", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "Plaid", - description: "US & Canada bank connections with transactions, investments, and liabilities.", - path: "https://github.com/we-promise/sure/blob/main/docs/hosting/plaid.md", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "SimpleFIN", - description: "US & Canada connections via SimpleFIN protocol.", - path: "https://beta-bridge.simplefin.org", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "Enable Banking (beta)", - description: "European bank connections via open banking APIs across multiple countries.", - path: "https://enablebanking.com", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "Sophtron (alpha)", - description: "US & Canada bank, credit card, investment, loan, insurance, utility, and other connections.", - path: "https://www.sophtron.com/", - target: "_blank", - rel: "noopener noreferrer" - } - ] - end -end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 7c6428376..361097ae1 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -1,12 +1,12 @@ class Settings::ProvidersController < ApplicationController - layout "settings" + layout -> { turbo_frame_request? ? "turbo_rails/frame" : "settings" } - before_action :ensure_admin, only: [ :show, :update ] + before_action :ensure_admin, only: [ :show, :update, :sync_all, :sync, :connect_form ] def show @breadcrumbs = [ [ "Home", root_path ], - [ "Bank Sync Providers", nil ] + [ "Bank sync", nil ] ] prepare_show_context @@ -77,6 +77,61 @@ class Settings::ProvidersController < ApplicationController render :show, status: :unprocessable_entity end + def sync_all + family = Current.family + now = Time.current + + updated_count = Family + .where(id: family.id) + .where("last_sync_all_attempted_at IS NULL OR last_sync_all_attempted_at <= ?", 30.seconds.ago) + .update_all(last_sync_all_attempted_at: now, updated_at: now) + + if updated_count.zero? + return redirect_to settings_providers_path, notice: t("settings.providers.sync_all_recently") + end + + SyncAllProvidersJob.perform_later(family.id) + redirect_to settings_providers_path, notice: t("settings.providers.sync_all_in_progress") + end + + def sync + provider_key = params[:provider_key] + syncable_type = PANEL_SYNCABLE_TYPES[provider_key] + return redirect_to settings_providers_path unless syncable_type + + items = syncable_type.constantize.where(family: Current.family).syncable + scheduled = items.reject(&:syncing?) + scheduled.each(&:sync_later) + + notice_key = scheduled.any? ? "settings.providers.sync_provider_in_progress" : "settings.providers.sync_provider_no_items" + redirect_to settings_providers_path, notice: t(notice_key) + end + + def connect_form + provider_key = params[:provider_key] + + panel = FAMILY_PANELS.find { |p| p[:key] == provider_key } + if panel + @panel_key = panel[:key] + @panel_partial = panel[:partial] + @panel_title = panel[:title] + load_provider_items(provider_key) + return render :connect_form + end + + Provider::Factory.ensure_adapters_loaded + config = Provider::ConfigurationRegistry.all.find { |c| c.provider_key.to_s == provider_key } + if config + @panel_title = Provider::Metadata.for(provider_key)[:name] || provider_key.titleize + @provider_configuration = config + return render :connect_form + end + + redirect_to settings_providers_path, alert: t("settings.providers.not_found") + rescue ActiveRecord::Encryption::Errors::Configuration + redirect_to settings_providers_path, alert: t("settings.providers.encryption_error.title") + end + private def provider_params # Dynamically permit all provider configuration fields @@ -93,7 +148,9 @@ class Settings::ProvidersController < ApplicationController end def ensure_admin - redirect_to settings_providers_path, alert: "Not authorized" unless Current.user.admin? + return if Current.user.admin? + + redirect_to root_path, alert: t("settings.providers.not_authorized") end # Reload provider configurations after settings update @@ -119,19 +176,71 @@ class Settings::ProvidersController < ApplicationController end end + # Hardcoded family-scoped panels — provider connections are managed through + # their own models (SimplefinItem, LunchflowItem, etc.) rather than global + # settings, so they need custom UI per-provider for connection management, + # status display, and sync actions. The configuration registry excludes + # them (see prepare_show_context). + FAMILY_PANELS = [ + { key: "lunchflow", title: "Lunch Flow", turbo_id: "lunchflow", partial: "lunchflow_panel" }, + { key: "simplefin", title: "SimpleFIN", turbo_id: "simplefin", partial: "simplefin_panel" }, + { key: "enable_banking", title: "Enable Banking", turbo_id: "enable_banking", partial: "enable_banking_panel" }, + { key: "coinstats", title: "CoinStats", turbo_id: "coinstats", partial: "coinstats_panel" }, + { key: "mercury", title: "Mercury", turbo_id: "mercury", partial: "mercury_panel" }, + { key: "coinbase", title: "Coinbase", turbo_id: "coinbase", partial: "coinbase_panel" }, + { key: "binance", title: "Binance", turbo_id: "binance", partial: "binance_panel" }, + { key: "snaptrade", title: "SnapTrade", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" }, + { key: "indexa_capital", title: "Indexa Capital", turbo_id: "indexa_capital", partial: "indexa_capital_panel" }, + { key: "sophtron", title: "Sophtron", turbo_id: "sophtron", partial: "sophtron_panel" } + ].freeze + + FAMILY_PANEL_KEYS = FAMILY_PANELS.map { |p| p[:key] }.freeze + + # Maps panel key → ActiveRecord model name for sync health queries + PANEL_SYNCABLE_TYPES = { + "simplefin" => "SimplefinItem", + "lunchflow" => "LunchflowItem", + "enable_banking" => "EnableBankingItem", + "coinstats" => "CoinstatsItem", + "mercury" => "MercuryItem", + "coinbase" => "CoinbaseItem", + "binance" => "BinanceItem", + "snaptrade" => "SnaptradeItem", + "indexa_capital" => "IndexaCapitalItem", + "sophtron" => "SophtronItem" + }.freeze + + def load_provider_items(provider_key) + case provider_key + when "simplefin" + @simplefin_items = Current.family.simplefin_items.ordered + when "lunchflow" + @lunchflow_items = Current.family.lunchflow_items.ordered + when "enable_banking" + @enable_banking_items = Current.family.enable_banking_items.ordered + when "coinstats" + @coinstats_items = Current.family.coinstats_items.ordered + when "mercury" + @mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts) + when "coinbase" + @coinbase_items = Current.family.coinbase_items.ordered + when "binance" + @binance_items = Current.family.binance_items.active.ordered + when "snaptrade" + @snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered + when "indexa_capital" + @indexa_capital_items = Current.family.indexa_capital_items.ordered + when "sophtron" + @sophtron_items = Current.family.sophtron_items.ordered + end + end + # Prepares instance vars needed by the show view and partials def prepare_show_context - # Load all provider configurations (exclude SimpleFin and Lunchflow, which have their own family-specific panels below) + # Load all provider configurations (exclude family-scoped panels, which have their own UI below) Provider::Factory.ensure_adapters_loaded @provider_configurations = Provider::ConfigurationRegistry.all.reject do |config| - config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \ - config.provider_key.to_s.casecmp("enable_banking").zero? || \ - config.provider_key.to_s.casecmp("sophtron").zero? || \ - config.provider_key.to_s.casecmp("coinstats").zero? || \ - config.provider_key.to_s.casecmp("mercury").zero? || \ - config.provider_key.to_s.casecmp("coinbase").zero? || \ - config.provider_key.to_s.casecmp("snaptrade").zero? || \ - config.provider_key.to_s.casecmp("indexa_capital").zero? + FAMILY_PANEL_KEYS.any? { |key| config.provider_key.to_s.casecmp(key).zero? } end # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials @@ -141,9 +250,100 @@ class Settings::ProvidersController < ApplicationController # Providers page only needs to know whether any Sophtron connections exist with valid credentials @sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id) @coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display - @mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts) + @mercury_items = Current.family.mercury_items.active.ordered @coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display - @snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered + @snaptrade_items = Current.family.snaptrade_items.ordered @indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id) + @binance_items = Current.family.binance_items.active.ordered + + @provider_sync_health = compute_provider_sync_health(family_panel_items) + + entries = build_provider_entries + + @connected = entries.select { |e| e[:summary][:status] == :ok } + @needs_attention = entries.select { |e| [ :warn, :err ].include?(e[:summary][:status]) } + @available = entries.select { |e| e[:summary][:status] == :off } + + @health = view_context.provider_health_strip(connected: @connected, needs_attention: @needs_attention) + end + + # Maps each family panel key to the loaded item collection. Used by + # compute_provider_sync_health and build_provider_entries to avoid relying + # on instance_variable_get for control flow. + def family_panel_items + { + "simplefin" => @simplefin_items, + "lunchflow" => @lunchflow_items, + "enable_banking" => @enable_banking_items, + "coinstats" => @coinstats_items, + "mercury" => @mercury_items, + "coinbase" => @coinbase_items, + "binance" => @binance_items, + "snaptrade" => @snaptrade_items, + "indexa_capital" => @indexa_capital_items, + "sophtron" => @sophtron_items + } + end + + # Returns a hash mapping provider key → { error:, last_synced_at:, stale: } + # by querying the latest sync per item for each family panel provider. + def compute_provider_sync_health(items_map) + PANEL_SYNCABLE_TYPES.each_with_object({}) do |(key, syncable_type), health| + ids = items_map[key]&.map(&:id)&.compact + next if ids.blank? + + health[key] = sync_health_for(syncable_type, ids) + end + end + + # Determines error/stale status and last successful sync time for a set of items. + def sync_health_for(syncable_type, item_ids) + # Use window function to get the single latest sync per item (same pattern as ProviderConnectionStatus) + ranked_subq = Sync + .where(syncable_type: syncable_type, syncable_id: item_ids) + .select("syncs.*, ROW_NUMBER() OVER (PARTITION BY syncable_id ORDER BY created_at DESC, id DESC) AS sync_rank") + + latest_per_item = Sync.from(ranked_subq, :syncs).where("sync_rank = 1").to_a + + has_error = latest_per_item.any? { |s| s.failed? || s.stale? } + + last_synced = Sync + .where(syncable_type: syncable_type, syncable_id: item_ids, status: "completed") + .maximum(:completed_at) + + stale = !has_error && last_synced.present? && last_synced < 24.hours.ago + + { error: has_error, last_synced_at: last_synced, stale: stale } + end + + # Builds a unified list of provider entries (registry-driven configurations + # and hardcoded family panels) with pre-computed status, sorted + # alphabetically by display title. Each entry carries enough data for the + # view to render either a provider_form or a family panel partial. + def build_provider_entries + configuration_entries = @provider_configurations.map do |config| + meta = Provider::Metadata.for(config.provider_key) + { + provider_key: config.provider_key.to_s, + title: meta[:name] || config.provider_key.to_s.titleize, + configuration: config, + maturity: meta[:maturity], + summary: view_context.provider_summary(config.provider_key) + } + end + + family_entries = FAMILY_PANELS.map do |panel| + { + provider_key: panel[:key], + title: panel[:title], + turbo_id: panel[:turbo_id], + partial: panel[:partial], + auto_open_param: panel[:auto_open], + maturity: Provider::Metadata.for(panel[:key])[:maturity], + summary: view_context.provider_summary(panel[:key]) + } + end + + (configuration_entries + family_entries).sort_by { |entry| entry[:title].downcase } end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 79a3c248a..3fc4f1af2 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -2,7 +2,7 @@ module SettingsHelper SETTINGS_ORDER = [ # General section { name: "Accounts", path: :accounts_path }, - { name: "Bank Sync", path: :settings_bank_sync_path }, + { name: "Bank Sync", path: :settings_providers_path, condition: :admin_user? }, { name: "Preferences", path: :settings_preferences_path }, { name: "Appearance", path: :settings_appearance_path }, { name: "Profile Info", path: :settings_profile_path }, @@ -19,7 +19,6 @@ module SettingsHelper { name: "LLM Usage", path: :settings_llm_usage_path, condition: :admin_user? }, { name: "API Key", path: :settings_api_key_path, condition: :admin_user? }, { name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted_and_admin? }, - { name: "Providers", path: :settings_providers_path, condition: :admin_user? }, { name: "Imports", path: :imports_path, condition: :admin_user? }, { name: "Exports", path: :family_exports_path, condition: :admin_user? }, # More section @@ -45,9 +44,70 @@ module SettingsHelper } end - def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, &block) + def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil, &block) content = capture(&block) - render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param } + render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param, status: status, meta: meta, actions: actions, badge: badge } + end + + def status_pill_classes(status) + pill = "bg-surface-inset text-primary" + + case status.to_s.to_sym + when :ok + { dot: "bg-success", pill: pill } + when :warn + { dot: "bg-warning", pill: pill } + when :err + { dot: "bg-destructive", pill: pill } + else + { dot: "bg-gray-400", pill: pill } + end + end + + def provider_summary(provider_key) + key = provider_key.to_s.downcase + + case key + when "plaid", "plaid_eu" + configured = @provider_configurations&.find { |c| c.provider_key.to_s.casecmp(key).zero? }&.configured? + configured ? { status: :ok } : { status: :off } + when "simplefin" + return { status: :off } unless @simplefin_items&.any? + sync_based_summary(key) + when "lunchflow" + return { status: :off } unless @lunchflow_items&.any? + sync_based_summary(key) + when "enable_banking" + return { status: :off } unless @enable_banking_items&.any? + enable_banking_summary + when "coinstats" + return { status: :off } unless @coinstats_items&.any? + sync_based_summary(key) + when "mercury" + return { status: :off } unless @mercury_items&.any? + sync_based_summary(key) + when "coinbase" + return { status: :off } unless @coinbase_items&.any? + sync_based_summary(key) + when "binance" + return { status: :off } unless @binance_items&.any? + sync_based_summary(key) + when "snaptrade" + configured_item = @snaptrade_items&.find(&:credentials_configured?) + return { status: :off } unless configured_item + unless configured_item.user_registered? + return { status: :warn, meta: t("settings.providers.meta.registration_needed") } + end + sync_based_summary(key) + when "indexa_capital" + return { status: :off } unless @indexa_capital_items&.any? + sync_based_summary(key) + when "sophtron" + return { status: :off } unless @sophtron_items&.any? + sync_based_summary(key) + else + { status: :off } + end end def settings_nav_footer @@ -70,7 +130,85 @@ module SettingsHelper end end + # Below this many synced accounts, the per-row pills already give the user + # enough at-a-glance signal and the strip is redundant chrome. + HEALTH_STRIP_MIN_ACCOUNTS = 10 + + # Slim health-strip data for the providers index. Pulls counts from the + # already-resolved entry summaries plus the family's distinct synced-account + # count for the trailing stat. Returns a hash consumed by the + # `settings/providers/_health_strip` partial, or nil when the family has + # fewer than HEALTH_STRIP_MIN_ACCOUNTS connected accounts. + def provider_health_strip(connected:, needs_attention:) + accounts_count = Current.family.accounts.joins(:account_providers).distinct.count + return nil if accounts_count < HEALTH_STRIP_MIN_ACCOUNTS + + active_entries = connected + needs_attention + last_synced_at = active_entries.map { |e| e[:summary][:last_synced_at] }.compact.max + + { + connected: active_entries.size, + needs_attention: needs_attention.size, + accounts_syncing: accounts_count, + last_synced_at: last_synced_at + } + end + + # Strips the leading "about " from `time_ago_in_words` so copy reads as + # "Synced 6 hours ago" instead of "Synced about 6 hours ago". + def concise_time_ago(time) + time_ago_in_words(time).sub(/\Aabout /, "") + end + private + def sync_based_summary(provider_key) + health = @provider_sync_health&.dig(provider_key) || {} + last_synced_at = health[:last_synced_at] + + base = if health[:error] + { status: :err, meta: t("settings.providers.meta.sync_error") } + elsif health[:stale] + { status: :warn, meta: t("settings.providers.meta.no_recent_sync") } + elsif last_synced_at.present? + { status: :ok, meta: t("settings.providers.meta.last_synced", time: concise_time_ago(last_synced_at)) } + else + { status: :ok } + end + + base.merge(last_synced_at: last_synced_at) + end + + def enable_banking_summary + health = @provider_sync_health&.dig("enable_banking") || {} + last_synced_at = health[:last_synced_at] + + return { status: :err, meta: t("settings.providers.meta.sync_error"), last_synced_at: nil } if health[:error] + + valid_items = @enable_banking_items&.select(&:session_valid?) || [] + + # All items have expired/missing sessions — need re-authorization + if valid_items.empty? + return { status: :warn, meta: t("settings.providers.meta.reconsent_required"), last_synced_at: last_synced_at } + end + + expiring = valid_items.find do |item| + item.session_expires_at.present? && item.session_expires_at < 7.days.from_now + end + + if expiring + days = [ ((expiring.session_expires_at - Time.current) / 1.day).ceil, 1 ].max + return { status: :warn, meta: t("settings.providers.meta.reconsent_needed", count: days), last_synced_at: last_synced_at } + end + + return { status: :warn, meta: t("settings.providers.meta.no_recent_sync"), last_synced_at: last_synced_at } if health[:stale] + + if last_synced_at.present? + { status: :ok, meta: t("settings.providers.meta.last_synced", time: concise_time_ago(last_synced_at)), last_synced_at: last_synced_at } + else + { status: :ok, last_synced_at: nil } + end + end + def not_self_hosted? !self_hosted? end diff --git a/app/javascript/controllers/providers_filter_controller.js b/app/javascript/controllers/providers_filter_controller.js new file mode 100644 index 000000000..54004b2f6 --- /dev/null +++ b/app/javascript/controllers/providers_filter_controller.js @@ -0,0 +1,68 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="providers-filter" +// Filters provider cards by free-text query and a chip-selected kind. +// Updates the visible-count target on the section heading and toggles +// an empty-state target when no card matches. +export default class extends Controller { + static targets = ["input", "chip", "card", "empty", "count"]; + static values = { kind: { type: String, default: "all" } }; + + connect() { + this.syncChipState(); + } + + filter() { + const query = this.hasInputTarget + ? this.inputTarget.value.toLocaleLowerCase().trim() + : ""; + const activeKind = this.kindValue; + let visibleCount = 0; + + this.cardTargets.forEach((card) => { + const name = card.dataset.providerName ?? ""; + const region = card.dataset.providerRegion ?? ""; + const kind = card.dataset.providerKind ?? ""; + const haystack = `${name} ${region} ${kind}`; + const matchesQuery = !query || haystack.includes(query); + const matchesKind = activeKind === "all" || kind === activeKind; + const visible = matchesQuery && matchesKind; + card.classList.toggle("hidden", !visible); + if (visible) visibleCount++; + }); + + if (this.hasCountTarget) { + this.countTarget.textContent = visibleCount; + } + + if (this.hasEmptyTarget) { + this.emptyTarget.classList.toggle("hidden", visibleCount > 0); + } + } + + selectChip(event) { + this.kindValue = event.currentTarget.dataset.kind ?? "all"; + this.syncChipState(); + this.filter(); + } + + clear() { + if (this.hasInputTarget) this.inputTarget.value = ""; + this.kindValue = "all"; + this.syncChipState(); + this.filter(); + if (this.hasInputTarget) this.inputTarget.focus(); + } + + syncChipState() { + if (!this.hasChipTarget) return; + this.chipTargets.forEach((chip) => { + const active = chip.dataset.kind === this.kindValue; + chip.classList.toggle("bg-container", active); + chip.classList.toggle("shadow-border-xs", active); + chip.classList.toggle("text-primary", active); + chip.classList.toggle("text-secondary", !active); + chip.setAttribute("aria-pressed", active ? "true" : "false"); + }); + } +} diff --git a/app/jobs/sync_all_providers_job.rb b/app/jobs/sync_all_providers_job.rb new file mode 100644 index 000000000..431b77cad --- /dev/null +++ b/app/jobs/sync_all_providers_job.rb @@ -0,0 +1,9 @@ +class SyncAllProvidersJob < ApplicationJob + queue_as :high_priority + sidekiq_options lock: :until_executed, lock_args: ->(args) { [ args.first ] }, on_conflict: :log + + def perform(family_id) + family = Family.find_by(id: family_id) + family&.sync_later + end +end diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 3eace0b06..6b909ebcb 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -17,6 +17,7 @@ class Family::Syncer coinbase_items coinstats_items mercury_items + binance_items snaptrade_items sophtron_items ].freeze diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb new file mode 100644 index 000000000..5cdd8e72c --- /dev/null +++ b/app/models/provider/metadata.rb @@ -0,0 +1,22 @@ +class Provider + module Metadata + REGISTRY = { + simplefin: { region: "US", kind: "Bank", maturity: :stable, logo_text: "SF", logo_bg: "bg-blue-600" }, + lunchflow: { region: "US", kind: "Bank", maturity: :stable, logo_text: "LF", logo_bg: "bg-orange-500" }, + enable_banking: { region: "EU", kind: "Bank", maturity: :beta, logo_text: "EB", logo_bg: "bg-purple-600" }, + coinstats: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CS", logo_bg: "bg-pink-600" }, + mercury: { region: "US", kind: "Bank", maturity: :beta, logo_text: "ME", logo_bg: "bg-cyan-600" }, + coinbase: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CB", logo_bg: "bg-blue-500" }, + binance: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "BI", logo_bg: "bg-yellow-600" }, + snaptrade: { region: "US / CA", kind: "Investment", maturity: :beta, logo_text: "ST", logo_bg: "bg-green-600" }, + indexa_capital: { region: "ES", kind: "Investment", maturity: :alpha, logo_text: "IC", logo_bg: "bg-red-600" }, + sophtron: { region: "US", kind: "Bank", maturity: :alpha, logo_text: "SO", logo_bg: "bg-teal-600" }, + plaid: { region: "US", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" }, + plaid_eu: { name: "Plaid EU", region: "EU", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" } + }.freeze + + def self.for(provider_key) + REGISTRY[provider_key.to_sym] || { logo_text: provider_key.to_s.first(2).upcase, logo_bg: "bg-gray-500" } + end + end +end diff --git a/app/models/provider/plaid_adapter.rb b/app/models/provider/plaid_adapter.rb index ac4fd8187..f75c5d97f 100644 --- a/app/models/provider/plaid_adapter.rb +++ b/app/models/provider/plaid_adapter.rb @@ -80,13 +80,6 @@ class Provider::PlaidAdapter < Provider::Base # Configuration for Plaid US configure do - description <<~DESC - Setup instructions: - 1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials - 2. Your Client ID and Secret Key are required to enable Plaid bank sync for US/CA banks - 3. For production use, set environment to 'production', for testing use 'sandbox' - DESC - field :client_id, label: "Client ID", required: false, diff --git a/app/models/provider/plaid_eu_adapter.rb b/app/models/provider/plaid_eu_adapter.rb index 497bb13c4..6db5331a0 100644 --- a/app/models/provider/plaid_eu_adapter.rb +++ b/app/models/provider/plaid_eu_adapter.rb @@ -19,13 +19,6 @@ class Provider::PlaidEuAdapter # Configuration for Plaid EU configure do - description <<~DESC - Setup instructions: - 1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials - 2. Your Client ID and Secret Key are required to enable Plaid bank sync for European banks - 3. For production use, set environment to 'production', for testing use 'sandbox' - DESC - field :client_id, label: "Client ID", required: false, diff --git a/app/views/settings/_section.html.erb b/app/views/settings/_section.html.erb index bb5873839..0c05833e8 100644 --- a/app/views/settings/_section.html.erb +++ b/app/views/settings/_section.html.erb @@ -1,4 +1,4 @@ -<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil) %> +<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil) %> <% if collapsible %>
class="group bg-container shadow-border-xs rounded-xl p-4" @@ -7,12 +7,24 @@
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %>
-

<%= title %>

+
+

<%= title %>

+ <%= badge if badge.present? %> +
<% if subtitle.present? %>

<%= subtitle %>

<% end %>
+ <% if status.present? %> +
+ <% if meta.present? %> + <%= meta %> + <% end %> + <%= status %> + <%= actions if actions.present? %> +
+ <% end %>
<%= content %> diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index bc4f9dcd0..94df67ae2 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -4,7 +4,7 @@ nav_sections = [ header: t(".general_section_title"), items: [ { label: t(".accounts_label"), path: accounts_path, icon: "layers" }, - { label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" }, + { label: t(".bank_sync_label"), path: settings_providers_path, icon: "banknote", if: Current.user&.admin? }, { label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" }, { label: t(".appearance_label"), path: settings_appearance_path, icon: "palette" }, { label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" }, @@ -30,7 +30,6 @@ nav_sections = [ { label: "LLM Usage", path: settings_llm_usage_path, icon: "activity" }, { label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" }, { label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? }, - { label: "Providers", path: settings_providers_path, icon: "plug" }, { label: t(".imports_label"), path: imports_path, icon: "download" }, { label: t(".exports_label"), path: family_exports_path, icon: "upload" }, { label: "SSO Providers", path: admin_sso_providers_path, icon: "key-round", if: Current.user&.super_admin? }, diff --git a/app/views/settings/bank_sync/_provider_link.html.erb b/app/views/settings/bank_sync/_provider_link.html.erb deleted file mode 100644 index 6cb50df92..000000000 --- a/app/views/settings/bank_sync/_provider_link.html.erb +++ /dev/null @@ -1,33 +0,0 @@ -<%# locals: (provider_link:) %> - -<%# Assign distinct colors to each provider %> -<% provider_colors = { - "Lunch Flow" => "#6471eb", - "Plaid" => "#4da568", - "SimpleFin" => "#e99537", - "Enable Banking" => "#6471eb", - "CoinStats" => "#FF9332", # https://coinstats.app/press-kit/ - "Sophtron" => "#1E90FF" -} %> -<% provider_color = provider_colors[provider_link[:name]] || "#6B7280" %> - -<%= link_to provider_link[:path], - target: provider_link[:target], - rel: provider_link[:rel], - class: "flex justify-between items-center p-4 bg-container hover:bg-container-hover transition-colors" do %> -
- <%= render partial: "shared/color_avatar", locals: { name: provider_link[:name], color: provider_color } %> - -
-

- <%= provider_link[:name] %> -

-

- <%= provider_link[:description] %> -

-
-
-
- <%= icon("arrow-right", size: "sm", class: "text-secondary") %> -
-<% end %> diff --git a/app/views/settings/bank_sync/show.html.erb b/app/views/settings/bank_sync/show.html.erb deleted file mode 100644 index 51c42bfcb..000000000 --- a/app/views/settings/bank_sync/show.html.erb +++ /dev/null @@ -1,26 +0,0 @@ -<%= content_for :page_title, "Bank Sync" %> - -
- <% if @providers.any? %> -
-
-

PROVIDERS

- · -

<%= @providers.count %>

-
- -
-
- <%= render partial: "provider_link", collection: @providers, spacer_template: "shared/ruler" %> -
-
-
- <% else %> -
-
-

No providers configured

-

Configure providers to link your bank accounts.

-
-
- <% end %> -
diff --git a/app/views/settings/providers/_binance_panel.html.erb b/app/views/settings/providers/_binance_panel.html.erb index 05378904a..f93eeea47 100644 --- a/app/views/settings/providers/_binance_panel.html.erb +++ b/app/views/settings/providers/_binance_panel.html.erb @@ -1,32 +1,39 @@
<% items = local_assigns[:binance_items] || @binance_items || Current.family.binance_items.active.ordered %> -
-

<%= t("settings.providers.binance_panel.setup_instructions") %>

-
    -
  1. <%= t("settings.providers.binance_panel.step1_html").html_safe %>
  2. -
  3. <%= t("settings.providers.binance_panel.step2") %>
  4. -
  5. <%= t("settings.providers.binance_panel.step3") %>
  6. -
-

<%= t("settings.providers.binance_panel.no_withdraw_warning") %>

-
+ <%= render DS::Alert.new( + variant: :warning, + message: safe_join([ + content_tag(:p, t("settings.providers.binance_panel.no_withdraw_title"), class: "font-medium"), + content_tag(:p, t("settings.providers.binance_panel.no_withdraw_body"), class: "mt-1") + ]) + ) %> -
-

<%= t("settings.providers.binance_panel.ip_hint_title") %>

-

<%= t("settings.providers.binance_panel.ip_hint_body") %>

- <% server_ip = ENV["BINANCE_EGRESS_IP"].presence %> - <% if server_ip %> - <%= server_ip %> - <% else %> -

<%= t("settings.providers.binance_panel.ip_hint_contact_admin") %>

- <% end %> +
+ <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.binance_panel.step1_html").html_safe, + t("settings.providers.binance_panel.step2"), + t("settings.providers.binance_panel.step3") + ] %> + +
+

+ <%= t("settings.providers.binance_panel.ip_hint_title") %> +

+

<%= t("settings.providers.binance_panel.ip_hint_body") %>

+ <% server_ip = ENV["BINANCE_EGRESS_IP"].presence %> + <% if server_ip %> + <%= server_ip %> + <% else %> +

<%= t("settings.providers.binance_panel.ip_hint_contact_admin") %>

+ <% end %> +
<% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% if items.any? %> @@ -94,13 +101,4 @@ <% end %> <% end %> -
- <% if items.any? %> -
-

<%= t("settings.providers.binance_panel.status_connected") %>

- <% else %> -
-

<%= t("settings.providers.binance_panel.status_not_connected") %>

- <% end %> -
diff --git a/app/views/settings/providers/_coinbase_panel.html.erb b/app/views/settings/providers/_coinbase_panel.html.erb index 3cd62ff5d..546d32626 100644 --- a/app/views/settings/providers/_coinbase_panel.html.erb +++ b/app/views/settings/providers/_coinbase_panel.html.erb @@ -1,20 +1,16 @@
<% items = local_assigns[:coinbase_items] || @coinbase_items || Current.family.coinbase_items.active.ordered %> -
-

<%= t("settings.providers.coinbase_panel.setup_instructions") %>

-
    -
  1. <%= t("settings.providers.coinbase_panel.step1_html").html_safe %>
  2. -
  3. <%= t("settings.providers.coinbase_panel.step2") %>
  4. -
  5. <%= t("settings.providers.coinbase_panel.step3") %>
  6. -
-
+ <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.coinbase_panel.step1_html").html_safe, + t("settings.providers.coinbase_panel.step2"), + t("settings.providers.coinbase_panel.step3") + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% if items.any? %> @@ -82,13 +78,4 @@ <% end %> <% end %> -
- <% if items.any? %> -
-

<%= t("settings.providers.coinbase_panel.status_connected") %>

- <% else %> -
-

<%= t("settings.providers.coinbase_panel.status_not_connected") %>

- <% end %> -
diff --git a/app/views/settings/providers/_coinstats_panel.html.erb b/app/views/settings/providers/_coinstats_panel.html.erb index c5359e104..b9e62b071 100644 --- a/app/views/settings/providers/_coinstats_panel.html.erb +++ b/app/views/settings/providers/_coinstats_panel.html.erb @@ -1,18 +1,14 @@
-
-

<%= t("coinstats_items.new.setup_instructions") %>

-
    -
  1. <%= t("coinstats_items.new.step1_html").html_safe %>
  2. -
  3. <%= t("coinstats_items.new.step2") %>
  4. -
  5. <%= t("coinstats_items.new.step3_html", accounts_url: accounts_path).html_safe %>
  6. -
-
+ <%= render "settings/providers/setup_steps", + steps: [ + t("coinstats_items.new.step1_html").html_safe, + t("coinstats_items.new.step2"), + t("coinstats_items.new.step3_html", accounts_url: accounts_path).html_safe + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% @@ -41,14 +37,4 @@
<% end %> - <% items = local_assigns[:coinstats_items] || @coinstats_items || Current.family.coinstats_items.where.not(api_key: [nil, ""]) %> -
- <% if items&.any? %> -
-

<%= t("coinstats_items.new.status_configured_html", accounts_url: accounts_path).html_safe %>

- <% else %> -
-

<%= t("coinstats_items.new.status_not_configured") %>

- <% end %> -
diff --git a/app/views/settings/providers/_connection_row.html.erb b/app/views/settings/providers/_connection_row.html.erb new file mode 100644 index 000000000..397e8ee15 --- /dev/null +++ b/app/views/settings/providers/_connection_row.html.erb @@ -0,0 +1,43 @@ +<%# locals: (entry:, open:) %> +<% + status = entry[:summary][:status] + meta = entry[:summary][:meta] + last_synced = entry[:summary][:last_synced_at] + border_class = + case status + when :warn then "border border-warning/25" + when :err then "border border-destructive/25" + else "border border-transparent" + end + sync_action = entry[:partial].present? ? render("settings/providers/sync_button", provider_key: entry[:provider_key], last_synced_at: last_synced) : nil + status_pill = render("settings/providers/status_pill", status: status) + maturity_lbl = Settings::ProviderCard.maturity_label(entry[:maturity]) + details_data = entry[:auto_open_param].present? ? { controller: "auto-open", auto_open_param_value: entry[:auto_open_param] } : {} +%> +<%= tag.details open: open, + class: "group bg-container shadow-border-xs rounded-xl #{border_class}", + data: details_data do %> + + <%= icon "chevron-right", size: "sm", class: "!w-3.5 !h-3.5 text-secondary group-open:rotate-90 transition-transform" %> +
+

<%= entry[:title] %>

+ <%= render "settings/providers/maturity_badge", label: maturity_lbl %> +
+
+ <% if meta.present? %> + <%= meta %> + <% end %> + <%= status_pill %> + <%= sync_action if sync_action %> +
+
+
+ <% if entry[:configuration] %> + <%= render "settings/providers/provider_form", configuration: entry[:configuration] %> + <% else %> + + <%= render "settings/providers/#{entry[:partial]}" %> + + <% end %> +
+<% end %> diff --git a/app/views/settings/providers/_drawer_header.html.erb b/app/views/settings/providers/_drawer_header.html.erb new file mode 100644 index 000000000..df439dafd --- /dev/null +++ b/app/views/settings/providers/_drawer_header.html.erb @@ -0,0 +1,22 @@ +<%# locals: (provider_key:, title:) %> +<% meta = provider_key.present? ? Provider::Metadata.for(provider_key) : nil %> +<% maturity_label = meta ? Settings::ProviderCard.maturity_label(meta[:maturity]) : nil %> +
+
+ <% if meta && meta[:logo_bg].present? %> + + <%= meta[:logo_text] %> + + <% end %> +

<%= title %>

+ <%= render "settings/providers/maturity_badge", label: maturity_label %> +
+ <%= render DS::Button.new( + variant: "icon", + class: "ml-auto hidden lg:flex", + icon: "x", + title: t("common.close"), + aria_label: t("common.close"), + data: { action: "DS--dialog#close" } + ) %> +
diff --git a/app/views/settings/providers/_enable_banking_panel.html.erb b/app/views/settings/providers/_enable_banking_panel.html.erb index d778d8759..4085520ef 100644 --- a/app/views/settings/providers/_enable_banking_panel.html.erb +++ b/app/views/settings/providers/_enable_banking_panel.html.erb @@ -1,27 +1,18 @@
-
-

Setup instructions:

-
    -
  1. Visit your Enable Banking developer account to get your credentials
  2. -
  3. Select your country code from the dropdown below
  4. -
  5. Enter your Application ID and paste your Client Certificate (including the private key)
  6. -
  7. Click Save Configuration, then use "Add Connection" to link your bank
  8. -
  9. <%= t("settings.providers.enable_banking_panel.callback_url_instruction", callback_url: enable_banking_callback_url) %>
  10. -
- -

Field descriptions:

-
    -
  • Country Code: ISO 3166-1 alpha-2 country code (e.g., GB, DE, FR) - determines available banks
  • -
  • Application ID: The ID generated in your Enable Banking developer account
  • -
  • Client Certificate: The certificate generated when you created your application (must include the private key)
  • -
-
+ <% + eb_link = link_to("Enable Banking", "https://enablebanking.com", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline") + %> + <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.enable_banking_panel.step_1_html", link: eb_link), + t("settings.providers.enable_banking_panel.step_2"), + t("settings.providers.enable_banking_panel.step_3"), + t("settings.providers.enable_banking_panel.callback_url_instruction", callback_url: enable_banking_callback_url) + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% @@ -78,10 +69,13 @@ { label: "Country", class: "form-field__input" } %> <% if has_authenticated_connections && !is_new_record %> -
-

Configuration locked

-

Credentials cannot be changed while you have active bank connections. Remove all connections first to update credentials.

-
+ <%= render DS::Alert.new( + variant: :warning, + message: safe_join([ + content_tag(:p, "Configuration locked", class: "font-medium"), + content_tag(:p, "Disconnect all linked banks before changing these credentials.", class: "mt-1") + ]) + ) %> <% end %> <%= form.text_field :application_id, @@ -98,7 +92,7 @@ disabled: has_authenticated_connections && !is_new_record %>
- <%= form.submit is_new_record ? "Save Configuration" : "Update Configuration", + <%= form.submit is_new_record ? "Save and connect" : "Update connection", class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
<% end %> @@ -169,7 +163,7 @@ <%= link_to select_bank_enable_banking_item_path(item), class: "inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium text-inverse button-bg-primary hover:button-bg-primary-hover transition-colors", data: { turbo_frame: "modal" } do %> - Connect Bank + Connect bank <% end %> <% end %> diff --git a/app/views/settings/providers/_group_heading.html.erb b/app/views/settings/providers/_group_heading.html.erb new file mode 100644 index 000000000..e0a831f49 --- /dev/null +++ b/app/views/settings/providers/_group_heading.html.erb @@ -0,0 +1,12 @@ +<%# locals: (title:, count: nil, description: nil, anchor: nil) %> +<%= tag.div id: anchor.presence, class: "flex items-baseline justify-between gap-3 mt-6 mb-1.5 px-1" do %> +

+ <%= title %> + <% if count %> + · <%= count %> + <% end %> +

+ <% if description.present? %> +

<%= description %>

+ <% end %> +<% end %> diff --git a/app/views/settings/providers/_health_strip.html.erb b/app/views/settings/providers/_health_strip.html.erb new file mode 100644 index 000000000..1a30989f7 --- /dev/null +++ b/app/views/settings/providers/_health_strip.html.erb @@ -0,0 +1,28 @@ +<%# locals: (connected:, needs_attention:, accounts_syncing:, last_synced_at:) %> +
+ + <%= icon "check", size: "sm", class: "!w-3.5 !h-3.5 text-success" %> + <%= connected %> + <%= t("settings.providers.health_strip.connected") %> + + <% if needs_attention.positive? %> + + + <%= icon "circle-alert", size: "sm", class: "!w-3.5 !h-3.5 text-warning" %> + <%= needs_attention %> + <%= t("settings.providers.health_strip.needs_attention") %> + + <% end %> + <% if accounts_syncing.positive? %> + + + <%= accounts_syncing %> + <%= t("settings.providers.health_strip.accounts_syncing") %> + + <% end %> + <% if last_synced_at %> + + <%= t("settings.providers.health_strip.last_synced", time: concise_time_ago(last_synced_at)) %> + + <% end %> +
diff --git a/app/views/settings/providers/_indexa_capital_panel.html.erb b/app/views/settings/providers/_indexa_capital_panel.html.erb index c31ec5f1c..c0fbdcaf1 100644 --- a/app/views/settings/providers/_indexa_capital_panel.html.erb +++ b/app/views/settings/providers/_indexa_capital_panel.html.erb @@ -1,18 +1,14 @@
-
-

<%= t("indexa_capital_items.panel.setup_instructions") %>

-
    -
  1. <%= t("indexa_capital_items.panel.step_1") %>
  2. -
  3. <%= t("indexa_capital_items.panel.step_2") %>
  4. -
  5. <%= t("indexa_capital_items.panel.step_3") %>
  6. -
-
+ <%= render "settings/providers/setup_steps", + steps: [ + t("indexa_capital_items.panel.step_1"), + t("indexa_capital_items.panel.step_2"), + t("indexa_capital_items.panel.step_3") + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% @@ -27,14 +23,11 @@ data: { turbo: true }, class: "space-y-3" do |form| %> -
-

<%= t("indexa_capital_items.panel.fields.api_token.label") %>

-

<%= t("indexa_capital_items.panel.fields.api_token.description") %>

- <%= form.text_field :api_token, - label: t("indexa_capital_items.panel.fields.api_token.label"), - placeholder: is_new_record ? t("indexa_capital_items.panel.fields.api_token.placeholder_new") : t("indexa_capital_items.panel.fields.api_token.placeholder_update"), - type: :password %> -
+ <%= form.text_field :api_token, + label: t("indexa_capital_items.panel.fields.api_token.label"), + placeholder: is_new_record ? t("indexa_capital_items.panel.fields.api_token.placeholder_new") : t("indexa_capital_items.panel.fields.api_token.placeholder_update"), + type: :password %> +

<%= t("indexa_capital_items.panel.fields.api_token.description") %>

@@ -64,14 +57,4 @@
<% end %> - <% items = local_assigns[:indexa_capital_items] || @indexa_capital_items || Current.family.indexa_capital_items.where.not(username: [nil, ""], document: [nil, ""], password: [nil, ""]).or(Current.family.indexa_capital_items.where.not(api_token: [nil, ""])) %> -
- <% if items&.any? %> -
-

<%= t("indexa_capital_items.panel.status_configured_html", accounts_path: accounts_path).html_safe %>

- <% else %> -
-

<%= t("indexa_capital_items.panel.status_not_configured") %>

- <% end %> -
diff --git a/app/views/settings/providers/_lunchflow_panel.html.erb b/app/views/settings/providers/_lunchflow_panel.html.erb index b54c49810..e4bccf2ba 100644 --- a/app/views/settings/providers/_lunchflow_panel.html.erb +++ b/app/views/settings/providers/_lunchflow_panel.html.erb @@ -1,24 +1,17 @@
-
-

Setup instructions:

-
    -
  1. Visit Lunch Flow to get your API key
  2. -
  3. Paste your API key below and click the Save button
  4. -
  5. After a successful connection, go to the Accounts tab to set up new accounts and link them to your existing ones
  6. -
- -

Field descriptions:

-
    -
  • API Key: Your Lunch Flow API key for authentication (required)
  • -
  • Base URL: Base URL for Lunch Flow API (optional, defaults to https://lunchflow.app/api/v1)
  • -
-
+ <% + lf_link = link_to("Lunch Flow", "https://www.lunchflow.app/?atp=BiDIYS", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline") + %> + <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.lunchflow_panel.step_1_html", link: lf_link), + t("settings.providers.lunchflow_panel.step_2"), + t("settings.providers.lunchflow_panel.step_3") + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% @@ -44,19 +37,9 @@ value: lunchflow_item.base_url %>
- <%= form.submit is_new_record ? "Save Configuration" : "Update Configuration", + <%= form.submit is_new_record ? "Save and connect" : "Update connection", class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
<% end %> - <% items = local_assigns[:lunchflow_items] || @lunchflow_items || Current.family.lunchflow_items.where.not(api_key: nil) %> -
- <% if items&.any? %> -
-

Configured and ready to use. Visit the Accounts tab to manage and set up accounts.

- <% else %> -
-

Not configured

- <% end %> -
diff --git a/app/views/settings/providers/_maturity_badge.html.erb b/app/views/settings/providers/_maturity_badge.html.erb new file mode 100644 index 000000000..56127edec --- /dev/null +++ b/app/views/settings/providers/_maturity_badge.html.erb @@ -0,0 +1,4 @@ +<%# locals: (label:) %> +<% if label %> + <%= label %> +<% end %> diff --git a/app/views/settings/providers/_mercury_panel.html.erb b/app/views/settings/providers/_mercury_panel.html.erb index 43173fa2b..47a7f8b20 100644 --- a/app/views/settings/providers/_mercury_panel.html.erb +++ b/app/views/settings/providers/_mercury_panel.html.erb @@ -2,26 +2,18 @@ <% active_items = local_assigns[:mercury_items] || @mercury_items || Current.family.mercury_items.active.ordered %> <% credentialed_items = active_items.select(&:credentials_configured?) %> -
-

<%= t("mercury_items.provider_panel.setup_title") %>

-
    -
  1. <%= t("mercury_items.provider_panel.instructions.sign_in_html", link: link_to("Mercury", "https://mercury.com", target: "_blank", rel: "noopener noreferrer", class: "link")) %>
  2. -
  3. <%= t("mercury_items.provider_panel.instructions.open_tokens") %>
  4. -
  5. <%= t("mercury_items.provider_panel.instructions.create_token") %>
  6. -
  7. <%= t("mercury_items.provider_panel.instructions.whitelist_ip_html") %>
  8. -
  9. <%= t("mercury_items.provider_panel.instructions.copy_token_html") %>
  10. -
- -

- <%= t("mercury_items.provider_panel.sandbox_note_html") %> -

-
+ <%= render "settings/providers/setup_steps", + steps: [ + t("mercury_items.provider_panel.instructions.sign_in_html", link: link_to("Mercury", "https://mercury.com", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline")).html_safe, + t("mercury_items.provider_panel.instructions.open_tokens"), + t("mercury_items.provider_panel.instructions.create_token"), + t("mercury_items.provider_panel.instructions.whitelist_ip_html").html_safe, + t("mercury_items.provider_panel.instructions.copy_token_html").html_safe + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% if active_items.any? %> @@ -96,47 +88,38 @@
<% end %> -
class="group bg-container p-4 shadow-border-xs rounded-xl"> - - <%= icon "plus" %> + <% mercury_item = Current.family.mercury_items.build(name: t("mercury_items.provider_panel.default_connection_name")) %> + <% if active_items.any? %> +

+ <%= icon "plus", size: "sm" %> <%= t("mercury_items.provider_panel.add_connection") %> -

+

+ <% end %> + <%= styled_form_with model: mercury_item, + url: mercury_items_path, + scope: :mercury_item, + method: :post, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :name, + label: t("mercury_items.provider_panel.connection_name_label"), + placeholder: t("mercury_items.provider_panel.connection_name_placeholder") %> - <% mercury_item = Current.family.mercury_items.build(name: t("mercury_items.provider_panel.default_connection_name")) %> - <%= styled_form_with model: mercury_item, - url: mercury_items_path, - scope: :mercury_item, - method: :post, - data: { turbo: true }, - class: "space-y-3 mt-4" do |form| %> - <%= form.text_field :name, - label: t("mercury_items.provider_panel.connection_name_label"), - placeholder: t("mercury_items.provider_panel.connection_name_placeholder") %> + <%= form.text_field :token, + label: t("mercury_items.provider_panel.token_label"), + placeholder: t("mercury_items.provider_panel.token_placeholder"), + type: :password, + value: nil %> - <%= form.text_field :token, - label: t("mercury_items.provider_panel.token_label"), - placeholder: t("mercury_items.provider_panel.token_placeholder"), - type: :password, - value: nil %> + <%= form.text_field :base_url, + label: t("mercury_items.provider_panel.base_url_label"), + placeholder: t("mercury_items.provider_panel.base_url_placeholder") %> +

<%= t("mercury_items.provider_panel.sandbox_note_html").html_safe %>

- <%= form.text_field :base_url, - label: t("mercury_items.provider_panel.base_url_label"), - placeholder: t("mercury_items.provider_panel.base_url_placeholder") %> +
+ <%= form.submit t("mercury_items.provider_panel.add_connection"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %> +
+ <% end %> -
- <%= form.submit t("mercury_items.provider_panel.add_connection"), - class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %> -
- <% end %> - - -
- <% if credentialed_items.any? %> -
-

<%= t("mercury_items.provider_panel.configured_html", accounts_link: link_to(t("mercury_items.provider_panel.accounts_link"), accounts_path, class: "link")) %>

- <% else %> -
-

<%= t("mercury_items.provider_panel.not_configured") %>

- <% end %> -
diff --git a/app/views/settings/providers/_provider_form.html.erb b/app/views/settings/providers/_provider_form.html.erb index 5c86e3b3a..4a7294a65 100644 --- a/app/views/settings/providers/_provider_form.html.erb +++ b/app/views/settings/providers/_provider_form.html.erb @@ -1,34 +1,35 @@ <% # Parameters: # - configuration: Provider::Configurable::Configuration object + provider_key = configuration.provider_key.to_s + + setup_steps_data = + if %w[plaid plaid_eu].include?(provider_key) + plaid_link = link_to("Plaid Dashboard", "https://dashboard.plaid.com/team/keys", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline") + step_1_key = "settings.providers.#{provider_key}_panel.step_1_html" + [ + t(step_1_key, link: plaid_link), + t("settings.providers.plaid_panel.step_2"), + t("settings.providers.plaid_panel.step_3") + ] + end %>
-
- <% if configuration.provider_description.present? %> -
- <%= markdown(configuration.provider_description).html_safe %> -
- <% end %> + <% if setup_steps_data %> + <%= render "settings/providers/setup_steps", steps: setup_steps_data %> + <% elsif configuration.provider_description.present? %> +
+ <%= markdown(configuration.provider_description).html_safe %> +
+ <% end %> - <% env_configured = configuration.fields.any? { |f| f.env_key && ENV[f.env_key].present? } %> - <% if env_configured %> -

- Configuration can be set via environment variables or overridden below. -

- <% end %> - - <% if configuration.fields.any? { |f| f.description.present? } %> -

Field descriptions:

-
    - <% configuration.fields.each do |field| %> - <% if field.description.present? %> -
  • <%= field.label %>: <%= field.description %>
  • - <% end %> - <% end %> -
- <% end %> -
+ <% env_configured = configuration.fields.any? { |f| f.env_key && ENV[f.env_key].present? } %> + <% if env_configured %> +

+ Configuration can be set via environment variables or overridden below. +

+ <% end %> <%= styled_form_with model: Setting.new, url: settings_providers_path, @@ -37,50 +38,34 @@ <% configuration.fields.each do |field| %> <% env_value = ENV[field.env_key] if field.env_key - # Use dynamic hash-style access - works without explicit field declaration setting_value = Setting[field.setting_key] - - # Show the setting value if it exists, otherwise show ENV value - # This allows users to see what they've overridden current_value = setting_value.presence || env_value - # Mask secret values if they exist display_value = if field.secret && current_value.present? "********" else current_value end - # Determine input type input_type = field.secret ? "password" : "text" - - # Don't disable fields - allow overriding ENV variables - disabled = false %> - <%= form.text_field field.setting_key, - label: field.label, - type: input_type, - placeholder: field.default || (field.required ? "" : "Optional"), - value: display_value, - disabled: disabled %> +
+ <%= form.text_field field.setting_key, + label: field.label, + type: input_type, + placeholder: field.default || (field.required ? "" : "Optional"), + value: display_value %> + <% if field.description.present? %> +

<%= field.description %>

+ <% end %> +
<% end %>
- <%= form.submit "Save Configuration", + <%= form.submit "Save and connect", class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
<% end %> - - <%# Show configuration status %> -
- <% if configuration.configured? %> -
-

Configured and ready to use

- <% else %> -
-

Not configured

- <% end %> -
diff --git a/app/views/settings/providers/_search_filters.html.erb b/app/views/settings/providers/_search_filters.html.erb new file mode 100644 index 000000000..f4c295faf --- /dev/null +++ b/app/views/settings/providers/_search_filters.html.erb @@ -0,0 +1,27 @@ +
+
+ " + placeholder="<%= t("settings.providers.search_filters.placeholder") %>" + class="block w-full border border-secondary rounded-md py-2.5 pl-10 pr-3 bg-container focus:ring-gray-500 sm:text-sm"> +
+ <%= icon "search", class: "text-secondary" %> +
+
+
+ <% %w[all bank crypto investment].each do |kind| %> + <% active = kind == "all" %> + + <% end %> +
+
diff --git a/app/views/settings/providers/_setup_steps.html.erb b/app/views/settings/providers/_setup_steps.html.erb new file mode 100644 index 000000000..e3537b3bb --- /dev/null +++ b/app/views/settings/providers/_setup_steps.html.erb @@ -0,0 +1,26 @@ +<%# locals: (steps:, help: nil, eyebrow: nil) %> +<%# steps: array of strings (or html_safe strings; caller is responsible for safety). + help: optional hash { url:, text: } rendered under the steps with a small divider + book icon. + eyebrow: optional override for the localized "SETUP" eyebrow label. %> +
+

+ <%= eyebrow.presence || t("settings.providers.setup_steps.eyebrow") %> +

+
    + <% steps.each_with_index do |step, i| %> +
  1. + <%= i + 1 %> + <%= step %> +
  2. + <% end %> +
+ <% if help %> +
+ <%= icon "book-open", size: "sm", class: "!w-3 !h-3" %> + + <%= t("settings.providers.setup_steps.need_help") %> + <%= link_to help[:text], help[:url], class: "text-primary font-medium", target: "_blank", rel: "noopener noreferrer" %> + +
+ <% end %> +
diff --git a/app/views/settings/providers/_simplefin_panel.html.erb b/app/views/settings/providers/_simplefin_panel.html.erb index d4b6b5251..a20b6ac4b 100644 --- a/app/views/settings/providers/_simplefin_panel.html.erb +++ b/app/views/settings/providers/_simplefin_panel.html.erb @@ -1,22 +1,16 @@
-
-

Setup instructions:

-
    -
  1. Visit SimpleFIN Bridge to get your one-time setup token
  2. -
  3. Paste the token below and click the Save button to enable SimpleFIN bank data sync
  4. -
  5. After a successful connection, go to the Accounts tab to set up new accounts and link them to your existing ones
  6. -
- -

Field descriptions:

-
    -
  • Setup Token: Your SimpleFIN one-time setup token from SimpleFIN Bridge (consumed on first use)
  • -
-
+ <% + sf_link = link_to("SimpleFIN Bridge", "https://beta-bridge.simplefin.org", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline") + %> + <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.simplefin_panel.step_1_html", link: sf_link), + t("settings.providers.simplefin_panel.step_2"), + t("settings.providers.simplefin_panel.step_3") + ] %> <% if defined?(@error_message) && @error_message.present? %> -
-

<%= @error_message %>

-
+ <%= render DS::Alert.new(message: @error_message, variant: :error) %> <% end %> <%= styled_form_with model: SimplefinItem.new, @@ -31,18 +25,9 @@ type: :password %>
- <%= form.submit "Save Configuration", + <%= form.submit "Save and connect", class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-gray-900 theme-dark:focus:ring-white focus:ring-offset-2 transition-colors" %>
<% end %> -
- <% if @simplefin_items&.any? %> -
-

Configured and ready to use. Visit the Accounts tab to manage and set up accounts.

- <% else %> -
-

Not configured

- <% end %> -
diff --git a/app/views/settings/providers/_snaptrade_panel.html.erb b/app/views/settings/providers/_snaptrade_panel.html.erb index 314213acc..b99bace2b 100644 --- a/app/views/settings/providers/_snaptrade_panel.html.erb +++ b/app/views/settings/providers/_snaptrade_panel.html.erb @@ -1,23 +1,17 @@
-
-

<%= t("providers.snaptrade.description") %>

+ <%= render DS::Alert.new(message: t("providers.snaptrade.free_tier_warning"), variant: :warning) %> -

<%= t("providers.snaptrade.setup_title") %>

-
    -
  1. <%= t("providers.snaptrade.step_1_html") %>
  2. -
  3. <%= t("providers.snaptrade.step_2") %>
  4. -
  5. <%= t("providers.snaptrade.step_3") %>
  6. -
  7. <%= t("providers.snaptrade.step_4") %>
  8. -
- -

<%= icon("alert-triangle", class: "inline-block w-4 h-4 mr-1") %><%= t("providers.snaptrade.free_tier_warning") %>

-
+ <%= render "settings/providers/setup_steps", + steps: [ + t("providers.snaptrade.step_1_html").html_safe, + t("providers.snaptrade.step_2"), + t("providers.snaptrade.step_3"), + t("providers.snaptrade.step_4") + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% @@ -51,56 +45,49 @@ <% items = local_assigns[:snaptrade_items] || @snaptrade_items || Current.family.snaptrade_items.where.not(client_id: [nil, ""]) %> -
- <% if items&.any? %> - <% item = items.first %> - <% if item.user_registered? %> -
- -
-
-

- <%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %> - <% if item.unlinked_accounts_count > 0 %> - (<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>) - <% end %> -

-
- - <%= t("providers.snaptrade.manage_connections") %> - <%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %> - -
- -
-

- <%= t("providers.snaptrade.connection_limit_info") %> -

- -
- <%= icon "loader-2", class: "w-4 h-4 animate-spin" %> - <%= t("providers.snaptrade.loading_connections") %> -
- -
-
-
-
- <% else %> -
-
-

<%= t("providers.snaptrade.status_needs_registration") %>

-
- <% end %> - <% else %> -
-
-

<%= t("providers.snaptrade.status_not_configured") %>

+ <% if items&.any? %> + <% item = items.first %> + <% unless item.user_registered? %> +
+ +

<%= t("providers.snaptrade.status_needs_registration") %>

<% end %> -
+ <% end %> + + <% if items&.any? && items.first.user_registered? %> + <% item = items.first %> +
+
+ +
+

+ <%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %> + <% if item.unlinked_accounts_count > 0 %> + (<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>) + <% end %> +

+
+ + <%= t("providers.snaptrade.manage_connections") %> + <%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %> + +
+ +
+
+ <%= icon "loader-2", class: "w-4 h-4 animate-spin" %> + <%= t("providers.snaptrade.loading_connections") %> +
+ +
+
+
+
+
+ <% end %>
diff --git a/app/views/settings/providers/_sophtron_panel.html.erb b/app/views/settings/providers/_sophtron_panel.html.erb index 878d4dd3f..e17c8a816 100644 --- a/app/views/settings/providers/_sophtron_panel.html.erb +++ b/app/views/settings/providers/_sophtron_panel.html.erb @@ -1,25 +1,14 @@
-
-

<%= t("sophtron_items.sophtron_panel.setup_instructions_title") %>

-
    -
  1. <%= t("sophtron_items.sophtron_panel.setup_instructions.step_1_html", url: "https://www.sophtron.com") %>
  2. -
  3. <%= t("sophtron_items.sophtron_panel.setup_instructions.step_2") %>
  4. -
  5. <%= t("sophtron_items.sophtron_panel.setup_instructions.step_3") %>
  6. -
- -

<%= t("sophtron_items.sophtron_panel.field_descriptions_title") %>

-
    -
  • <%= t("sophtron_items.sophtron_panel.field_descriptions.user_id_html") %>
  • -
  • <%= t("sophtron_items.sophtron_panel.field_descriptions.access_key_html") %>
  • -
  • <%= t("sophtron_items.sophtron_panel.field_descriptions.base_url_html") %>
  • -
-
+ <%= render "settings/providers/setup_steps", + steps: [ + t("sophtron_items.sophtron_panel.setup_instructions.step_1_html", url: "https://www.sophtron.com").html_safe, + t("sophtron_items.sophtron_panel.setup_instructions.step_2"), + t("sophtron_items.sophtron_panel.setup_instructions.step_3") + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

-
+ <%= render DS::Alert.new(message: error_msg, variant: :error) %> <% end %> <% @@ -56,13 +45,4 @@
<% end %> -
- <% if Current.family.sophtron_items.any? %> -
-

<%= t("sophtron_items.sophtron_panel.status.configured_html", accounts_path: accounts_path) %>

- <% else %> -
-

<%= t("sophtron_items.sophtron_panel.status.not_configured") %>

- <% end %> -
-
\ No newline at end of file + diff --git a/app/views/settings/providers/_status_pill.html.erb b/app/views/settings/providers/_status_pill.html.erb new file mode 100644 index 000000000..1f46b9216 --- /dev/null +++ b/app/views/settings/providers/_status_pill.html.erb @@ -0,0 +1,7 @@ +<%# locals: (status:) %> +<% classes = status_pill_classes(status) %> +<% dot_class, pill_class = classes[:dot], classes[:pill] %> + + + <%= t("settings.providers.status.#{status}") %> + diff --git a/app/views/settings/providers/_sync_button.html.erb b/app/views/settings/providers/_sync_button.html.erb new file mode 100644 index 000000000..6e3df16ae --- /dev/null +++ b/app/views/settings/providers/_sync_button.html.erb @@ -0,0 +1,15 @@ +<%# locals: (provider_key:, last_synced_at: nil) %> +<% recently_synced = last_synced_at.present? && last_synced_at > 60.seconds.ago %> +<% button_label = recently_synced ? t("settings.providers.recently_synced") : t("settings.providers.sync_provider") %> +<%= render DS::Button.new( + variant: "icon", + size: "sm", + icon: "refresh-cw", + href: sync_provider_settings_providers_path(provider_key: provider_key), + method: :post, + disabled: recently_synced, + title: button_label, + aria: { label: button_label }, + class: "disabled:opacity-40 disabled:cursor-not-allowed", + form: { onclick: "event.stopPropagation()", class: "inline-flex" } + ) %> diff --git a/app/views/settings/providers/connect_form.html.erb b/app/views/settings/providers/connect_form.html.erb new file mode 100644 index 000000000..f4c6e6d3c --- /dev/null +++ b/app/views/settings/providers/connect_form.html.erb @@ -0,0 +1,21 @@ +<%= render DS::Dialog.new(frame: "drawer", responsive: true, auto_open: true) do |dialog| %> + <% provider_key = @panel_key || @provider_configuration&.provider_key&.to_s %> + <% dialog.with_header(custom_header: true) do %> + <%= render "settings/providers/drawer_header", provider_key: provider_key, title: @panel_title %> + <% end %> + <% dialog.with_body do %> + <% if @panel_partial %> + + <%= render "settings/providers/#{@panel_partial}" %> + + <% else %> + + <%= render "settings/providers/provider_form", configuration: @provider_configuration %> + + <% end %> + +

+ <%= t("settings.providers.drawer_trust_statement") %> +

+ <% end %> +<% end %> diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 9f2b07c09..b78a19a96 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -1,94 +1,98 @@ -<%= content_for :page_title, "Sync Providers" %> +<%= content_for :page_title, t("settings.providers.bank_sync.page_title") %>
<% if @encryption_error %> -
-
- <%= icon("triangle-alert", class: "w-5 h-5 text-destructive shrink-0 mt-0.5") %> -
-

<%= t("settings.providers.encryption_error.title") %>

-

<%= t("settings.providers.encryption_error.message") %>

+ <%= render DS::Alert.new( + variant: :error, + message: safe_join([ + content_tag(:h2, t("settings.providers.encryption_error.title"), class: "font-medium"), + content_tag(:p, t("settings.providers.encryption_error.message"), class: "text-sm mt-1") + ]) + ) %> + <% else %> +
+

<%= t("settings.providers.bank_sync.lede") %>

+ <% if @connected.any? || @needs_attention.any? %> + <% sync_all_disabled = Current.family.last_sync_all_attempted_at.present? && Current.family.last_sync_all_attempted_at > 30.seconds.ago %> + <%= render DS::Link.new( + text: t("settings.providers.sync_all"), + icon: "refresh-cw", + variant: "outline", + href: sync_all_settings_providers_path, + method: :post, + title: sync_all_disabled ? t("settings.providers.sync_all_recently") : nil, + aria: { disabled: sync_all_disabled.to_s }, + class: sync_all_disabled ? "opacity-50 pointer-events-none" : nil + ) %> + <% end %> +
+ + <% all_connections = @needs_attention + @connected %> + + <% if all_connections.any? %> + <% if @health %> + <%= render "settings/providers/health_strip", + connected: @health[:connected], + needs_attention: @health[:needs_attention], + accounts_syncing: @health[:accounts_syncing], + last_synced_at: @health[:last_synced_at] %> + <% end %> + + <%= render "settings/providers/group_heading", + title: t("settings.providers.groups.your_connections"), + count: all_connections.size %> + +
+ <% all_connections.each do |entry| %> + <% auto_open = all_connections.size == 1 %> + <%= render "settings/providers/connection_row", entry: entry, open: auto_open %> + <% end %> +
+ <% end %> + + <% if @available.any? %> +
+ <%= render "settings/providers/search_filters" %> + +
+

+ <%= t("settings.providers.groups.available") %> + + · <%= @available.size %> + +

+
+ + + +
+ <% @available.each do |entry| %> + <% meta = Provider::Metadata.for(entry[:provider_key]) %> + <%= render Settings::ProviderCard.new( + provider_key: entry[:provider_key], + name: entry[:title], + tagline: t("settings.providers.taglines.#{entry[:provider_key]}", default: nil), + region: meta[:region], + kind: meta[:kind], + tier: meta[:tier], + maturity: meta[:maturity] || :stable, + logo_bg: meta[:logo_bg], + logo_text: meta[:logo_text] + ) %> + <% end %>
-
- <% else %> -
-

- Configure credentials for third-party sync providers. Settings configured here will override environment variables. -

-
- <% end %> - - <% unless @encryption_error %> - <% @provider_configurations.each do |config| %> - <%= settings_section title: config.provider_key.titleize, collapsible: true, open: false do %> - <%= render "settings/providers/provider_form", configuration: config %> - <% end %> - <% end %> - - <%# Providers below are hardcoded because they manage Family-scoped connections %> - <%# (via their own models like SimplefinItem, LunchflowItem, etc.) rather than global settings. %> - <%# They require custom UI for connection management, status display, and sync actions. %> - <%# The controller excludes them from @provider_configurations (see prepare_show_context). %> - - <%= settings_section title: "Lunch Flow", collapsible: true, open: false do %> - - <%= render "settings/providers/lunchflow_panel" %> - - <% end %> - - <%= settings_section title: "SimpleFIN", collapsible: true, open: false do %> - - <%= render "settings/providers/simplefin_panel" %> - - <% end %> - - <%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/enable_banking_panel" %> - - <% end %> - - <%= settings_section title: "CoinStats (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/coinstats_panel" %> - - <% end %> - - <%= settings_section title: "Mercury (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/mercury_panel" %> - - <% end %> - - <%= settings_section title: "Coinbase (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/coinbase_panel" %> - - <% end %> - - <%= settings_section title: "Binance (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/binance_panel" %> - - <% end %> - - <%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %> - - <%= render "settings/providers/snaptrade_panel" %> - - <% end %> - - <%= settings_section title: "Indexa Capital (alpha)", collapsible: true, open: false do %> - - <%= render "settings/providers/indexa_capital_panel" %> - - <% end %> - - <%= settings_section title: "Sophtron (alpha)", collapsible: true, open: false do %> - - <%= render "settings/providers/sophtron_panel" %> - + <% else %> + <%= render "settings/providers/group_heading", + title: t("settings.providers.groups.available"), + count: 0, + anchor: "available" %> +

<%= t("settings.providers.groups.empty_available") %>

<% end %> <% end %>
diff --git a/config/locales/views/coinstats_items/ca.yml b/config/locales/views/coinstats_items/ca.yml index 470fd3add..66140452a 100644 --- a/config/locales/views/coinstats_items/ca.yml +++ b/config/locales/views/coinstats_items/ca.yml @@ -50,8 +50,6 @@ ca: not_configured_step3_html: Segueix les instruccions de configuració proporcionades per completar la configuració del proveïdor not_configured_title: La connexió amb el proveïdor CoinStats no està configurada setup_instructions: "Instruccions de configuració:" - status_configured_html: Llest per utilitzar - status_not_configured: No configurat step1_html: Ves al Panell de l'API Pública de CoinStats per obtenir una clau API. step2: Introdueix la teva clau API a continuació i fes clic a Configura. step3_html: Després d'una connexió reeixida, ves a la pestanya Comptes per configurar les carteres de criptomonedes. diff --git a/config/locales/views/coinstats_items/de.yml b/config/locales/views/coinstats_items/de.yml index 56ce87bd8..d54f1e4fe 100644 --- a/config/locales/views/coinstats_items/de.yml +++ b/config/locales/views/coinstats_items/de.yml @@ -41,8 +41,6 @@ de: configure: Konfigurieren update_configuration: Neu konfigurieren default_name: CoinStats-Verbindung - status_configured_html: Bereit zur Nutzung - status_not_configured: Nicht konfiguriert coinstats_item: deletion_in_progress: Krypto-Wallet-Daten werden gelöscht… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/en.yml b/config/locales/views/coinstats_items/en.yml index ecd41109b..982aa8db3 100644 --- a/config/locales/views/coinstats_items/en.yml +++ b/config/locales/views/coinstats_items/en.yml @@ -55,8 +55,6 @@ en: configure: Configure update_configuration: Reconfigure default_name: CoinStats Connection - status_configured_html: Ready to use - status_not_configured: Not configured coinstats_item: deletion_in_progress: Crypto wallet data is being deleted… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/es.yml b/config/locales/views/coinstats_items/es.yml index 36744d53e..ca54f463b 100644 --- a/config/locales/views/coinstats_items/es.yml +++ b/config/locales/views/coinstats_items/es.yml @@ -41,8 +41,6 @@ es: configure: Configurar update_configuration: Reconfigurar default_name: Conexión de CoinStats - status_configured_html: Listo para usar - status_not_configured: No configurado coinstats_item: deletion_in_progress: Los datos de la cartera de criptomonedas se están eliminando… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/fr.yml b/config/locales/views/coinstats_items/fr.yml index b2b365510..84a89d621 100644 --- a/config/locales/views/coinstats_items/fr.yml +++ b/config/locales/views/coinstats_items/fr.yml @@ -55,8 +55,6 @@ fr: configure: Configurer update_configuration: Reconfigurer default_name: Connexion CoinStats - status_configured_html: Prêt à utiliser - status_not_configured: Non configuré coinstats_item: deletion_in_progress: Les données du portefeuille crypto sont en cours de suppression… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/hu.yml b/config/locales/views/coinstats_items/hu.yml index 8de8131a9..3a3dbad56 100644 --- a/config/locales/views/coinstats_items/hu.yml +++ b/config/locales/views/coinstats_items/hu.yml @@ -55,8 +55,6 @@ hu: configure: Konfigurálás update_configuration: Újrakonfigurálás default_name: CoinStats kapcsolat - status_configured_html: Használatra kész - status_not_configured: Nincs beállítva coinstats_item: deletion_in_progress: A kriptó pénztárca adatai törlés alatt… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/nl.yml b/config/locales/views/coinstats_items/nl.yml index 7c0918599..da6b19b55 100644 --- a/config/locales/views/coinstats_items/nl.yml +++ b/config/locales/views/coinstats_items/nl.yml @@ -43,8 +43,6 @@ nl: configure: Configureren update_configuration: Opnieuw configureren default_name: CoinStats Verbinding - status_configured_html: Klaar voor gebruik - status_not_configured: Niet geconfigureerd coinstats_item: deletion_in_progress: Crypto wallet gegevens worden verwijderd… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/pl.yml b/config/locales/views/coinstats_items/pl.yml index 968c9aa0b..15d0c66b4 100644 --- a/config/locales/views/coinstats_items/pl.yml +++ b/config/locales/views/coinstats_items/pl.yml @@ -45,8 +45,6 @@ pl: configure: Skonfiguruj update_configuration: Skonfiguruj ponownie default_name: Połączenie CoinStats - status_configured_html: Gotowe do użycia - status_not_configured: Nieskonfigurowane coinstats_item: deletion_in_progress: Trwa usuwanie danych portfela kryptowalutowego… provider_name: CoinStats diff --git a/config/locales/views/indexa_capital_items/de.yml b/config/locales/views/indexa_capital_items/de.yml index 09d0e2a22..e9c50dbfa 100644 --- a/config/locales/views/indexa_capital_items/de.yml +++ b/config/locales/views/indexa_capital_items/de.yml @@ -39,8 +39,6 @@ de: alternative_auth: "Oder nutzen Sie Benutzername/Passwort-Anmeldung..." save_button: "Konfiguration speichern" update_button: "Konfiguration aktualisieren" - status_configured_html: "Konfiguriert und einsatzbereit. Besuchen Sie die Registerkarte Konten, um Konten zu verwalten und einzurichten." - status_not_configured: "Nicht konfiguriert" fields: api_token: label: "API-Token" diff --git a/config/locales/views/indexa_capital_items/en.yml b/config/locales/views/indexa_capital_items/en.yml index a3a448b7c..774bca112 100644 --- a/config/locales/views/indexa_capital_items/en.yml +++ b/config/locales/views/indexa_capital_items/en.yml @@ -40,8 +40,6 @@ en: alternative_auth: "Or use username/password authentication instead..." save_button: "Save Configuration" update_button: "Update Configuration" - status_configured_html: "Configured and ready to use. Visit the Accounts tab to manage and set up accounts." - status_not_configured: "Not configured" fields: api_token: label: "API Token" diff --git a/config/locales/views/indexa_capital_items/es.yml b/config/locales/views/indexa_capital_items/es.yml index f63db32d8..82f9247a1 100644 --- a/config/locales/views/indexa_capital_items/es.yml +++ b/config/locales/views/indexa_capital_items/es.yml @@ -37,8 +37,6 @@ es: alternative_auth: "O usa la autenticación por usuario/contraseña en su lugar..." save_button: "Guardar configuración" update_button: "Actualizar configuración" - status_configured_html: "Configurado y listo para usar. Visita la pestaña de Cuentas para gestionar y configurar tus cuentas." - status_not_configured: "No configurado" fields: api_token: label: "Token de API" diff --git a/config/locales/views/indexa_capital_items/fr.yml b/config/locales/views/indexa_capital_items/fr.yml index 1e46d9bac..66dce0d18 100644 --- a/config/locales/views/indexa_capital_items/fr.yml +++ b/config/locales/views/indexa_capital_items/fr.yml @@ -40,8 +40,6 @@ fr: alternative_auth: "Ou utilisez plutôt l'authentification par nom d'utilisateur / mot de passe…" save_button: "Enregistrer la configuration" update_button: "Mettre à jour la configuration" - status_configured_html: "Configuré et prêt à l'emploi. Rendez-vous sur l'onglet Comptes pour gérer et configurer les comptes." - status_not_configured: "Non configuré" fields: api_token: label: "Jeton API" diff --git a/config/locales/views/indexa_capital_items/hu.yml b/config/locales/views/indexa_capital_items/hu.yml index 02ef29c59..be7bf5ff5 100644 --- a/config/locales/views/indexa_capital_items/hu.yml +++ b/config/locales/views/indexa_capital_items/hu.yml @@ -37,8 +37,6 @@ hu: alternative_auth: "Vagy használj felhasználónév/jelszó hitelesítést helyette..." save_button: "Konfiguráció mentése" update_button: "Konfiguráció frissítése" - status_configured_html: "Beállítva és használatra kész. A számlák kezeléséhez és beállításához látogass el a Számlák lapra." - status_not_configured: "Nincs beállítva" fields: api_token: label: "API token" diff --git a/config/locales/views/indexa_capital_items/pl.yml b/config/locales/views/indexa_capital_items/pl.yml index abab63caa..4ae96d462 100644 --- a/config/locales/views/indexa_capital_items/pl.yml +++ b/config/locales/views/indexa_capital_items/pl.yml @@ -39,8 +39,6 @@ pl: alternative_auth: Lub użyj uwierzytelniania loginem i hasłem... save_button: Zapisz konfigurację update_button: Zaktualizuj konfigurację - status_configured_html: Skonfigurowano i gotowe do użycia. Przejdź do zakładki Konta, aby zarządzać kontami i je konfigurować. - status_not_configured: Nie skonfigurowano fields: api_token: label: Token API diff --git a/config/locales/views/mercury_items/en.yml b/config/locales/views/mercury_items/en.yml index f3916fae5..2625bc40f 100644 --- a/config/locales/views/mercury_items/en.yml +++ b/config/locales/views/mercury_items/en.yml @@ -44,11 +44,9 @@ en: total: Total unlinked: Unlinked provider_panel: - accounts_link: Accounts add_connection: Add Mercury connection base_url_label: Base URL (optional) base_url_placeholder: https://api.mercury.com/api/v1 (default) - configured_html: "Configured and ready to use. Visit the %{accounts_link} tab to manage and set up accounts." connection_name_label: Connection name connection_name_placeholder: Business checking default_connection_name: Mercury Connection @@ -60,7 +58,6 @@ en: sign_in_html: "Visit %{link} and log in to the account you want to connect" whitelist_ip_html: "Important: Add your server's IP address to the token's whitelist" keep_token_placeholder: Leave blank to keep the current token - not_configured: Not configured sandbox_note_html: "Use a separate named connection for each Mercury login/API token you want to sync. For sandbox testing, use https://api-sandbox.mercury.com/api/v1 as the Base URL. Mercury requires IP whitelisting - make sure to add your IP in the Mercury dashboard." setup_accounts: Set up accounts setup_title: "Setup instructions:" diff --git a/config/locales/views/mercury_items/hu.yml b/config/locales/views/mercury_items/hu.yml index 75091c34c..8bf7271a7 100644 --- a/config/locales/views/mercury_items/hu.yml +++ b/config/locales/views/mercury_items/hu.yml @@ -44,11 +44,9 @@ hu: total: Összesen unlinked: Nincs összekapcsolva provider_panel: - accounts_link: Számlák add_connection: Mercury kapcsolat hozzáadása base_url_label: Alap URL (opcionális) base_url_placeholder: https://api.mercury.com/api/v1 (alapértelmezett) - configured_html: "Beállítva és használatra kész. A számlák kezeléséhez és beállításához látogass el a %{accounts_link} lapra." connection_name_label: Kapcsolat neve connection_name_placeholder: Üzleti folyószámla default_connection_name: Mercury kapcsolat @@ -60,7 +58,6 @@ hu: sign_in_html: "Látogass el a(z) %{link} oldalra, és lépj be az összekapcsolni kívánt fiókba" whitelist_ip_html: "Fontos: Add a szervered IP-címét a token engedélyezési listájához" keep_token_placeholder: Hagyd üresen az aktuális token megtartásához - not_configured: Nincs beállítva sandbox_note_html: "Minden Mercury bejelentkezéshez/API tokenhez használj külön elnevezett kapcsolatot. Sandbox teszteléshez használd a https://api-sandbox.mercury.com/api/v1 alap URL-t. A Mercury IP engedélyezési listát igényel — győződj meg róla, hogy hozzáadtad az IP-d a Mercury irányítópulton." setup_accounts: Számlák beállítása setup_title: "Beállítási utasítások:" diff --git a/config/locales/views/settings/de.yml b/config/locales/views/settings/de.yml index e8a64d97e..eb17807f2 100644 --- a/config/locales/views/settings/de.yml +++ b/config/locales/views/settings/de.yml @@ -167,8 +167,6 @@ de: syncing: Wird synchronisiert… sync: Synchronisieren disconnect_confirm: Bist du sicher, dass du diese Coinbase-Verbindung trennen möchtest? Deine synchronisierten Konten werden zu manuellen Konten. - status_connected: Coinbase ist verbunden und synchronisiert deine Krypto-Bestände. - status_not_connected: Nicht verbunden. Gib deine API-Zugangsdaten oben ein, um zu starten. enable_banking_panel: callback_url_instruction: "Für die Callback-URL, verwende %{callback_url}." connection_error: Verbindungsfehler diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 79e7c69d3..f3753374a 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -176,7 +176,7 @@ en: whats_new_label: What's new api_keys_label: API Key appearance_label: Appearance - bank_sync_label: Bank Sync + bank_sync_label: Bank sync settings_nav_link_large: next: Next previous: Back @@ -186,11 +186,73 @@ en: choose_label: (optional) change: Change photo providers: - show: - coinbase_title: Coinbase + not_authorized: Not authorized + bank_sync: + page_title: Bank sync + lede: Connect external accounts so transactions, balances and holdings flow into Sure automatically. + status: + ok: Connected + warn: Action needed + err: Error + off: Not configured + maturity: + beta: Beta + alpha: Alpha + drawer_trust_statement: "Read-only access. Sure can never move money, and your credentials are stored encrypted." + setup_steps: + eyebrow: Setup + need_help: "Need help?" + connect: Connect + groups: + your_connections: Your connections + available: Available + empty_available: All available providers are connected. + health_strip: + connected: connected + needs_attention: needs attention + accounts_syncing: accounts syncing + last_synced: Last synced %{time} ago + meta: + sync_error: Sync error + no_recent_sync: Sync overdue + registration_needed: Registration needed + reconsent_required: Re-consent required + reconsent_needed: + one: Re-consent needed in 1 day + other: Re-consent needed in %{count} days + last_synced: Synced %{time} ago + sync_all: Sync all + sync_all_in_progress: Syncing all connected providers… + sync_all_recently: Sync already in progress. Try again in a moment. + sync_provider: Sync now + sync_provider_in_progress: Sync started. + recently_synced: Synced recently. Try again in a moment. + taglines: + simplefin: Connect US bank accounts via the open SimpleFIN protocol. + lunchflow: Connect 20k+ banks from 40+ countries (UK, EU, USA and more!) + enable_banking: Sync European bank accounts via PSD2 open banking. + coinstats: Track your entire crypto portfolio across wallets and exchanges. + mercury: Sync your Mercury business banking accounts automatically. + coinbase: Import your Coinbase crypto holdings and track performance. + binance: Sync your Binance spot balances using a read-only API key. + snaptrade: Connect brokerage accounts via the SnapTrade aggregation network. + indexa_capital: Track your Indexa Capital automated investment portfolio. + sophtron: Connect US & Canadian banks and utilities. + plaid: Connect thousands of US financial institutions via Plaid. + plaid_eu: Connect European financial institutions via Plaid (PSD2 / Open Banking). + search_filters: + aria_label: Search providers + placeholder: Search providers + chips: + all: All + bank: Banks + crypto: Crypto + investment: Investments + empty_filter: No providers match your filter. + clear_filter: Clear filters encryption_error: - title: Encryption Configuration Required - message: Active Record encryption keys are not configured. Please ensure the encryption credentials (active_record_encryption.primary_key, active_record_encryption.deterministic_key, and active_record_encryption.key_derivation_salt) are properly set up in your Rails credentials or environment variables before using sync providers. + title: Encryption keys missing + message: "Bank sync needs Active Record encryption configured. Set primary_key, deterministic_key and key_derivation_salt in your Rails credentials or environment variables." coinbase_panel: setup_instructions: "To connect Coinbase:" step1_html: Go to Coinbase API Settings @@ -204,15 +266,14 @@ en: syncing: Syncing... sync: Sync disconnect_confirm: Are you sure you want to disconnect this Coinbase connection? Your synced accounts will become manual accounts. - status_connected: Coinbase is connected and syncing your crypto holdings. - status_not_connected: Not connected. Enter your API credentials above to get started. binance_panel: setup_instructions: "To connect Binance, create a read-only API key:" step1_html: 'Go to Binance API Management' step2: "Create a new API key with Enable Reading permission only" step3: "Paste your API Key and Secret below" - no_withdraw_warning: "Warning: do NOT enable withdrawal permissions" - ip_hint_title: "IP Whitelisting Required" + no_withdraw_title: "Read-only key only" + no_withdraw_body: "Don't enable withdrawal permissions when creating your Binance API key. Sure only needs read access." + ip_hint_title: "IP whitelisting required" ip_hint_body: "Add the app server's egress IP to the Binance API Key whitelist:" ip_hint_contact_admin: "Contact your administrator to obtain the app server's egress IP address." api_key_label: API Key @@ -223,8 +284,25 @@ en: syncing: Syncing... sync: Sync disconnect_confirm: "Are you sure you want to disconnect Binance?" - status_connected: Binance connected - status_not_connected: Binance not connected enable_banking_panel: callback_url_instruction: "For the callback URL, use %{callback_url}." connection_error: Connection Error + step_1_html: "Go to %{link} and grab your developer credentials." + step_2: "Pick your country and paste the Application ID + Client Certificate below." + step_3: "Save, then use Add Connection to link your bank." + lunchflow_panel: + step_1_html: "Go to %{link} and create an API key." + step_2: "Paste your key below and connect." + step_3: "Then head to Accounts to link your synced accounts." + simplefin_panel: + step_1_html: "Go to %{link} for a one-time setup token." + step_2: "Paste the token below and connect." + step_3: "Then head to Accounts to link your synced accounts." + plaid_panel: + step_1_html: "Open the %{link} and copy your Client ID and Secret Key." + step_2: "Pick an environment. Use sandbox for testing and production for real accounts." + step_3: "Paste your credentials below and connect." + plaid_eu_panel: + step_1_html: "Open the %{link} and copy your EU Client ID and Secret Key." + not_found: Provider not found. + sync_provider_no_items: No connections available to sync. diff --git a/config/locales/views/settings/es.yml b/config/locales/views/settings/es.yml index b9346a547..e8cf7fb0c 100644 --- a/config/locales/views/settings/es.yml +++ b/config/locales/views/settings/es.yml @@ -168,8 +168,6 @@ es: syncing: Sincronizando... sync: Sincronizar disconnect_confirm: ¿Estás seguro de que deseas desconectar esta conexión de Coinbase? Tus cuentas sincronizadas pasarán a ser cuentas manuales. - status_connected: Coinbase está conectado y sincronizando tus activos de criptomonedas. - status_not_connected: No conectado. Introduce tus credenciales de API arriba para comenzar. enable_banking_panel: callback_url_instruction: "Para la URL de retorno (callback), utiliza %{callback_url}." connection_error: Error de conexión \ No newline at end of file diff --git a/config/locales/views/settings/fr.yml b/config/locales/views/settings/fr.yml index e3179c63f..a3cb0aec7 100644 --- a/config/locales/views/settings/fr.yml +++ b/config/locales/views/settings/fr.yml @@ -202,8 +202,6 @@ fr: syncing: Synchronisation… sync: Synchroniser disconnect_confirm: Êtes-vous sûr(e) de vouloir déconnecter cette connexion Coinbase ? Vos comptes synchronisés deviendront des comptes manuels. - status_connected: Coinbase est connecté et synchronise vos avoirs en crypto. - status_not_connected: Non connecté. Saisissez vos identifiants API ci-dessus pour commencer. binance_panel: setup_instructions: "Pour connecter Binance, créez une clé API en lecture seule :" step1_html: 'Allez dans la Gestion des API Binance' @@ -221,8 +219,6 @@ fr: syncing: Synchronisation… sync: Synchroniser disconnect_confirm: "Êtes-vous sûr(e) de vouloir déconnecter Binance ?" - status_connected: Binance connecté - status_not_connected: Binance non connecté enable_banking_panel: callback_url_instruction: "Pour l'URL de rappel, utilisez %{callback_url}." connection_error: Erreur de connexion diff --git a/config/locales/views/settings/hu.yml b/config/locales/views/settings/hu.yml index ad687fdff..4d4d3dc91 100644 --- a/config/locales/views/settings/hu.yml +++ b/config/locales/views/settings/hu.yml @@ -202,8 +202,6 @@ hu: syncing: Szinkronizálás... sync: Szinkronizálás disconnect_confirm: Biztosan le szeretnéd választani ezt a Coinbase-kapcsolatot? A szinkronizált számlák manuális számlákká válnak. - status_connected: A Coinbase csatlakoztatva van, és szinkronizálja a kriptovaluta-állományodat. - status_not_connected: Nincs csatlakoztatva. Az induláshoz add meg az API-hitelesítő adataidat fent. binance_panel: setup_instructions: "A Binance csatlakoztatásához hozz létre egy csak olvasási jogosultsággal rendelkező API-kulcsot:" step1_html: 'Nyisd meg a Binance API-kezelőjét' @@ -221,8 +219,6 @@ hu: syncing: Szinkronizálás... sync: Szinkronizálás disconnect_confirm: "Biztosan le szeretnéd választani a Binance-t?" - status_connected: A Binance csatlakoztatva van - status_not_connected: A Binance nincs csatlakoztatva enable_banking_panel: callback_url_instruction: "A visszahívási URL-hez használd a következőt: %{callback_url}." connection_error: Kapcsolódási hiba diff --git a/config/locales/views/settings/pl.yml b/config/locales/views/settings/pl.yml index 9962be6a7..8acfe1772 100644 --- a/config/locales/views/settings/pl.yml +++ b/config/locales/views/settings/pl.yml @@ -185,8 +185,6 @@ pl: syncing: Synchronizacja... sync: Synchronizuj disconnect_confirm: Czy na pewno chcesz odłączyć to połączenie Coinbase? Twoje zsynchronizowane konta staną się kontami ręcznymi. - status_connected: Coinbase jest połączony i synchronizuje Twoje zasoby kryptowalutowe. - status_not_connected: Brak połączenia. Wprowadź powyżej dane API, aby rozpocząć. enable_banking_panel: callback_url_instruction: Dla URL callback użyj %{callback_url}. connection_error: Błąd połączenia diff --git a/config/locales/views/settings/pt-BR.yml b/config/locales/views/settings/pt-BR.yml index 26fa9fa13..65962871b 100644 --- a/config/locales/views/settings/pt-BR.yml +++ b/config/locales/views/settings/pt-BR.yml @@ -186,8 +186,6 @@ pt-BR: syncing: Sincronizando... sync: Sincronizar disconnect_confirm: Tem certeza de que deseja desconectar esta conexão com a Coinbase? Suas contas sincronizadas se tornarão contas manuais. - status_connected: A Coinbase está conectada e sincronizando seus ativos em criptomoedas. - status_not_connected: Não conectado. Insira suas credenciais de API acima para começar. enable_banking_panel: callback_url_instruction: "Para a URL de retorno de chamada, use %{callback_url}." connection_error: Erro de conexão diff --git a/config/locales/views/snaptrade_items/de.yml b/config/locales/views/snaptrade_items/de.yml index c0f0cd22c..6ab8c28cf 100644 --- a/config/locales/views/snaptrade_items/de.yml +++ b/config/locales/views/snaptrade_items/de.yml @@ -134,8 +134,6 @@ de: one: "%{count} muss eingerichtet werden" other: "%{count} müssen eingerichtet werden" status_ready: "Bereit zum Verbinden von Brokern" - status_needs_registration: "Zugangsdaten gespeichert. Gehen Sie zur Konten-Seite, um Broker zu verbinden." - status_not_configured: "Nicht konfiguriert" setup_accounts_button: "Konten einrichten" connect_button: "Broker verbinden" connected_brokerages: "Verbunden:" diff --git a/config/locales/views/snaptrade_items/en.yml b/config/locales/views/snaptrade_items/en.yml index e9cdfd250..053978a4b 100644 --- a/config/locales/views/snaptrade_items/en.yml +++ b/config/locales/views/snaptrade_items/en.yml @@ -117,7 +117,7 @@ en: step_2: "Copy your Client ID and Consumer Key from the dashboard" step_3: "Enter your credentials below and click Save" step_4: "Go to the Accounts page and use 'Connect another brokerage' to link your investment accounts" - free_tier_warning: "Free tier includes 5 brokerage connections. Additional connections require a paid SnapTrade plan." + free_tier_warning: "SnapTrade's free tier covers 5 brokerage connections. Upgrade on SnapTrade for more." client_id_label: "Client ID" client_id_placeholder: "Enter your SnapTrade Client ID" client_id_update_placeholder: "Enter new Client ID to update" @@ -129,17 +129,15 @@ en: status_connected: one: "%{count} account from SnapTrade" other: "%{count} accounts from SnapTrade" + status_needs_registration: "Credentials saved. Finish setup to connect a brokerage." needs_setup: one: "%{count} needs setup" other: "%{count} need setup" status_ready: "Ready to connect brokerages" - status_needs_registration: "Credentials saved. Go to Accounts page to connect brokerages." - status_not_configured: "Not configured" setup_accounts_button: "Setup Accounts" connect_button: "Connect Brokerage" connected_brokerages: "Connected:" manage_connections: "Manage Connections" - connection_limit_info: "SnapTrade free tier allows 5 brokerage connections. Delete unused connections to free up slots." loading_connections: "Loading connections..." connections_error: "Failed to load connections: %{message}" accounts_count: diff --git a/config/locales/views/snaptrade_items/es.yml b/config/locales/views/snaptrade_items/es.yml index 47a6ca609..db087fff9 100644 --- a/config/locales/views/snaptrade_items/es.yml +++ b/config/locales/views/snaptrade_items/es.yml @@ -134,8 +134,6 @@ es: one: "%{count} necesita configuración" other: "%{count} necesitan configuración" status_ready: "Listo para conectar brókers" - status_needs_registration: "Credenciales guardadas. Ve a la página de Cuentas para conectar brókers." - status_not_configured: "No configurado" setup_accounts_button: "Configurar cuentas" connect_button: "Conectar bróker" connected_brokerages: "Conectados:" diff --git a/config/locales/views/snaptrade_items/fr.yml b/config/locales/views/snaptrade_items/fr.yml index 91a28e5ba..65183408e 100644 --- a/config/locales/views/snaptrade_items/fr.yml +++ b/config/locales/views/snaptrade_items/fr.yml @@ -134,8 +134,6 @@ fr: one: "%{count} à configurer" other: "%{count} à configurer" status_ready: "Prêt à connecter des courtiers" - status_needs_registration: "Identifiants enregistrés. Rendez-vous sur la page Comptes pour connecter des courtiers." - status_not_configured: "Non configuré" setup_accounts_button: "Configurer les comptes" connect_button: "Connecter un courtier" connected_brokerages: "Connectés :" diff --git a/config/locales/views/snaptrade_items/hu.yml b/config/locales/views/snaptrade_items/hu.yml index fd85c5f75..424ba7ca3 100644 --- a/config/locales/views/snaptrade_items/hu.yml +++ b/config/locales/views/snaptrade_items/hu.yml @@ -134,8 +134,6 @@ hu: one: "%{count} beállítást igényel" other: "%{count} beállítást igényel" status_ready: "Készen áll brókercégek csatlakoztatásához" - status_needs_registration: "Hitelesítő adatok mentve. Menj a Számlák oldalra brókercégek csatlakoztatásához." - status_not_configured: "Nincs beállítva" setup_accounts_button: "Számlák beállítása" connect_button: "Brókercég csatlakoztatása" connected_brokerages: "Csatlakoztatva:" diff --git a/config/locales/views/snaptrade_items/pl.yml b/config/locales/views/snaptrade_items/pl.yml index ba3eb7058..1f45a3aa1 100644 --- a/config/locales/views/snaptrade_items/pl.yml +++ b/config/locales/views/snaptrade_items/pl.yml @@ -145,8 +145,6 @@ pl: many: "%{count} wymaga konfiguracji" other: "%{count} wymaga konfiguracji" status_ready: Gotowe do połączenia z biurami maklerskimi - status_needs_registration: Dane uwierzytelniające zapisane. Przejdź do strony Konta, aby połączyć biura maklerskie. - status_not_configured: Nieskonfigurowane setup_accounts_button: Konfiguruj konta connect_button: Połącz biuro maklerskie connected_brokerages: 'Połączone:' diff --git a/config/locales/views/sophtron_items/en.yml b/config/locales/views/sophtron_items/en.yml index a74c76e77..5e52084bf 100644 --- a/config/locales/views/sophtron_items/en.yml +++ b/config/locales/views/sophtron_items/en.yml @@ -279,9 +279,6 @@ en: placeholder: "https://api.sophtron.com/api" save: "Save Configuration" update: "Update Configuration" - status: - configured_html: 'Configured and ready to use. Visit the Accounts tab to manage and set up accounts.' - not_configured: "Not configured" syncer: manual_sync_required: "Manual Sophtron sync is required for this institution; skipping those accounts during automated sync." importing_accounts: "Importing accounts from Sophtron..." diff --git a/config/locales/views/sophtron_items/hu.yml b/config/locales/views/sophtron_items/hu.yml index 7589414df..a4cb2d375 100644 --- a/config/locales/views/sophtron_items/hu.yml +++ b/config/locales/views/sophtron_items/hu.yml @@ -233,9 +233,6 @@ hu: placeholder: "https://api.sophtron.com/v2" save: "Konfiguráció mentése" update: "Konfiguráció frissítése" - status: - configured_html: "Beállítva és használatra kész. A számlák kezeléséhez és beállításához látogass el a Számlák lapra." - not_configured: "Nincs beállítva" syncer: manual_sync_required: "A kézi Sophtron szinkronizálás engedélyezve van; automatikus szinkronizálás kihagyva." importing_accounts: "Számlák importálása a Sophtron-ból..." diff --git a/config/routes.rb b/config/routes.rb index b143c64d5..3a59f340b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -205,8 +205,14 @@ Rails.application.routes.draw do resource :ai_prompts, only: :show resource :llm_usage, only: :show resource :guides, only: :show - resource :bank_sync, only: :show, controller: "bank_sync" - resource :providers, only: %i[show update] + get "bank_sync", to: redirect("/settings/providers", status: 301) + resource :providers, only: %i[show update] do + collection do + post :sync_all + post ":provider_key/sync", action: :sync, as: :sync_provider + get ":provider_key/connect_form", action: :connect_form, as: :connect_form + end + end end resource :subscription, only: %i[new show create] do diff --git a/db/migrate/20260510120000_add_last_sync_all_attempted_at_to_families.rb b/db/migrate/20260510120000_add_last_sync_all_attempted_at_to_families.rb new file mode 100644 index 000000000..0ab431f0a --- /dev/null +++ b/db/migrate/20260510120000_add_last_sync_all_attempted_at_to_families.rb @@ -0,0 +1,5 @@ +class AddLastSyncAllAttemptedAtToFamilies < ActiveRecord::Migration[7.2] + def change + add_column :families, :last_sync_all_attempted_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 641c5e640..b40605ab9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_05_08_130000) do +ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -595,6 +595,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_08_130000) do t.string "assistant_type", default: "builtin", null: false t.string "default_account_sharing", default: "shared", null: false t.string "enabled_currencies", array: true + t.datetime "last_sync_all_attempted_at" t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing" t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end diff --git a/test/controllers/settings/providers_controller_test.rb b/test/controllers/settings/providers_controller_test.rb index a7358e06e..00b98e893 100644 --- a/test/controllers/settings/providers_controller_test.rb +++ b/test/controllers/settings/providers_controller_test.rb @@ -1,6 +1,8 @@ require "test_helper" class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + setup do sign_in users(:family_admin) @@ -8,6 +10,12 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest Provider::Factory.ensure_adapters_loaded end + test "GET /settings/bank_sync redirects permanently to /settings/providers" do + get "/settings/bank_sync" + assert_redirected_to "/settings/providers" + assert_equal 301, response.status + end + test "can access when self hosting is disabled (managed mode)" do Rails.configuration.stubs(:app_mode).returns("managed".inquiry) get settings_providers_url @@ -298,6 +306,55 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest end end + test "POST sync_all enqueues SyncAllProvidersJob" do + SimplefinItem.create!( + family: families(:dylan_family), + name: "Test SimpleFIN Sync All", + access_url: "https://bridge.simplefin.org/simplefin/access" + ) + families(:dylan_family).update_column(:last_sync_all_attempted_at, nil) + + assert_enqueued_with(job: SyncAllProvidersJob) do + post sync_all_settings_providers_path + end + + assert_redirected_to settings_providers_path + + follow_redirect! + assert_response :success + assert_match(/Syncing all connected providers/i, response.body) + end + + test "POST sync_all respects recent sync throttle" do + families(:dylan_family).update_column(:last_sync_all_attempted_at, Time.current) + + assert_no_enqueued_jobs only: SyncAllProvidersJob do + post sync_all_settings_providers_path + end + + assert_redirected_to settings_providers_path + assert_equal I18n.t("settings.providers.sync_all_recently"), flash[:notice] + end + + test "POST sync for simplefin without an active Simplefin sync enqueues SyncJob" do + item = SimplefinItem.create!( + family: families(:dylan_family), + name: "Test SimpleFIN Per Row Sync", + access_url: "https://bridge.simplefin.org/simplefin/access" + ) + Sync.where(syncable_type: "SimplefinItem", syncable_id: item.id).delete_all + + assert_enqueued_jobs 1, only: SyncJob do + post sync_provider_settings_providers_path(provider_key: "simplefin") + end + + assert_redirected_to settings_providers_path + + follow_redirect! + assert_response :success + assert_match(/Sync started/i, response.body) + end + test "non-admin users cannot update providers" do with_self_hosting do sign_in users(:family_member) @@ -306,7 +363,7 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest setting: { plaid_client_id: "test" } } - assert_redirected_to settings_providers_path + assert_redirected_to root_path assert_equal "Not authorized", flash[:alert] # Value should not have changed diff --git a/test/models/family/syncer_test.rb b/test/models/family/syncer_test.rb index baada992e..48ed9ffb2 100644 --- a/test/models/family/syncer_test.rb +++ b/test/models/family/syncer_test.rb @@ -5,11 +5,12 @@ class Family::SyncerTest < ActiveSupport::TestCase @family = families(:dylan_family) end - test "syncs plaid items and manual accounts" do + test "syncs provider items and manual accounts" do family_sync = syncs(:family) manual_accounts_count = @family.accounts.manual.count - items_count = @family.plaid_items.count + plaid_items_count = @family.plaid_items.syncable.count + binance_items_count = @family.binance_items.syncable.count syncer = Family::Syncer.new(@family) @@ -19,9 +20,14 @@ class Family::SyncerTest < ActiveSupport::TestCase .times(manual_accounts_count) PlaidItem.any_instance - .expects(:sync_later) - .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) - .times(items_count) + .expects(:sync_later) + .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) + .times(plaid_items_count) + + BinanceItem.any_instance + .expects(:sync_later) + .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) + .times(binance_items_count) syncer.perform_sync(family_sync) @@ -61,6 +67,7 @@ class Family::SyncerTest < ActiveSupport::TestCase LunchflowItem.any_instance.stubs(:sync_later) EnableBankingItem.any_instance.stubs(:sync_later) SophtronItem.any_instance.stubs(:sync_later) + BinanceItem.any_instance.stubs(:sync_later) syncer.perform_sync(family_sync) syncer.perform_post_sync diff --git a/test/system/settings/providers_test.rb b/test/system/settings/providers_test.rb new file mode 100644 index 000000000..549925ae0 --- /dev/null +++ b/test/system/settings/providers_test.rb @@ -0,0 +1,213 @@ +require "application_system_test_case" + +class Settings::ProvidersTest < ApplicationSystemTestCase + setup do + @user = users(:family_admin) + @family = families(:dylan_family) + login_as @user + end + + test "shows status pill on section header for a configured provider" do + SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access") + + visit settings_providers_path + + within("details", text: "SimpleFIN") do + assert_text "Connected" + end + end + + test "unconfigured SimpleFIN appears in Available with a connect affordance" do + visit settings_providers_path + + assert_no_selector "details", text: "SimpleFIN" + + within available_provider_cards_container do + assert_text "SimpleFIN" + assert_selector "a[data-turbo-frame='drawer']", text: "Connect" + end + end + + test "connected providers are grouped under Your connections in alphabetical title order" do + SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access") + + visit settings_providers_path + + titles = all("details").map { |d| d.find("summary h3", match: :first).text.squish } + assert_equal titles.sort_by(&:downcase), titles, "Connection panels should render alphabetically by title" + + connections_heading = page.find(:xpath, "//h2[contains(translate(normalize-space(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'your connections')]") + available_heading = page.find(:xpath, "//h2[contains(translate(normalize-space(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'available')]") + connections_y = connections_heading.native.location.y + available_y = available_heading.native.location.y + + assert_operator connections_y, :<, page.find("details", text: "SimpleFIN").native.location.y + assert_operator page.find("details", text: "SimpleFIN").native.location.y, :<, available_y + end + + test "expanding a section still works as expected" do + SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access") + + visit settings_providers_path + + assert_selector "details:not([open])", text: "SimpleFIN" + + find("details", text: "SimpleFIN").find("summary").click + + assert_selector "details[open]", text: "SimpleFIN" + within("details[open]", text: "SimpleFIN") do + assert_text "Setup Token" + end + end + + test "groups providers into Your connections and Available with counts" do + SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access") + + visit settings_providers_path + + connections_heading = find(:xpath, "//h2[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'your connections')]") + normalized = connections_heading.text.squish + assert_match(/Your connections .*· \d+/i, normalized) + + connections_y = connections_heading.native.location.y + available_heading = find(:xpath, "//h2[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'available')]") + available_y = available_heading.native.location.y + simplefin_y = find("details", text: "SimpleFIN").native.location.y + + assert_operator connections_y, :<, simplefin_y, "Your connections heading should appear above SimpleFIN section" + assert_operator simplefin_y, :<, available_y, "SimpleFIN should appear above Available heading" + + available_grid_top = available_provider_cards_container.native.location.y + assert_operator available_y, :<, available_grid_top, "Available heading should appear above the card grid" + end + + test "action needed group is absent when no providers have issues" do + SimplefinItem.create!(family: @family, name: "Test SimpleFIN", access_url: "https://bridge.simplefin.org/simplefin/access") + + visit settings_providers_path + + assert_selector "h2", text: /\AYour connections/i + assert_no_selector "h2", text: /\AAction needed/i + end + + test "enable banking with expiring session appears in your connections and auto-opens" do + item = EnableBankingItem.new( + family: @family, + name: "Test Bank", + country_code: "DE", + application_id: "test-app-id", + session_id: "test-session", + session_expires_at: 5.days.from_now + ) + # Skip certificate validation for test purposes + item.save!(validate: false) + + visit settings_providers_path + + assert_selector "h2", text: /\AYour connections/i + + # Auto-expanded warning sections hide compact meta behind `group-open:hidden`; + # collapse once so the re-consent copy is visible again. + enable = find("details", text: /Enable Banking/) + enable.find("summary").click if enable.matches_selector?(":open") + + assert_selector "details:not([open])", text: /Enable Banking/ + assert_text "Re-consent needed in 5 days" + end + + test "search input filters provider cards by name" do + visit settings_providers_path + + find('[data-providers-filter-target="input"]').set("Coinbase") + + assert_selector "a[data-providers-filter-target='card']", text: /Coinbase/i + assert_no_selector "a[data-providers-filter-target='card']", text: /Binance/i + end + + test "kind chip narrows the grid to providers of that kind" do + visit settings_providers_path + + click_on "Crypto" + + assert_selector "a[data-providers-filter-target='card']", text: /Coinbase/i + assert_no_selector "a[data-providers-filter-target='card']", text: /SimpleFIN/i + end + + test "search shows the empty filter message when no provider matches" do + visit settings_providers_path + + find('[data-providers-filter-target="input"]').set("zzz_no_match_zzz") + + assert_selector '[data-providers-filter-target="empty"]', text: I18n.t("settings.providers.empty_filter") + assert_no_selector "a[data-providers-filter-target='card']", visible: true + end + + test "available providers render as a card grid" do + visit settings_providers_path + + within available_provider_cards_container do + assert_text "SimpleFIN" + assert_selector "a[data-turbo-frame='drawer']", minimum: 1 + end + end + + test "clicking a provider card opens the connect drawer" do + visit settings_providers_path + + within available_provider_cards_container do + find("a[data-turbo-frame='drawer']", text: "SimpleFIN").click + end + + assert_selector "dialog[open]" + assert_text "Setup Token" + end + + test "configured plaid_eu surfaces in Your connections instead of Available" do + Setting["plaid_eu_client_id"] = "test_eu_client" + Setting["plaid_eu_secret"] = "test_eu_secret" + + visit settings_providers_path + + assert_selector "details summary h3", text: "Plaid EU" + within available_provider_cards_container do + assert_no_text "Plaid EU" + end + end + + test "clear filters button resets search input and chip state" do + visit settings_providers_path + + find('[data-providers-filter-target="input"]').set("zzz_no_match_zzz") + assert_selector '[data-providers-filter-target="empty"]', visible: true + + click_on I18n.t("settings.providers.clear_filter") + + assert_no_selector '[data-providers-filter-target="empty"]', visible: true + assert_equal "", find('[data-providers-filter-target="input"]').value + assert_selector "a[data-providers-filter-target='card']", text: /SimpleFIN/i + end + + test "warn-state connection row carries warning outline class" do + item = EnableBankingItem.new( + family: @family, + name: "Test Bank", + country_code: "DE", + application_id: "test-app-id", + session_id: "test-session", + session_expires_at: 5.days.from_now + ) + item.save!(validate: false) + + visit settings_providers_path + + details = find("details", text: /Enable Banking/) + assert_includes details[:class], "border-warning/25" + end + + private + + # Card grid rendered after the `#available` group heading (following sibling div.grid) + def available_provider_cards_container + find("#available").find(:xpath, "following-sibling::div[contains(concat(' ', normalize-space(@class), ' '), ' grid ')]") + end +end diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index 099e55f6b..4aef39d0e 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -6,8 +6,12 @@ class SettingsTest < ApplicationSystemTestCase # Base settings available to all users @settings_links = [ - [ "Accounts", accounts_path ], - [ "Bank Sync", settings_bank_sync_path ], + [ "Accounts", accounts_path ] + ] + + @settings_links << [ "Bank sync", settings_providers_path ] if @user.admin? + + @settings_links += [ [ "Preferences", settings_preferences_path ], [ "Profile Info", settings_profile_path ], [ "Security", settings_security_path ], @@ -87,6 +91,7 @@ class SettingsTest < ApplicationSystemTestCase # Assert that admin-only settings are not present in the navigation assert_no_selector "li", text: "AI Prompts" assert_no_selector "li", text: "API Key" + assert_no_selector "li", text: "Bank sync" end end