diff --git a/.gitignore b/.gitignore index e6b243328..6a4d77fe0 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,3 @@ scripts/ .claude_settings.json .security-key logs/security/ - -# Added by codex -.codex diff --git a/.sure-version b/.sure-version index af1f9c7b6..57984f54d 100644 --- a/.sure-version +++ b/.sure-version @@ -1 +1 @@ -0.7.1-alpha.5 +0.7.1-alpha.6 diff --git a/AGENTS.md b/AGENTS.md index 30637862d..c5359f025 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,17 @@ When adding or modifying API endpoints in `app/controllers/api/v1/`, you **MUST* ### Post-commit API consistency (LLM checklist) After every API endpoint commit, ensure: (1) **Minitest** behavioral coverage in `test/controllers/api/v1/{resource}_controller_test.rb` (no behavioral assertions in rswag); (2) **rswag** remains docs-only (no `expect`/`assert_*` in `spec/requests/api/v1/`); (3) **rswag auth** uses the same API key pattern everywhere (`X-Api-Key`, not OAuth/Bearer). Full checklist: [.cursor/rules/api-endpoint-consistency.mdc](.cursor/rules/api-endpoint-consistency.mdc). +## Design System Hygiene (UI PRs) + +When a PR touches `.erb`, view components, or `.css`: + +1. **Tokens, not palette.** Use functional tokens from `app/assets/tailwind/sure-design-system.css` (`bg-warning/10`, `text-destructive`, `bg-container`, `text-primary`, `border-primary`). No raw Tailwind palette (`bg-blue-50`, `text-red-500`, hex literals). +2. **Reach for `DS::*` first.** Check `app/components/DS/` (`DS::Alert`, `DS::Button`, `DS::Disclosure`, `DS::Dialog`, `DS::Menu`, etc.) before writing an alert, badge, button, disclosure, dialog, or input shape. +3. **Two copies → lift to DS.** Same hand-rolled shape ≥2× in a diff with no DS equivalent → propose a new `DS::*` primitive before the second copy lands. +4. **Conventions.** Use the `icon` helper (never `lucide_icon` directly), no raw SVG outside DS primitives, user-facing strings via `t()`, avoid arbitrary `*-[Npx]` values when a scale token fits. + +Reviewers escalate violations of (2)–(3) to close/rewrite; (1) and (4) are request-changes. + ## Securities Providers If you need to add a new securities price provider (Tiingo, EODHD, Binance-style crypto, etc.), see [adding-a-securities-provider.md](./docs/llm-guides/adding-a-securities-provider.md) for the full walkthrough — provider class, registry wiring, MIC handling, settings UI, locales, and tests. diff --git a/README.md b/README.md index 809f013cb..41c93b4c6 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ involved: [Discord](https://discord.gg/36ZGBsxYEK) • [Website](https://sure.am ## Backstory -The Maybe Finance team spent most of 2021–2022 building a full-featured personal finance and wealth management app. It even included an “Ask an Advisor” feature that connected users with a real CFP/CFA — all included with your subscription. +The [Maybe Finance](https://github.com/maybe-finance/maybe) (archived/abandoned repo) team spent most of 2021–2022 building a full-featured personal finance and wealth management app. It even included an “Ask an Advisor” feature that connected users with a real CFP/CFA — all included with your subscription. The business end of things didn't work out, and so they stopped developing the app in mid-2023. diff --git a/app/assets/tailwind/sure-design-system/_generated.css b/app/assets/tailwind/sure-design-system/_generated.css index fe92a6a29..5c7290a77 100644 --- a/app/assets/tailwind/sure-design-system/_generated.css +++ b/app/assets/tailwind/sure-design-system/_generated.css @@ -12,6 +12,7 @@ --color-success: var(--color-green-600); --color-warning: var(--color-yellow-600); --color-destructive: var(--color-red-600); + --color-info: var(--color-blue-600); --color-shadow: --alpha(var(--color-black) / 6%); --color-gray-25: #FAFAFA; --color-gray-50: #F7F7F7; @@ -199,6 +200,7 @@ --color-success: var(--color-green-500); --color-warning: var(--color-yellow-400); --color-destructive: var(--color-red-400); + --color-info: var(--color-blue-500); --color-shadow: --alpha(var(--color-white) / 8%); --budget-unused-fill: var(--color-gray-500); --budget-unallocated-fill: var(--color-gray-700); @@ -274,6 +276,14 @@ } } +@utility bg-destructive-surface { + @apply bg-red-tint-5; + + @variant theme-dark { + @apply bg-red-tint-10; + } +} + @utility bg-inverse { @apply bg-gray-800; diff --git a/app/components/DS/alert.html.erb b/app/components/DS/alert.html.erb index e56c9fc40..419a4607c 100644 --- a/app/components/DS/alert.html.erb +++ b/app/components/DS/alert.html.erb @@ -1,7 +1,15 @@
<%= title %>
+ <% end %> + + <% if content.present? %> + <%= content %> + <% elsif message.present? %> + <%= message %> + <% end %><%= meta_line %>
+ <% end %> +<%= tagline %>
+ <% end %> +<%= t(".heading") %>
+<%= error_message %>
+<%= t(".common_issues") %>
++ <%= brex_item.institution_summary %> +
+ <% end %> + <% if brex_item.syncing? %> ++ <% if brex_item.last_synced_at %> + <% if brex_item.sync_status_summary %> + <%= t(".status_with_summary", timestamp: time_ago_in_words(brex_item.last_synced_at), summary: brex_item.sync_status_summary) %> + <% else %> + <%= t(".status", timestamp: time_ago_in_words(brex_item.last_synced_at)) %> + <% end %> + <% else %> + <%= t(".status_never") %> + <% end %> +
+ <% end %> +<%= t(".setup_needed") %>
+<%= t(".setup_description", linked: linked_count, total: total_count) %>
+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_brex_item_path(brex_item), + frame: :modal + ) %> +<%= t(".no_accounts_title") %>
+<%= t(".no_accounts_description") %>
+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_brex_item_path(brex_item), + frame: :modal + ) %> +<%= t(".heading") %>
+<%= t(".description") %>
+<%= t(".setup_steps") %>
++ <%= t(".description", product_name: product_name) %> +
+ + <%= form_with url: link_accounts_brex_items_path, + method: :post, + data: { turbo_frame: "_top" }, + class: "space-y-4" do %> + <%= hidden_field_tag :brex_item_id, @brex_item.id %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :return_to, @return_to %> + + <% account_displays = @available_accounts.map { |account| brex_account_display(account) } %> + <% has_selectable = account_displays.any? { |account_display| !account_display.blank_name? } %> + ++ <%= t(".description") %> +
+ + <%= form_with url: link_existing_account_brex_items_path, + method: :post, + data: { turbo_frame: "_top" }, + class: "space-y-4" do %> + <%= hidden_field_tag :brex_item_id, @brex_item.id %> + <%= hidden_field_tag :account_id, @account.id %> + <%= hidden_field_tag :return_to, @return_to %> + + <% account_displays = @available_accounts.map { |account| brex_account_display(account) } %> + <% has_selectable = account_displays.any? { |account_display| !account_display.blank_name? } %> + +<%= t(".fetch_failed") %>
+<%= @api_error %>
+<%= t(".no_accounts_to_setup") %>
+<%= t(".all_accounts_linked") %>
++ <%= t(".choose_account_type") %> +
+Unable to connect to Lunch Flow
-<%= error_message %>
-Common Issues:
diff --git a/app/views/lunchflow_items/_setup_required.html.erb b/app/views/lunchflow_items/_setup_required.html.erb index b205ef4bd..afd03df41 100644 --- a/app/views/lunchflow_items/_setup_required.html.erb +++ b/app/views/lunchflow_items/_setup_required.html.erb @@ -3,13 +3,11 @@ <% dialog.with_header(title: "Lunch Flow Setup Required") %> <% dialog.with_body do %>API Key Not Configured
-Before you can link Lunch Flow accounts, you need to configure your Lunch Flow API key.
-Setup Steps:
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 %><%= subtitle %>
<% end %>- This is the only time your API key will be displayed. Make sure to copy it now and store it securely. - If you lose this key, you'll need to generate a new one. -
-+ This is the only time your API key will be displayed. Make sure to copy it now and store it securely. + If you lose this key, you'll need to generate a new one. +
+ <% end %>- This is the only time your API key will be displayed. Make sure to copy it now and store it securely. - If you lose this key, you'll need to generate a new one. -
-+ This is the only time your API key will be displayed. Make sure to copy it now and store it securely. + If you lose this key, you'll need to generate a new one. +
+ <% end %>- Your API key will be displayed only once after creation. Make sure to copy and store it securely. - Anyone with access to this key can access your data according to the permissions you select. -
-+ Your API key will be displayed only once after creation. Make sure to copy and store it securely. + Anyone with access to this key can access your data according to the permissions you select. +
+ <% end %>- <%= provider_link[:name] %> -
-- <%= provider_link[:description] %> -
-PROVIDERS
- · -<%= @providers.count %>
-No providers configured
-Configure providers to link your bank accounts.
-<%= t(".rate_limit_warning") %>
-<%= t(".no_health_check_note") %>
-<%= t(".rate_limit_warning") %>
+<%= t(".no_health_check_note") %>
+ <% end %>- <%= t(".rate_limit_warning") %> -
-- <%= t(".env_configured_message") %> -
-<%= t(".plan_upgrade_warning_title") %>
-<%= t(".plan_upgrade_warning_description") %>
-<%= t(".plan_upgrade_warning_description") %>
+<%= t(".troubleshooting") %>
-<%= t("settings.providers.binance_panel.setup_instructions") %>
-<%= t("settings.providers.binance_panel.no_withdraw_warning") %>
-<%= 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 %> ++ <%= 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 %>
-<%= t("settings.providers.binance_panel.status_connected") %>
- <% else %> - -<%= t("settings.providers.binance_panel.status_not_connected") %>
- <% end %> -<%= t("brex_items.provider_panel.setup_title") %>
++ <%= t("brex_items.provider_panel.sandbox_note_html") %> +
+<%= t("brex_items.provider_panel.encryption_warning.title") %>
+<%= t("brex_items.provider_panel.encryption_warning.message") %>
+<%= error_msg %>
+<%= item.name.to_s.first.to_s.upcase %>
+<%= item.name %>
+<%= item.sync_status_summary %>
+<%= t("brex_items.provider_panel.configured_html", accounts_link: link_to(t("brex_items.provider_panel.accounts_link"), accounts_path, class: "link")) %>
+ <% else %> + +<%= t("brex_items.provider_panel.not_configured") %>
+ <% end %> +<%= t("settings.providers.coinbase_panel.setup_instructions") %>
-<%= error_msg %>
-<%= t("settings.providers.coinbase_panel.status_connected") %>
- <% else %> - -<%= t("settings.providers.coinbase_panel.status_not_connected") %>
- <% end %> -<%= t("coinstats_items.new.setup_instructions") %>
-<%= error_msg %>
-<%= t("coinstats_items.new.status_configured_html", accounts_url: accounts_path).html_safe %>
- <% else %> - -<%= t("coinstats_items.new.status_not_configured") %>
- <% end %> -Setup instructions:
-Field descriptions:
-<%= error_msg %>
-Configuration locked
-Credentials cannot be changed while you have active bank connections. Remove all connections first to update credentials.
-<%= 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:) %> +<%= t("indexa_capital_items.panel.setup_instructions") %>
-<%= error_msg %>
-<%= 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 %> -<%= t("indexa_capital_items.panel.fields.api_token.description") %>
<%= t("indexa_capital_items.panel.status_configured_html", accounts_path: accounts_path).html_safe %>
- <% else %> - -<%= t("indexa_capital_items.panel.status_not_configured") %>
- <% end %> -Setup instructions:
-Field descriptions:
-<%= error_msg %>
-Configured and ready to use. Visit the Accounts tab to manage and set up accounts.
- <% else %> - -Not configured
- <% end %> -<%= t("mercury_items.provider_panel.setup_title") %>
-- <%= t("mercury_items.provider_panel.sandbox_note_html") %> -
-<%= error_msg %>
-<%= t("mercury_items.provider_panel.sandbox_note_html").html_safe %>
-<%= 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 %> -- Configuration can be set via environment variables or overridden below. -
- <% end %> + <% if setup_steps_data %> + <%= render "settings/providers/setup_steps", steps: setup_steps_data %> + <% elsif configuration.provider_description.present? %> +Field descriptions:
-+ 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 %> +<%= field.description %>
+ <% end %> +Configured and ready to use
- <% else %> - -Not configured
- <% end %> -+ <%= eyebrow.presence || t("settings.providers.setup_steps.eyebrow") %> +
+Setup instructions:
-Field descriptions:
-<%= @error_message %>
-Configured and ready to use. Visit the Accounts tab to manage and set up accounts.
- <% else %> - -Not configured
- <% end %> -<%= t("providers.snaptrade.description") %>
+ <%= render DS::Alert.new(message: t("providers.snaptrade.free_tier_warning"), variant: :warning) %> -<%= t("providers.snaptrade.setup_title") %>
-<%= icon("alert-triangle", class: "inline-block w-4 h-4 mr-1") %><%= t("providers.snaptrade.free_tier_warning") %>
-<%= error_msg %>
-- <%= 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.status_needs_registration") %>
+- <%= t("providers.snaptrade.connection_limit_info") %> + <% 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.status_needs_registration") %>
<%= t("providers.snaptrade.status_not_configured") %>
-<%= t("sophtron_items.sophtron_panel.setup_instructions_title") %>
-<%= t("sophtron_items.sophtron_panel.field_descriptions_title") %>
-<%= error_msg %>
-<%= t("sophtron_items.sophtron_panel.status.configured_html", accounts_path: accounts_path) %>
- <% else %> - -<%= t("sophtron_items.sophtron_panel.status.not_configured") %>
- <% 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") %><%= t("settings.providers.encryption_error.message") %>
-- Configure credentials for third-party sync providers. Settings configured here will override environment variables. -
-<%= 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 %> - <% 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 %> -<%= t("settings.providers.groups.empty_available") %>
<% end %> <% end %><%= t(".deletion_in_progress") %>
<% end %> @@ -56,15 +61,37 @@<%= t(".message") %>
+<%= t(".description") %>
+<%= @challenge[:token_read] %>
<%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, data: { turbo_frame: "modal" } do %> <%= hidden_field_tag :mfa_type, "verify_phone" %> - <%= hidden_field_tag :accountable_type, @accountable_type %> - <%= hidden_field_tag :account_id, @account_id %> - <%= hidden_field_tag :return_to, @return_to %> + <%= render "sophtron_items/mfa_context_fields" %> <%= render DS::Button.new(text: t(".phone_confirmed"), type: "submit") %> <% end %>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/mfa/es.yml b/config/locales/views/mfa/es.yml
index c6db6fb0a..def1e4b5d 100644
--- a/config/locales/views/mfa/es.yml
+++ b/config/locales/views/mfa/es.yml
@@ -31,8 +31,15 @@ es:
verify_title: 2. Ingresa el Código de Verificación
verify:
description: Ingresa el código de tu aplicación de autenticación para continuar
+ or: o
page_title: Verificar la Autenticación de Dos Factores
title: Autenticación de Dos Factores
verify_button: Verificar
+ webauthn_button: Usar passkey o llave de seguridad
+ webauthn_unsupported: Este navegador no admite passkeys ni llaves de seguridad.
verify_code:
invalid_code: Código de autenticación inválido. Por favor, inténtalo de nuevo.
+ verify_webauthn:
+ invalid_credential: No se pudo verificar esa passkey o llave de seguridad. Inténtalo de nuevo.
+ webauthn_options:
+ unavailable: No hay passkeys ni llaves de seguridad disponibles para esta cuenta.
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..711a0abd1 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,74 @@ 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.
+ brex: Sync Brex cash and corporate card activity with read-only access.
+ 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 +267,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 +285,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/settings/securities/es.yml b/config/locales/views/settings/securities/es.yml
index e5beff85e..384649ffe 100644
--- a/config/locales/views/settings/securities/es.yml
+++ b/config/locales/views/settings/securities/es.yml
@@ -8,3 +8,20 @@ es:
enable_mfa: Habilitar 2FA
mfa_description: Agrega una capa adicional de seguridad a tu cuenta al requerir un código de tu aplicación de autenticación al iniciar sesión
mfa_title: Autenticación de 2FA
+ webauthn_add: Añadir passkey o llave de seguridad
+ webauthn_added: Añadida el %{date}
+ webauthn_description: Usa una passkey, Touch ID, Windows Hello o una llave de seguridad física como segundo factor al iniciar sesión.
+ webauthn_empty: Aún no hay passkeys ni llaves de seguridad registradas.
+ webauthn_last_used: Último uso hace %{time_ago}
+ webauthn_name_label: Nombre de la llave
+ webauthn_name_placeholder: Touch ID del MacBook, YubiKey, etc.
+ webauthn_remove: Eliminar
+ webauthn_remove_confirm: ¿Seguro que quieres eliminar esta passkey o llave de seguridad?
+ webauthn_remove_confirm_body: Tendrás que volver a registrar esta passkey o llave de seguridad antes de poder usarla para verificar el inicio de sesión.
+ webauthn_title: Passkeys y llaves de seguridad
+ webauthn_unsupported: Este navegador no admite passkeys ni llaves de seguridad.
+ webauthn_credentials:
+ default_name: Llave de seguridad
+ failure: No se pudo guardar esa passkey o llave de seguridad. Inténtalo de nuevo.
+ mfa_required: Activa la autenticación de dos factores antes de añadir una passkey o llave de seguridad.
+ success: Se eliminó la passkey o llave de seguridad.
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 1a03116d6..5e52084bf 100644
--- a/config/locales/views/sophtron_items/en.yml
+++ b/config/locales/views/sophtron_items/en.yml
@@ -97,17 +97,23 @@ en:
unknown_challenge: Unknown Sophtron verification step.
sophtron_item:
accounts_need_setup: Accounts need setup
+ automatic_sync: Use automatic sync
delete: Delete connection
deletion_in_progress: deletion in progress...
error: Error
no_accounts_description: This connection has no linked accounts yet.
no_accounts_title: No accounts
+ manual_sync: Manual sync
+ manual_sync_action: Require manual sync
+ manual_sync_action_for: "Require manual sync for %{institution}"
+ automatic_sync_for: "Use automatic sync for %{institution}"
setup_action: Set Up New Accounts
setup_description: "%{linked} of %{total} accounts linked. Choose account types for your newly imported Sophtron accounts."
setup_needed: New accounts ready to set up
status: "Synced %{timestamp} ago"
status_never: Never synced
status_with_summary: "Last synced %{timestamp} ago • %{summary}"
+ sync_now: Sync now
syncing: Syncing...
total: Total
unlinked: Unlinked
@@ -203,7 +209,20 @@ en:
no_accounts: "No accounts to set up."
success: "Successfully created %{count} account(s)."
sync:
+ already_running: Sophtron manual sync is already in progress.
+ api_error: "Sophtron manual sync failed: %{message}"
+ failed: Sophtron manual sync failed
+ no_linked_accounts: This Sophtron institution does not have any linked accounts to sync.
+ processing_failed: Sophtron manual sync could not process the refreshed transactions.
success: Sync started
+ toggle_manual_sync:
+ success_disabled: Sophtron institution will sync automatically.
+ success_enabled: Sophtron institution now requires manual sync.
+ manual_sync_complete:
+ close: Close
+ description: Account balances will finish updating in the background.
+ message: Transactions were downloaded after Sophtron verification.
+ title: Sophtron Sync Started
sophtron_setup_required:
title: Sophtron Setup Required
message: >
@@ -226,7 +245,7 @@ en:
expired_credentials: "Expired Credentials: Generate a new User ID and Access Key from Sophtron"
network_issue: "Network Issue: Check your internet connection"
service_down: "Service Down: Sophtron API may be temporarily unavailable"
- bank_credentials: "Bank credentials: Check the username and password for the selected institution"
+ bad_credentials: "Bank credentials: Check the username and password are correct"
verification_code: "Verification code: Make sure the latest code was entered before it expired"
institution_timeout: "Institution timeout: The bank login page did not finish in time"
unsupported_mfa: "MFA support: Sophtron may not support this institution's current verification flow"
@@ -260,10 +279,8 @@ 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..."
checking_account_configuration: "Checking account configuration..."
accounts_need_setup: "%{count} account(s) need setup"
diff --git a/config/locales/views/sophtron_items/hu.yml b/config/locales/views/sophtron_items/hu.yml
index d412ab818..a4cb2d375 100644
--- a/config/locales/views/sophtron_items/hu.yml
+++ b/config/locales/views/sophtron_items/hu.yml
@@ -61,17 +61,23 @@ hu:
no_user_id: A Sophtron felhasználói azonosító nincs beállítva. Kérlek állítsd be a Beállításokban.
sophtron_item:
accounts_need_setup: Számlák beállítást igényelnek
+ automatic_sync: Automatikus szinkronizálás használata
delete: Kapcsolat törlése
deletion_in_progress: törlés folyamatban...
error: Hiba
no_accounts_description: Ehhez a kapcsolathoz még nincsenek összekapcsolt számlák.
no_accounts_title: Nincsenek számlák
+ manual_sync: Kézi szinkronizálás
+ manual_sync_action: Kézi szinkronizálás megkövetelése
+ manual_sync_action_for: "Kézi szinkronizálás megkövetelése ehhez: %{institution}"
+ automatic_sync_for: "Automatikus szinkronizálás használata ehhez: %{institution}"
setup_action: Új számlák beállítása
setup_description: "%{linked} / %{total} számla összekapcsolva. Válaszd ki az újonnan importált Sophtron számlák típusait."
setup_needed: Új számlák beállításra várnak
status: "Szinkronizálva %{timestamp} ezelőtt"
status_never: Még nem szinkronizált
status_with_summary: "Utolsó szinkronizálás %{timestamp} ezelőtt • %{summary}"
+ sync_now: Szinkronizálás most
syncing: Szinkronizálás...
total: Összesen
unlinked: Nincs összekapcsolva
@@ -163,7 +169,20 @@ hu:
no_accounts: "Nincs beállítandó számla."
success: "%{count} számla sikeresen létrehozva."
sync:
+ already_running: A Sophtron kézi szinkronizálása már folyamatban van.
+ api_error: "A Sophtron kézi szinkronizálása sikertelen: %{message}"
+ failed: A Sophtron kézi szinkronizálása sikertelen
+ no_linked_accounts: Ehhez a Sophtron intézményhez nincs szinkronizálható összekapcsolt számla.
+ processing_failed: A Sophtron kézi szinkronizálása nem tudta feldolgozni a frissített tranzakciókat.
success: Szinkronizálás elindítva
+ toggle_manual_sync:
+ success_disabled: A Sophtron intézmény automatikusan fog szinkronizálni.
+ success_enabled: A Sophtron intézmény mostantól kézi szinkronizálást igényel.
+ manual_sync_complete:
+ close: Bezárás
+ description: A számlaegyenlegek frissítése a háttérben fejeződik be.
+ message: A tranzakciók letöltése elindult a Sophtron ellenőrzés után.
+ title: Sophtron szinkronizálás elindítva
sophtron_setup_required:
title: Sophtron beállítás szükséges
message: >
@@ -214,10 +233,8 @@ 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..."
checking_account_configuration: "Számlakonfiguráció ellenőrzése..."
accounts_need_setup: "%{count} számla beállítást igényel"
diff --git a/config/locales/views/transfer_matches/es.yml b/config/locales/views/transfer_matches/es.yml
new file mode 100644
index 000000000..2b1fc5986
--- /dev/null
+++ b/config/locales/views/transfer_matches/es.yml
@@ -0,0 +1,7 @@
+---
+es:
+ transfer_matches:
+ new:
+ header:
+ title: Vincular transferencia o pago
+ subtitle: Vincula la transacción correspondiente en otra cuenta o crea una si no existe.
diff --git a/config/locales/views/valuations/es.yml b/config/locales/views/valuations/es.yml
index ef47eb33c..9f1e6312c 100644
--- a/config/locales/views/valuations/es.yml
+++ b/config/locales/views/valuations/es.yml
@@ -1,6 +1,8 @@
---
es:
valuations:
+ errors:
+ amount_required: El importe es obligatorio
form:
amount: Importe
submit: Añadir actualización de saldo
diff --git a/config/routes.rb b/config/routes.rb
index fcda7daf5..cc111c0d5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -33,6 +33,22 @@
end
end
+ resources :brex_items, only: %i[index new create show edit update destroy] do
+ collection do
+ get :preload_accounts, to: "brex_items/account_flows#preload_accounts"
+ get :select_accounts, to: "brex_items/account_flows#select_accounts"
+ post :link_accounts, to: "brex_items/account_flows#link_accounts"
+ get :select_existing_account, to: "brex_items/account_flows#select_existing_account"
+ post :link_existing_account, to: "brex_items/account_flows#link_existing_account"
+ end
+
+ member do
+ post :sync
+ get :setup_accounts, to: "brex_items/account_setups#setup_accounts"
+ post :complete_account_setup, to: "brex_items/account_setups#complete_account_setup"
+ end
+ end
+
resources :coinbase_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do
collection do
get :preload_accounts
@@ -205,8 +221,14 @@
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
@@ -548,6 +570,7 @@
member do
post :connect_institution
post :sync
+ post :toggle_manual_sync
post :balances
get :connection_status
post :submit_mfa
diff --git a/db/migrate/20260505010000_create_brex_items_and_accounts.rb b/db/migrate/20260505010000_create_brex_items_and_accounts.rb
new file mode 100644
index 000000000..a76820b11
--- /dev/null
+++ b/db/migrate/20260505010000_create_brex_items_and_accounts.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+class CreateBrexItemsAndAccounts < ActiveRecord::Migration[7.2]
+ def change
+ create_table :brex_items, id: :uuid do |t|
+ t.references :family, null: false, foreign_key: true, type: :uuid
+ t.string :name, null: false
+
+ t.string :institution_id
+ t.string :institution_name
+ t.string :institution_domain
+ t.string :institution_url
+ t.string :institution_color
+
+ t.string :status, null: false, default: "good"
+ t.boolean :scheduled_for_deletion, null: false, default: false
+ t.boolean :pending_account_setup, null: false, default: false
+
+ t.datetime :sync_start_date
+
+ t.jsonb :raw_payload
+ t.jsonb :raw_institution_payload
+
+ t.text :token, null: false
+ t.string :base_url
+
+ t.timestamps
+ end
+
+ add_index :brex_items, :status
+
+ create_table :brex_accounts, id: :uuid do |t|
+ t.references :brex_item, null: false, foreign_key: true, type: :uuid
+
+ t.string :name
+ t.string :account_id, null: false
+ t.string :account_kind, null: false, default: "cash"
+
+ t.string :currency, null: false, default: "USD"
+ t.decimal :current_balance, precision: 19, scale: 4
+ t.decimal :available_balance, precision: 19, scale: 4
+ t.decimal :account_limit, precision: 19, scale: 4
+ t.string :account_status
+ t.string :account_type
+ t.string :provider
+
+ t.jsonb :institution_metadata
+ t.jsonb :raw_payload
+ t.jsonb :raw_transactions_payload
+
+ t.timestamps
+ end
+
+ add_index :brex_accounts,
+ [ :brex_item_id, :account_id ],
+ unique: true,
+ name: "index_brex_accounts_on_item_and_account_id"
+ end
+end
diff --git a/db/migrate/20260508120000_add_manual_sync_to_sophtron_items.rb b/db/migrate/20260508120000_add_manual_sync_to_sophtron_items.rb
new file mode 100644
index 000000000..ce94f6460
--- /dev/null
+++ b/db/migrate/20260508120000_add_manual_sync_to_sophtron_items.rb
@@ -0,0 +1,7 @@
+class AddManualSyncToSophtronItems < ActiveRecord::Migration[7.2]
+ def change
+ add_column :sophtron_items, :manual_sync, :boolean, null: false, default: false
+ add_column :sophtron_items, :current_job_sophtron_account_id, :uuid
+ add_index :sophtron_items, :current_job_sophtron_account_id
+ end
+end
diff --git a/db/migrate/20260508130000_add_manual_sync_to_sophtron_accounts.rb b/db/migrate/20260508130000_add_manual_sync_to_sophtron_accounts.rb
new file mode 100644
index 000000000..e36357cc0
--- /dev/null
+++ b/db/migrate/20260508130000_add_manual_sync_to_sophtron_accounts.rb
@@ -0,0 +1,17 @@
+class AddManualSyncToSophtronAccounts < ActiveRecord::Migration[7.2]
+ def change
+ add_column :sophtron_accounts, :manual_sync, :boolean, default: false, null: false
+
+ reversible do |dir|
+ dir.up do
+ execute <<~SQL.squish
+ UPDATE sophtron_accounts
+ SET manual_sync = TRUE
+ FROM sophtron_items
+ WHERE sophtron_accounts.sophtron_item_id = sophtron_items.id
+ AND sophtron_items.manual_sync = TRUE
+ SQL
+ end
+ end
+ end
+end
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 6512c65ff..b4965d4d7 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_07_120000) 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"
@@ -214,6 +214,49 @@
t.index ["status"], name: "index_binance_items_on_status"
end
+ create_table "brex_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "brex_item_id", null: false
+ t.string "name"
+ t.string "account_id", null: false
+ t.string "account_kind", default: "cash", null: false
+ t.string "currency", default: "USD", null: false
+ t.decimal "current_balance", precision: 19, scale: 4
+ t.decimal "available_balance", precision: 19, scale: 4
+ t.decimal "account_limit", precision: 19, scale: 4
+ t.string "account_status"
+ t.string "account_type"
+ t.string "provider"
+ t.jsonb "institution_metadata"
+ t.jsonb "raw_payload"
+ t.jsonb "raw_transactions_payload"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["brex_item_id", "account_id"], name: "index_brex_accounts_on_item_and_account_id", unique: true
+ t.index ["brex_item_id"], name: "index_brex_accounts_on_brex_item_id"
+ end
+
+ create_table "brex_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "family_id", null: false
+ t.string "name", null: false
+ t.string "institution_id"
+ t.string "institution_name"
+ t.string "institution_domain"
+ t.string "institution_url"
+ t.string "institution_color"
+ t.string "status", default: "good", null: false
+ t.boolean "scheduled_for_deletion", default: false, null: false
+ t.boolean "pending_account_setup", default: false, null: false
+ t.datetime "sync_start_date"
+ t.jsonb "raw_payload"
+ t.jsonb "raw_institution_payload"
+ t.text "token", null: false
+ t.string "base_url"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["family_id"], name: "index_brex_items_on_family_id"
+ t.index ["status"], name: "index_brex_items_on_status"
+ end
+
create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "budget_id", null: false
t.uuid "category_id", null: false
@@ -595,6 +638,7 @@
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
@@ -1405,6 +1449,7 @@
t.string "account_number_mask"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.boolean "manual_sync", default: false, null: false
t.index ["account_id"], name: "index_sophtron_accounts_on_account_id"
t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id"
t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true
@@ -1437,6 +1482,9 @@
t.text "last_connection_error"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.boolean "manual_sync", default: false, null: false
+ t.uuid "current_job_sophtron_account_id"
+ t.index ["current_job_sophtron_account_id"], name: "index_sophtron_items_on_current_job_sophtron_account_id"
t.index ["customer_id"], name: "index_sophtron_items_on_customer_id"
t.index ["family_id"], name: "index_sophtron_items_on_family_id"
t.index ["status"], name: "index_sophtron_items_on_status"
@@ -1685,6 +1733,8 @@
add_foreign_key "chats", "users"
add_foreign_key "coinbase_accounts", "coinbase_items"
add_foreign_key "coinbase_items", "families"
+ add_foreign_key "brex_accounts", "brex_items"
+ add_foreign_key "brex_items", "families"
add_foreign_key "coinstats_accounts", "coinstats_items"
add_foreign_key "coinstats_items", "families"
add_foreign_key "enable_banking_accounts", "enable_banking_items"
diff --git a/design/tokens/sure.tokens.json b/design/tokens/sure.tokens.json
index 8803068e6..209cad152 100644
--- a/design/tokens/sure.tokens.json
+++ b/design/tokens/sure.tokens.json
@@ -1,6 +1,6 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
- "$version": "2.1.0",
+ "$version": "2.2.0",
"$description": "Sure design tokens. Single source of truth. Hand-edit; run `npm run tokens:build` to regenerate CSS. Template syntax in $value strings: `{path.to.token}` resolves to `var(--path-to-token)`; `{path|N%}` becomes `--alpha(var(--path) / N%)`. Utility tokens whose value lacks `{}` are treated as raw Tailwind class lists for @apply.",
"font": {
@@ -21,6 +21,7 @@
"success": { "$value": "{color.green.600}", "$type": "color", "$extensions": { "sure.dark": "{color.green.500}" } },
"warning": { "$value": "{color.yellow.600}", "$type": "color", "$extensions": { "sure.dark": "{color.yellow.400}" } },
"destructive": { "$value": "{color.red.600}", "$type": "color", "$extensions": { "sure.dark": "{color.red.400}" } },
+ "info": { "$value": "{color.blue.600}", "$type": "color", "$extensions": { "sure.dark": "{color.blue.500}" } },
"shadow": { "$value": "{color.black|6%}", "$type": "color", "$extensions": { "sure.dark": "{color.white|8%}" } },
"gray": {
@@ -260,6 +261,7 @@
"bg-container-hover": { "$type": "utility", "$value": "{color.gray.50}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.800}" } },
"bg-container-inset": { "$type": "utility", "$value": "{color.gray.50}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.800}" } },
"bg-container-inset-hover":{ "$type": "utility","$value": "{color.gray.100}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.700}" } },
+ "bg-destructive-surface": { "$type": "utility", "$value": "{color.red.tint-5}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.red.tint-10}" } },
"bg-inverse": { "$type": "utility", "$value": "{color.gray.800}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.white}" } },
"bg-inverse-hover": { "$type": "utility", "$value": "{color.gray.700}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.100}" } },
"bg-overlay": {
diff --git a/lib/active_record_encryption_config.rb b/lib/active_record_encryption_config.rb
new file mode 100644
index 000000000..463976adc
--- /dev/null
+++ b/lib/active_record_encryption_config.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module ActiveRecordEncryptionConfig
+ ENV_KEYS = %w[
+ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
+ ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
+ ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
+ ].freeze
+
+ CONFIG_KEYS = %i[
+ primary_key
+ deterministic_key
+ key_derivation_salt
+ ].freeze
+
+ module_function
+
+ def complete_env?(env = ENV)
+ ENV_KEYS.all? { |key| env_value_present?(env, key) }
+ end
+
+ def partial_env?(env = ENV)
+ present_count = ENV_KEYS.count { |key| env_value_present?(env, key) }
+ present_count.positive? && present_count < ENV_KEYS.count
+ end
+
+ def missing_env_keys(env = ENV)
+ ENV_KEYS.reject { |key| env_value_present?(env, key) }
+ end
+
+ def partial_env_message(env = ENV)
+ "Active Record encryption environment variables are partially configured. Missing: #{missing_env_keys(env).join(', ')}"
+ end
+
+ def credentials_configured?(credentials = Rails.application.credentials)
+ credentials.active_record_encryption.present?
+ rescue NoMethodError
+ false
+ end
+
+ def runtime_configured?(config = Rails.application.config.active_record.encryption)
+ CONFIG_KEYS.all? { |key| config.public_send(key).present? }
+ rescue NoMethodError
+ false
+ end
+
+ def explicitly_configured?
+ complete_env? || credentials_configured?
+ end
+
+ def ready?
+ explicitly_configured? || runtime_configured?
+ end
+
+ def env_value_present?(env, key)
+ env[key].present?
+ end
+end
diff --git a/test/components/previews/alert_component_preview.rb b/test/components/previews/alert_component_preview.rb
index ddd91183c..3ae3d36e4 100644
--- a/test/components/previews/alert_component_preview.rb
+++ b/test/components/previews/alert_component_preview.rb
@@ -1,7 +1,34 @@
class AlertComponentPreview < Lookbook::Preview
# @param message text
+ # @param title text
# @param variant select [info, success, warning, error]
- def default(message: "This is an alert message.", variant: :info)
- render DS::Alert.new(message: message, variant: variant.to_sym)
+ def default(message: "This is an alert message.", title: nil, variant: :info)
+ render DS::Alert.new(message: message, title: title.presence, variant: variant.to_sym)
+ end
+
+ # @param variant select [info, success, warning, error]
+ def with_title(variant: :warning)
+ render DS::Alert.new(
+ message: "Heads up — this account hasn't synced in 7 days.",
+ title: "Stale connection",
+ variant: variant.to_sym
+ )
+ end
+
+ # @param variant select [info, success, warning, error]
+ def with_body_slot(variant: :error)
+ render DS::Alert.new(title: "We couldn't process this request", variant: variant.to_sym) do
+ tag.div do
+ safe_join([
+ tag.p("Verify the values you submitted and try again. If the issue persists, contact support.", class: "text-secondary"),
+ tag.ul(class: "list-disc list-inside text-secondary") do
+ safe_join([
+ tag.li("Check that all required fields are populated."),
+ tag.li("Confirm the dates fall within an open period.")
+ ])
+ end
+ ])
+ end
+ end
end
end
diff --git a/test/controllers/api/v1/provider_connections_controller_test.rb b/test/controllers/api/v1/provider_connections_controller_test.rb
index 815aeb9e7..974e8303c 100644
--- a/test/controllers/api/v1/provider_connections_controller_test.rb
+++ b/test/controllers/api/v1/provider_connections_controller_test.rb
@@ -142,6 +142,24 @@ class Api::V1::ProviderConnectionsControllerTest < ActionDispatch::IntegrationTe
assert_response :success
end
+ test "lists Brex provider connection status" do
+ brex_item = brex_items(:one)
+
+ get api_v1_provider_connections_url, headers: api_headers(@api_key)
+ assert_response :success
+
+ brex_connection = JSON.parse(response.body)["data"].detect do |connection|
+ connection["id"] == brex_item.id && connection["provider"] == "brex"
+ end
+
+ assert_not_nil brex_connection
+ assert_equal "BrexItem", brex_connection["provider_type"]
+ assert_equal brex_item.name, brex_connection["name"]
+ assert_equal brex_item.brex_accounts.count, brex_connection["accounts"]["total_count"]
+ assert_equal brex_item.linked_accounts_count, brex_connection["accounts"]["linked_count"]
+ assert_equal brex_item.unlinked_accounts_count, brex_connection["accounts"]["unlinked_count"]
+ end
+
test "returns an empty list when no provider connections exist" do
ProviderConnectionStatus.stub(:for_family, []) do
get api_v1_provider_connections_url, headers: api_headers(@api_key)
diff --git a/test/controllers/brex_items_controller_test.rb b/test/controllers/brex_items_controller_test.rb
new file mode 100644
index 000000000..b4311ca51
--- /dev/null
+++ b/test/controllers/brex_items_controller_test.rb
@@ -0,0 +1,461 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class BrexItemsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in users(:family_admin)
+ SyncJob.stubs(:perform_later)
+
+ @family = families(:dylan_family)
+ clear_brex_cache_entries
+ @existing_item = brex_items(:one)
+ @second_item = BrexItem.create!(
+ family: @family,
+ name: "Business Brex",
+ token: "second_brex_token",
+ base_url: "https://api.brex.com"
+ )
+ end
+
+ teardown do
+ clear_brex_cache_entries
+ end
+
+ test "create adds a new brex connection without overwriting existing credentials" do
+ existing_token = @existing_item.token
+
+ assert_difference "BrexItem.count", 1 do
+ post brex_items_url, params: {
+ brex_item: {
+ name: "Joint Brex",
+ token: "joint_brex_token",
+ base_url: "https://api.brex.com"
+ }
+ }
+ end
+
+ assert_redirected_to accounts_path
+ assert_equal existing_token, @existing_item.reload.token
+ assert_equal "joint_brex_token", @family.brex_items.find_by!(name: "Joint Brex").token
+ end
+
+ test "update changes only the selected brex connection" do
+ existing_token = @existing_item.token
+
+ patch brex_item_url(@second_item), params: {
+ brex_item: {
+ name: "Renamed Business Brex",
+ token: "updated_second_token",
+ base_url: "https://api-staging.brex.com"
+ }
+ }
+
+ assert_redirected_to accounts_path
+ assert_equal existing_token, @existing_item.reload.token
+ assert_equal "Renamed Business Brex", @second_item.reload.name
+ assert_equal "updated_second_token", @second_item.token
+ assert_equal "https://api-staging.brex.com", @second_item.base_url
+ end
+
+ test "update rejects arbitrary brex base url" do
+ patch brex_item_url(@second_item), params: {
+ brex_item: {
+ name: "Renamed Business Brex",
+ token: "updated_second_token",
+ base_url: "https://evil.example.test"
+ }
+ }
+
+ assert_redirected_to settings_providers_path
+ assert_includes flash[:alert], "https://api.brex.com"
+ assert_equal "https://api.brex.com", @second_item.reload.base_url
+ assert_equal "second_brex_token", @second_item.token
+ end
+
+ test "blank token update preserves the selected brex token" do
+ original_token = @second_item.token
+
+ patch brex_item_url(@second_item), params: {
+ brex_item: {
+ name: "Renamed Business Brex",
+ token: "",
+ base_url: "https://api.brex.com"
+ }
+ }
+
+ assert_redirected_to accounts_path
+ assert_equal "Renamed Business Brex", @second_item.reload.name
+ assert_equal original_token, @second_item.token
+ end
+
+ test "update expires selected brex account cache when credentials change" do
+ Rails.cache.expects(:delete).with(brex_cache_key(@existing_item)).never
+ Rails.cache.expects(:delete).with(brex_cache_key(@second_item)).once
+
+ patch brex_item_url(@second_item), params: {
+ brex_item: {
+ name: "Renamed Business Brex",
+ token: "updated_second_token",
+ base_url: "https://api-staging.brex.com"
+ }
+ }
+
+ assert_redirected_to accounts_path
+ end
+
+ test "update does not expire selected brex account cache for name-only changes" do
+ Rails.cache.expects(:delete).never
+
+ patch brex_item_url(@second_item), params: {
+ brex_item: {
+ name: "Renamed Business Brex"
+ }
+ }
+
+ assert_redirected_to accounts_path
+ assert_equal "Renamed Business Brex", @second_item.reload.name
+ end
+
+ test "preload accounts uses selected brex item cache key" do
+ Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(nil)
+ Rails.cache.expects(:write).with(brex_cache_key(@second_item), brex_accounts_payload, expires_in: 5.minutes)
+
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(accounts: brex_accounts_payload)
+ Provider::Brex.expects(:new)
+ .with(@second_item.token, base_url: @second_item.effective_base_url)
+ .returns(provider)
+
+ get preload_accounts_brex_items_url, params: { brex_item_id: @second_item.id }, as: :json
+
+ assert_response :success
+ response = JSON.parse(@response.body)
+ assert_equal true, response["success"]
+ assert_equal true, response["has_accounts"]
+ end
+
+ test "select accounts requires an explicit connection when multiple brex items exist" do
+ get select_accounts_brex_items_url, params: { accountable_type: "Depository" }
+
+ assert_redirected_to settings_providers_path
+ assert_equal I18n.t("brex_items.select_accounts.select_connection"), flash[:alert]
+ end
+
+ test "select accounts renders the selected brex item id" do
+ Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(nil)
+ Rails.cache.expects(:write).with(brex_cache_key(@second_item), brex_accounts_payload, expires_in: 5.minutes)
+
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(accounts: brex_accounts_payload)
+ Provider::Brex.expects(:new)
+ .with(@second_item.token, base_url: @second_item.effective_base_url)
+ .returns(provider)
+
+ get select_accounts_brex_items_url, params: {
+ brex_item_id: @second_item.id,
+ accountable_type: "Depository"
+ }
+
+ assert_response :success
+ assert_includes @response.body, %(name="brex_item_id")
+ assert_includes @response.body, %(value="#{@second_item.id}")
+ end
+
+ test "select accounts rejects protocol relative return paths" do
+ Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(brex_accounts_payload)
+
+ get select_accounts_brex_items_url, params: {
+ brex_item_id: @second_item.id,
+ accountable_type: "Depository",
+ return_to: "//evil.example/accounts"
+ }
+
+ assert_response :success
+ refute_includes @response.body, "//evil.example/accounts"
+ end
+
+ test "select accounts rejects backslash and unsafe local return paths" do
+ [
+ "/\\evil.example/accounts",
+ "/%2fevil.example/accounts",
+ "/%2Fevil.example/accounts",
+ "/%5cevil.example/accounts",
+ "/%5Cevil.example/accounts",
+ "/\naccounts",
+ "/ accounts",
+ "/"
+ ].each do |return_to|
+ Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(brex_accounts_payload)
+
+ get select_accounts_brex_items_url, params: {
+ brex_item_id: @second_item.id,
+ accountable_type: "Depository",
+ return_to: return_to
+ }
+
+ assert_response :success
+ assert_select %(input[name="return_to"]) do |fields|
+ assert fields.first["value"].blank?
+ end
+ end
+ end
+
+ test "select existing account rejects unsafe return paths" do
+ account = @family.accounts.create!(
+ name: "Manual Checking",
+ balance: 0,
+ currency: "USD",
+ accountable: Depository.new
+ )
+
+ [
+ "//evil.example/accounts",
+ "\\evil.example/accounts",
+ "/\\evil.example/accounts",
+ "/%2fevil.example/accounts",
+ "/%2Fevil.example/accounts",
+ "/%5cevil.example/accounts",
+ "/%5Cevil.example/accounts",
+ "/\naccounts",
+ "/ accounts",
+ " ",
+ "/"
+ ].each do |return_to|
+ Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(brex_accounts_payload)
+
+ get select_existing_account_brex_items_url, params: {
+ brex_item_id: @second_item.id,
+ account_id: account.id,
+ return_to: return_to
+ }
+
+ assert_response :success
+ assert_select %(input[name="return_to"]) do |fields|
+ assert fields.first["value"].blank?
+ end
+ end
+ end
+
+ test "select existing account preserves safe local return path" do
+ account = @family.accounts.create!(
+ name: "Manual Checking",
+ balance: 0,
+ currency: "USD",
+ accountable: Depository.new
+ )
+ return_to = "/accounts?tab=manual"
+
+ Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(brex_accounts_payload)
+
+ get select_existing_account_brex_items_url, params: {
+ brex_item_id: @second_item.id,
+ account_id: account.id,
+ return_to: return_to
+ }
+
+ assert_response :success
+ assert_select %(input[name="return_to"][value="#{return_to}"])
+ end
+
+ test "select existing account redirects when account id is invalid" do
+ get select_existing_account_brex_items_url, params: {
+ brex_item_id: @second_item.id,
+ account_id: SecureRandom.uuid
+ }
+
+ assert_redirected_to accounts_path
+ assert_equal I18n.t("brex_items.select_existing_account.no_account_specified"), flash[:alert]
+ end
+
+ test "select existing account renders the selected brex item id" do
+ account = @family.accounts.create!(
+ name: "Manual Checking",
+ balance: 0,
+ currency: "USD",
+ accountable: Depository.new
+ )
+
+ Rails.cache.expects(:read).with(brex_cache_key(@second_item)).returns(nil)
+ Rails.cache.expects(:write).with(brex_cache_key(@second_item), brex_accounts_payload, expires_in: 5.minutes)
+
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(accounts: brex_accounts_payload)
+ Provider::Brex.expects(:new)
+ .with(@second_item.token, base_url: @second_item.effective_base_url)
+ .returns(provider)
+
+ get select_existing_account_brex_items_url, params: {
+ brex_item_id: @second_item.id,
+ account_id: account.id
+ }
+
+ assert_response :success
+ assert_includes @response.body, %(name="brex_item_id")
+ assert_includes @response.body, %(value="#{@second_item.id}")
+ end
+
+ test "link accounts uses selected brex item and allows duplicate upstream ids across items" do
+ @existing_item.brex_accounts.create!(
+ account_id: "shared_brex_account",
+ name: "Shared Checking",
+ currency: "USD",
+ current_balance: 1000
+ )
+
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(accounts: brex_accounts_payload)
+ Provider::Brex.expects(:new)
+ .with(@second_item.token, base_url: @second_item.effective_base_url)
+ .returns(provider)
+
+ assert_difference -> { @second_item.brex_accounts.where(account_id: "shared_brex_account").count }, 1 do
+ assert_difference "AccountProvider.count", 1 do
+ post link_accounts_brex_items_url, params: {
+ brex_item_id: @second_item.id,
+ account_ids: [ "shared_brex_account" ],
+ accountable_type: "Depository"
+ }
+ end
+ end
+
+ assert_redirected_to accounts_path
+ assert_equal 1, @existing_item.brex_accounts.where(account_id: "shared_brex_account").count
+ end
+
+ test "link accounts does not silently use the first connection when multiple items exist" do
+ assert_no_difference "BrexAccount.count" do
+ assert_no_difference "Account.count" do
+ post link_accounts_brex_items_url, params: {
+ account_ids: [ "shared_brex_account" ],
+ accountable_type: "Depository"
+ }
+ end
+ end
+
+ assert_redirected_to settings_providers_path
+ assert_equal I18n.t("brex_items.link_accounts.select_connection"), flash[:alert]
+ end
+
+ test "link existing account does not silently use the first connection when multiple items exist" do
+ account = @family.accounts.create!(
+ name: "Manual Checking",
+ balance: 0,
+ currency: "USD",
+ accountable: Depository.new
+ )
+
+ assert_no_difference "BrexAccount.count" do
+ assert_no_difference "AccountProvider.count" do
+ post link_existing_account_brex_items_url, params: {
+ account_id: account.id,
+ brex_account_id: "shared_brex_account"
+ }
+ end
+ end
+
+ assert_redirected_to settings_providers_path
+ assert_equal I18n.t("brex_items.link_existing_account.select_connection"), flash[:alert]
+ end
+
+ test "link existing account requires account id" do
+ assert_no_difference "AccountProvider.count" do
+ post link_existing_account_brex_items_url, params: {
+ brex_item_id: @second_item.id,
+ brex_account_id: "shared_brex_account"
+ }
+ end
+
+ assert_redirected_to accounts_path
+ assert_equal I18n.t("brex_items.link_existing_account.no_account_specified"), flash[:alert]
+ end
+
+ test "link existing account redirects when account id is invalid" do
+ assert_no_difference "AccountProvider.count" do
+ post link_existing_account_brex_items_url, params: {
+ brex_item_id: @second_item.id,
+ account_id: SecureRandom.uuid,
+ brex_account_id: "shared_brex_account"
+ }
+ end
+
+ assert_redirected_to accounts_path
+ assert_equal I18n.t("brex_items.link_existing_account.no_account_specified"), flash[:alert]
+ end
+
+ test "sync only queues a sync for the selected brex item" do
+ assert_difference -> { Sync.where(syncable: @second_item).count }, 1 do
+ assert_no_difference -> { Sync.where(syncable: @existing_item).count } do
+ post sync_brex_item_url(@second_item)
+ end
+ end
+
+ assert_response :redirect
+ end
+
+ test "complete account setup ignores unsupported account type and subtype params" do
+ valid_brex_account = @second_item.brex_accounts.create!(
+ account_id: "setup_valid",
+ account_kind: "cash",
+ name: "Setup Valid",
+ currency: "USD",
+ current_balance: 100
+ )
+ unsupported_brex_account = @second_item.brex_accounts.create!(
+ account_id: "setup_unsupported",
+ account_kind: "cash",
+ name: "Setup Unsupported",
+ currency: "USD",
+ current_balance: 100
+ )
+
+ assert_difference "AccountProvider.count", 1 do
+ post complete_account_setup_brex_item_url(@second_item), params: {
+ account_types: {
+ valid_brex_account.id => "Depository",
+ unsupported_brex_account.id => "Investment",
+ "not-a-brex-account" => "Depository"
+ },
+ account_subtypes: {
+ valid_brex_account.id => "savings",
+ unsupported_brex_account.id => "brokerage",
+ "not-a-brex-account" => "checking"
+ }
+ }
+ end
+
+ assert_redirected_to accounts_path
+ assert_equal "savings", valid_brex_account.reload.account.accountable.subtype
+ assert_nil unsupported_brex_account.reload.account_provider
+ assert_match(/skipped/i, flash[:notice])
+ end
+
+ private
+
+ def brex_accounts_payload
+ [
+ {
+ id: "shared_brex_account",
+ name: "Shared Checking",
+ account_kind: "cash",
+ status: "active",
+ current_balance: { amount: 100_000, currency: "USD" },
+ available_balance: { amount: 95_000, currency: "USD" }
+ }
+ ]
+ end
+
+ def brex_cache_key(brex_item)
+ BrexItem::AccountFlow.cache_key(@family, brex_item)
+ end
+
+ def clear_brex_cache_entries
+ return unless defined?(@family) && @family.present?
+ return unless Rails.cache.respond_to?(:delete_matched)
+
+ Rails.cache.delete_matched("brex_accounts_#{@family.id}_*")
+ rescue NotImplementedError
+ # Some test cache stores do not implement delete_matched; tests that depend
+ # on cache state stub exact Brex cache keys instead of relying on globals.
+ end
+end
diff --git a/test/controllers/settings/providers_controller_test.rb b/test/controllers/settings/providers_controller_test.rb
index a7358e06e..9107bd593 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
@@ -24,6 +32,27 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest
end
end
+ test "shows configured Brex connections in bank sync settings" do
+ get settings_providers_url
+
+ assert_response :success
+ assert_includes response.body, "Brex"
+ assert_includes response.body, "Test Brex Connection"
+ assert_includes response.body, "brex-providers-panel"
+ end
+
+ test "shows Brex as available when family has no Brex connections" do
+ sign_in users(:empty)
+
+ get settings_providers_url
+
+ assert_response :success
+ assert_includes response.body, "Brex"
+ assert_includes response.body, I18n.t("settings.providers.taglines.brex")
+ assert_includes response.body, connect_form_settings_providers_path(provider_key: "brex")
+ refute_includes response.body, "Test Brex Connection"
+ end
+
test "correctly identifies declared vs dynamic fields" do
# All current provider fields are dynamic, but the logic should correctly
# distinguish between declared and dynamic fields
@@ -298,6 +327,70 @@ 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 "POST sync for brex without an active Brex sync enqueues SyncJob" do
+ item = brex_items(:one)
+ Sync.where(syncable_type: "BrexItem", syncable_id: item.id).delete_all
+
+ assert_enqueued_jobs 1, only: SyncJob do
+ post sync_provider_settings_providers_path(provider_key: "brex")
+ 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 +399,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/controllers/sophtron_items_controller_test.rb b/test/controllers/sophtron_items_controller_test.rb
index c69f5fa09..0ae7132a1 100644
--- a/test/controllers/sophtron_items_controller_test.rb
+++ b/test/controllers/sophtron_items_controller_test.rb
@@ -569,6 +569,351 @@ class SophtronItemsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to connection_status_sophtron_item_path(@item, post_mfa: true)
end
+ test "toggle_manual_sync marks linked Sophtron institution accounts manual" do
+ sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-1",
+ name: "Sophtron Checking",
+ currency: "USD",
+ balance: 100
+ )
+ AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
+
+ assert_not @item.manual_sync?
+ assert_not sophtron_account.manual_sync?
+
+ post toggle_manual_sync_sophtron_item_url(@item)
+
+ assert_redirected_to accounts_path
+ assert_not @item.reload.manual_sync?
+ assert sophtron_account.reload.manual_sync?
+ assert_includes SophtronItem.syncable, @item
+ assert_equal "Sophtron institution now requires manual sync.", flash[:notice]
+ end
+
+ test "toggle_manual_sync can target one Sophtron institution on a mixed item" do
+ first_sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-1",
+ name: "Apple Card",
+ currency: "USD",
+ balance: 100,
+ institution_metadata: { name: "Apple", user_institution_id: "ui-apple" }
+ )
+ second_sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-2",
+ name: "Amazon Card",
+ currency: "USD",
+ balance: 200,
+ institution_metadata: { name: "Amazon", user_institution_id: "ui-amazon" }
+ )
+ AccountProvider.create!(account: accounts(:depository), provider: first_sophtron_account)
+ AccountProvider.create!(account: accounts(:credit_card), provider: second_sophtron_account)
+
+ post toggle_manual_sync_sophtron_item_url(@item), params: { user_institution_id: "ui-amazon" }
+
+ assert_not first_sophtron_account.reload.manual_sync?
+ assert second_sophtron_account.reload.manual_sync?
+ end
+
+ test "toggle_manual_sync makes targeted institution automatic when whole item is manual" do
+ first_sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-1",
+ name: "Apple Card",
+ currency: "USD",
+ balance: 100,
+ institution_metadata: { name: "Apple", user_institution_id: "ui-apple" }
+ )
+ second_sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-2",
+ name: "Amazon Card",
+ currency: "USD",
+ balance: 200,
+ institution_metadata: { name: "Amazon", user_institution_id: "ui-amazon" }
+ )
+ AccountProvider.create!(account: accounts(:depository), provider: first_sophtron_account)
+ AccountProvider.create!(account: accounts(:credit_card), provider: second_sophtron_account)
+ @item.update!(manual_sync: true)
+
+ post toggle_manual_sync_sophtron_item_url(@item), params: { user_institution_id: "ui-amazon" }
+
+ assert_not @item.reload.manual_sync?
+ assert first_sophtron_account.reload.manual_sync?
+ assert_not second_sophtron_account.reload.manual_sync?
+ assert_equal "Sophtron institution will sync automatically.", flash[:notice]
+ end
+
+ test "manual sync starts Sophtron refresh and renders MFA challenge" do
+ @item.update!(user_institution_id: "ui-1")
+ sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-1",
+ name: "Sophtron Checking",
+ currency: "USD",
+ balance: 100,
+ manual_sync: true
+ )
+ AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
+
+ provider = mock
+ provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" })
+ provider.expects(:get_job_information).with("job-1").returns({
+ SecurityQuestion: [ "What is your favorite color?" ].to_json,
+ SuccessFlag: nil,
+ LastStatus: "Waiting"
+ })
+ SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
+
+ assert_no_enqueued_jobs only: SyncJob do
+ assert_difference -> { @item.syncs.count }, 1 do
+ post sync_sophtron_item_url(@item)
+ end
+ end
+
+ assert_response :success
+ assert_includes response.body, "What is your favorite color?"
+ assert_equal "job-1", @item.reload.current_job_id
+ assert_equal sophtron_account.id, @item.current_job_sophtron_account_id
+ assert @item.syncs.ordered.first.syncing?
+ end
+
+ test "manual sync creates its own sync when an automatic sync is visible" do
+ @item.update!(user_institution_id: "ui-1")
+ automatic_sync = @item.syncs.create!
+ automatic_sync.start!
+ sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-1",
+ name: "Sophtron Checking",
+ currency: "USD",
+ balance: 100,
+ manual_sync: true
+ )
+ AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
+
+ provider = mock
+ provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" })
+ provider.expects(:get_job_information).with("job-1").returns({
+ SecurityQuestion: [ "What is your favorite color?" ].to_json,
+ LastStatus: "Waiting"
+ })
+ SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
+
+ assert_difference -> { @item.syncs.count }, 1 do
+ post sync_sophtron_item_url(@item)
+ end
+
+ assert_response :success
+ assert_equal automatic_sync.id, @item.syncs.ordered.second.id
+ manual_sync = @item.syncs.ordered.first
+ assert_equal [], manual_sync.sync_stats["manual_sync_processed_sophtron_account_ids"]
+ assert_includes response.body, "value=\"#{manual_sync.id}\""
+ assert_not_includes response.body, "value=\"#{automatic_sync.id}\""
+ end
+
+ test "manual sync does not start another refresh while one is active" do
+ @item.update!(user_institution_id: "ui-1")
+ sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-1",
+ name: "Sophtron Checking",
+ currency: "USD",
+ balance: 100,
+ manual_sync: true
+ )
+ AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
+ sync = @item.syncs.create!(sync_stats: { SophtronItemsController::MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY => [] })
+ sync.start!
+ @item.update!(current_job_id: "job-1", current_job_sophtron_account_id: sophtron_account.id)
+ SophtronItem.any_instance.expects(:sophtron_provider).never
+
+ post sync_sophtron_item_url(@item)
+
+ assert_redirected_to connection_status_sophtron_item_path(
+ @item,
+ manual_sync: true,
+ sync_id: sync.id,
+ sophtron_account_id: sophtron_account.id
+ )
+ assert_equal "Sophtron manual sync is already in progress.", flash[:alert]
+ end
+
+ test "manual sync refreshes every linked Sophtron account" do
+ @item.update!(user_institution_id: "ui-1")
+ first_sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-1",
+ name: "Sophtron Checking",
+ currency: "USD",
+ balance: 100,
+ manual_sync: true
+ )
+ second_sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-2",
+ name: "Sophtron Card",
+ currency: "USD",
+ balance: 200,
+ manual_sync: true
+ )
+ AccountProvider.create!(account: accounts(:depository), provider: first_sophtron_account)
+ AccountProvider.create!(account: accounts(:credit_card), provider: second_sophtron_account)
+
+ provider = mock
+ sequence = sequence("sophtron manual refresh")
+ provider.expects(:refresh_account).with("acct-1").in_sequence(sequence).returns({ JobID: "job-1" })
+ provider.expects(:get_job_information).with("job-1").in_sequence(sequence).returns({ LastStatus: "Completed" })
+ provider.expects(:refresh_account).with("acct-2").in_sequence(sequence).returns({ JobID: "job-2" })
+ provider.expects(:get_job_information).with("job-2").in_sequence(sequence).returns({ LastStatus: "Completed" })
+ SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
+ SophtronItem::Importer.any_instance.expects(:import_transactions_after_refresh)
+ .with(first_sophtron_account)
+ .returns({ success: true, transactions_count: 1 })
+ SophtronItem::Importer.any_instance.expects(:import_transactions_after_refresh)
+ .with(second_sophtron_account)
+ .returns({ success: true, transactions_count: 1 })
+ SophtronAccount::Processor.any_instance.expects(:process).twice.returns({ transactions_imported: 1 })
+
+ assert_enqueued_jobs 2, only: SyncJob do
+ post sync_sophtron_item_url(@item)
+ end
+
+ assert_response :success
+ assert_includes response.body, "Transactions were downloaded after Sophtron verification."
+ @item.reload
+ assert_nil @item.current_job_id
+ assert_nil @item.current_job_sophtron_account_id
+ assert_equal(
+ [ first_sophtron_account.id, second_sophtron_account.id ].map(&:to_s),
+ @item.syncs.ordered.first.sync_stats["manual_sync_processed_sophtron_account_ids"]
+ )
+ stats = @item.syncs.ordered.first.sync_stats
+ assert_equal 2, stats["total_accounts"]
+ assert_equal 2, stats["linked_accounts"]
+ assert_equal 0, stats["unlinked_accounts"]
+ assert_equal 0, stats["total_errors"]
+ assert stats.key?("tx_seen")
+ assert stats.key?("tx_imported")
+ assert stats.key?("tx_updated")
+ end
+
+ test "manual sync clears job pointers when refresh job fails" do
+ @item.update!(user_institution_id: "ui-1")
+ sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-1",
+ name: "Sophtron Checking",
+ currency: "USD",
+ balance: 100,
+ manual_sync: true
+ )
+ AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
+
+ provider = mock
+ provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" })
+ provider.expects(:get_job_information).with("job-1").returns({
+ SuccessFlag: false,
+ LastStatus: "Timeout"
+ })
+ SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
+
+ post sync_sophtron_item_url(@item)
+
+ assert_redirected_to accounts_path
+ @item.reload
+ assert_nil @item.current_job_id
+ assert_nil @item.current_job_sophtron_account_id
+ assert_equal "requires_update", @item.status
+ assert_equal "Sophtron manual sync failed", @item.last_connection_error
+ assert @item.syncs.ordered.first.failed?
+ end
+
+ test "manual sync clears job pointers when job polling raises provider error" do
+ @item.update!(user_institution_id: "ui-1")
+ sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-1",
+ name: "Sophtron Checking",
+ currency: "USD",
+ balance: 100,
+ manual_sync: true
+ )
+ AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
+
+ provider = mock
+ provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" })
+ provider.expects(:get_job_information)
+ .with("job-1")
+ .raises(Provider::Sophtron::Error.new("Sophtron unavailable", :api_error))
+ SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
+
+ post sync_sophtron_item_url(@item)
+
+ assert_redirected_to accounts_path
+ @item.reload
+ assert_nil @item.current_job_id
+ assert_nil @item.current_job_sophtron_account_id
+ assert_equal "requires_update", @item.status
+ assert_equal "Sophtron unavailable", @item.last_connection_error
+ assert @item.syncs.ordered.first.failed?
+ end
+
+ test "manual sync fails and clears job pointers when processing raises" do
+ @item.update!(user_institution_id: "ui-1")
+ sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-1",
+ name: "Sophtron Checking",
+ currency: "USD",
+ balance: 100,
+ manual_sync: true
+ )
+ AccountProvider.create!(account: accounts(:depository), provider: sophtron_account)
+
+ provider = mock
+ provider.expects(:refresh_account).with("acct-1").returns({ JobID: "job-1" })
+ provider.expects(:get_job_information).with("job-1").returns({ LastStatus: "Completed" })
+ SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
+ SophtronItem::Importer.any_instance.expects(:import_transactions_after_refresh)
+ .with(sophtron_account)
+ .returns({ success: true, transactions_count: 1 })
+ SophtronAccount::Processor.any_instance.expects(:process).raises(StandardError.new("processor failed"))
+
+ post sync_sophtron_item_url(@item)
+
+ assert_redirected_to accounts_path
+ @item.reload
+ assert_nil @item.current_job_id
+ assert_nil @item.current_job_sophtron_account_id
+ assert_equal "requires_update", @item.status
+ assert_equal "processor failed", @item.last_connection_error
+ assert @item.syncs.ordered.first.failed?
+ assert_equal "Sophtron manual sync failed: Sophtron manual sync could not process the refreshed transactions.", flash[:alert]
+ assert_not_includes flash[:alert], "processor failed"
+ end
+
+ test "submit_mfa preserves manual sync context" do
+ @item.update!(user_institution_id: "ui-1", current_job_id: "job-1")
+ sync = @item.syncs.create!
+ sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-1",
+ name: "Sophtron Checking",
+ currency: "USD",
+ balance: 100,
+ manual_sync: true
+ )
+ provider = mock
+ provider.expects(:update_job_token_input).with("job-1", token_input: "123456").returns({})
+ SophtronItem.any_instance.stubs(:sophtron_provider).returns(provider)
+
+ post submit_mfa_sophtron_item_url(@item), params: {
+ mfa_type: "token_input",
+ token_input: "123456",
+ manual_sync: true,
+ sync_id: sync.id,
+ sophtron_account_id: sophtron_account.id
+ }
+
+ assert_redirected_to connection_status_sophtron_item_path(
+ @item,
+ manual_sync: "true",
+ post_mfa: true,
+ sophtron_account_id: sophtron_account.id,
+ sync_id: sync.id
+ )
+ end
+
+
test "link_existing_account links manual account to sophtron account" do
@item.update!(user_institution_id: "ui-1")
account = accounts(:depository)
diff --git a/test/fixtures/brex_accounts.yml b/test/fixtures/brex_accounts.yml
new file mode 100644
index 000000000..ce5214b47
--- /dev/null
+++ b/test/fixtures/brex_accounts.yml
@@ -0,0 +1,7 @@
+checking_account:
+ brex_item: one
+ account_id: "cash_acc_checking_1"
+ account_kind: cash
+ name: "Brex Checking"
+ currency: USD
+ current_balance: 10000.00
diff --git a/test/fixtures/brex_items.yml b/test/fixtures/brex_items.yml
new file mode 100644
index 000000000..492f464df
--- /dev/null
+++ b/test/fixtures/brex_items.yml
@@ -0,0 +1,7 @@
+one:
+ family: dylan_family
+
+ name: "Test Brex Connection"
+ token: "test_brex_token_123"
+ base_url: "https://api-staging.brex.com"
+ status: good
diff --git a/test/lib/active_record_encryption_config_test.rb b/test/lib/active_record_encryption_config_test.rb
new file mode 100644
index 000000000..825d357d7
--- /dev/null
+++ b/test/lib/active_record_encryption_config_test.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ActiveRecordEncryptionConfigTest < ActiveSupport::TestCase
+ test "detects complete encryption environment" do
+ env = {
+ "ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY" => "primary",
+ "ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY" => "deterministic",
+ "ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT" => "salt"
+ }
+
+ assert ActiveRecordEncryptionConfig.complete_env?(env)
+ refute ActiveRecordEncryptionConfig.partial_env?(env)
+ assert_empty ActiveRecordEncryptionConfig.missing_env_keys(env)
+ end
+
+ test "detects partially configured encryption environment" do
+ env = {
+ "ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY" => "primary",
+ "ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY" => nil,
+ "ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT" => "salt"
+ }
+
+ refute ActiveRecordEncryptionConfig.complete_env?(env)
+ assert ActiveRecordEncryptionConfig.partial_env?(env)
+ assert_equal [ "ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY" ], ActiveRecordEncryptionConfig.missing_env_keys(env)
+ assert_includes ActiveRecordEncryptionConfig.partial_env_message(env), "ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"
+ end
+
+ test "does not treat absent encryption environment as partial" do
+ env = {
+ "ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY" => nil,
+ "ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY" => nil,
+ "ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT" => nil
+ }
+
+ refute ActiveRecordEncryptionConfig.complete_env?(env)
+ refute ActiveRecordEncryptionConfig.partial_env?(env)
+ end
+
+ test "detects runtime encryption configuration" do
+ config = Struct.new(:primary_key, :deterministic_key, :key_derivation_salt).new("primary", "deterministic", "salt")
+
+ assert ActiveRecordEncryptionConfig.runtime_configured?(config)
+ end
+
+ test "explicit configuration excludes runtime generated config" do
+ ActiveRecordEncryptionConfig.stubs(:complete_env?).returns(false)
+ ActiveRecordEncryptionConfig.stubs(:credentials_configured?).returns(false)
+ ActiveRecordEncryptionConfig.stubs(:runtime_configured?).returns(true)
+
+ refute ActiveRecordEncryptionConfig.explicitly_configured?
+ assert ActiveRecordEncryptionConfig.ready?
+ end
+end
diff --git a/test/models/brex_account/transactions/processor_test.rb b/test/models/brex_account/transactions/processor_test.rb
new file mode 100644
index 000000000..74c4a2a3a
--- /dev/null
+++ b/test/models/brex_account/transactions/processor_test.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class BrexAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
+ setup do
+ @brex_item = brex_items(:one)
+ @brex_account = @brex_item.brex_accounts.create!(
+ account_id: "cash_unlinked",
+ account_kind: "cash",
+ name: "Unlinked Cash",
+ currency: "USD",
+ raw_transactions_payload: [
+ {
+ id: "tx_skipped",
+ amount: { amount: 1_00, currency: "USD" },
+ description: "Skipped transaction",
+ posted_at_date: "2026-01-02"
+ }
+ ]
+ )
+ end
+
+ test "counts intentionally skipped transactions separately from failures" do
+ result = BrexAccount::Transactions::Processor.new(@brex_account).process
+
+ assert result[:success]
+ assert_equal 1, result[:total]
+ assert_equal 0, result[:imported]
+ assert_equal 1, result[:skipped]
+ assert_equal 0, result[:failed]
+ assert_equal "No linked account", result[:skipped_transactions].first[:reason]
+ assert_empty result[:errors]
+ end
+
+ test "imports linked transactions successfully" do
+ link_brex_account!
+
+ result = BrexAccount::Transactions::Processor.new(@brex_account).process
+
+ assert result[:success]
+ assert_equal 1, result[:total]
+ assert_equal 1, result[:imported]
+ assert_equal 0, result[:skipped]
+ assert_equal 0, result[:failed]
+ assert_empty result[:skipped_transactions]
+ assert_empty result[:errors]
+ end
+
+ test "aggregates partial transaction failures" do
+ link_brex_account!
+ @brex_account.update!(
+ raw_transactions_payload: [
+ {
+ id: "tx_success",
+ amount: { amount: 1_00, currency: "USD" },
+ description: "Successful transaction",
+ posted_at_date: "2026-01-02"
+ },
+ {
+ id: "tx_failure",
+ amount: { amount: 2_00, currency: "USD" },
+ description: "Failed transaction",
+ posted_at_date: "not-a-date"
+ }
+ ]
+ )
+
+ result = BrexAccount::Transactions::Processor.new(@brex_account).process
+
+ assert_not result[:success]
+ assert_equal 2, result[:total]
+ assert_equal 1, result[:imported]
+ assert_equal 0, result[:skipped]
+ assert_equal 1, result[:failed]
+ assert_empty result[:skipped_transactions]
+ assert_equal "tx_failure", result[:errors].first[:transaction_id]
+ assert_match(/Unable to parse transaction date/, result[:errors].first[:error])
+ end
+
+ private
+
+ def link_brex_account!
+ account = @brex_item.family.accounts.create!(
+ name: "Linked Cash",
+ balance: 0,
+ currency: "USD",
+ accountable: Depository.new
+ )
+ AccountProvider.create!(account: account, provider: @brex_account)
+ end
+end
diff --git a/test/models/brex_account_test.rb b/test/models/brex_account_test.rb
new file mode 100644
index 000000000..c1ea30622
--- /dev/null
+++ b/test/models/brex_account_test.rb
@@ -0,0 +1,201 @@
+require "test_helper"
+
+class BrexAccountTest < ActiveSupport::TestCase
+ setup do
+ @family_a = families(:dylan_family)
+ @family_b = families(:empty)
+
+ @item_a = BrexItem.create!(
+ family: @family_a,
+ name: "Family A Brex",
+ token: "token_a",
+ base_url: "https://api-staging.brex.com",
+ status: "good"
+ )
+
+ @item_b = BrexItem.create!(
+ family: @family_b,
+ name: "Family B Brex",
+ token: "token_b",
+ base_url: "https://api-staging.brex.com",
+ status: "good"
+ )
+ end
+
+ test "same account_id can be linked under different brex_items" do
+ BrexAccount.create!(
+ brex_item: @item_a,
+ account_id: "shared_brex_acc_1",
+ name: "Checking",
+ currency: "USD",
+ current_balance: 5000
+ )
+
+ # A second family connecting the same Brex account must succeed and produce
+ # an independent ledger (separate BrexAccount row, separate Account).
+ assert_difference "BrexAccount.count", 1 do
+ BrexAccount.create!(
+ brex_item: @item_b,
+ account_id: "shared_brex_acc_1",
+ name: "Checking",
+ currency: "USD",
+ current_balance: 5000
+ )
+ end
+ end
+
+ test "declares raw Brex payloads as encrypted" do
+ encrypted_attributes = BrexAccount.encrypted_attributes.map(&:to_s)
+
+ assert_includes encrypted_attributes, "raw_payload"
+ assert_includes encrypted_attributes, "raw_transactions_payload"
+ end
+
+ test "same account_id can be linked under different brex_items in the same family" do
+ item_a_2 = BrexItem.create!(
+ family: @family_a,
+ name: "Family A Second Brex",
+ token: "token_a_2",
+ base_url: "https://api-staging.brex.com",
+ status: "good"
+ )
+
+ BrexAccount.create!(
+ brex_item: @item_a,
+ account_id: "shared_brex_acc_1",
+ name: "Checking",
+ currency: "USD",
+ current_balance: 5000
+ )
+
+ assert_difference "BrexAccount.count", 1 do
+ BrexAccount.create!(
+ brex_item: item_a_2,
+ account_id: "shared_brex_acc_1",
+ name: "Checking",
+ currency: "USD",
+ current_balance: 5000
+ )
+ end
+ end
+
+ test "same account_id cannot appear twice under the same brex_item" do
+ BrexAccount.create!(
+ brex_item: @item_a,
+ account_id: "duplicate_acc",
+ name: "Checking",
+ currency: "USD",
+ current_balance: 1000
+ )
+
+ duplicate = BrexAccount.new(
+ brex_item: @item_a,
+ account_id: "duplicate_acc",
+ name: "Checking",
+ currency: "USD",
+ current_balance: 1000
+ )
+ refute duplicate.valid?
+ assert_includes duplicate.errors[:account_id], "has already been taken"
+
+ assert_raises(ActiveRecord::RecordInvalid) do
+ BrexAccount.create!(
+ brex_item: @item_a,
+ account_id: "duplicate_acc",
+ name: "Checking",
+ currency: "USD",
+ current_balance: 1000
+ )
+ end
+ end
+
+ test "minor-unit money converts to decimal account balances" do
+ brex_account = @item_a.brex_accounts.create!(
+ account_id: "cash_1",
+ name: "Operating",
+ currency: "USD",
+ account_kind: "cash"
+ )
+
+ brex_account.upsert_brex_snapshot!(
+ {
+ id: "cash_1",
+ name: "Operating",
+ account_kind: "cash",
+ current_balance: { amount: 123_456, currency: "USD" },
+ available_balance: { amount: 120_000, currency: "USD" }
+ }
+ )
+
+ assert_equal BigDecimal("1234.56"), brex_account.current_balance
+ assert_equal BigDecimal("1200.0"), brex_account.available_balance
+ end
+
+ test "invalid Brex money amount falls back to zero" do
+ assert_equal BigDecimal("0"), BrexAccount.money_to_decimal(amount: "not-a-number", currency: "USD")
+ end
+
+ test "snapshot sanitizes full account and routing numbers" do
+ brex_account = @item_a.brex_accounts.create!(
+ account_id: "cash_2",
+ name: "Operating",
+ currency: "USD",
+ account_kind: "cash"
+ )
+
+ brex_account.upsert_brex_snapshot!(
+ {
+ id: "cash_2",
+ name: "Operating",
+ account_kind: "cash",
+ current_balance: { amount: 100, currency: "USD" },
+ account_number: "account-last4-9012",
+ routing_number: "routing-last4-0021",
+ token: "test-token-placeholder"
+ }
+ )
+
+ payload = brex_account.raw_payload
+ refute_includes payload.values.compact.map(&:to_s).join(" "), "account-last4-9012"
+ refute_includes payload.values.compact.map(&:to_s).join(" "), "routing-last4-0021"
+ assert_equal "9012", payload["account_number_last4"]
+ assert_equal "0021", payload["routing_number_last4"]
+ assert_equal "[FILTERED]", payload["token"]
+ end
+
+ test "transaction payload sanitizer drops arbitrary card metadata" do
+ sanitized = BrexAccount.sanitize_payload(
+ {
+ id: "tx_1",
+ card_metadata: {
+ card_id: "card_1",
+ pan: "test-pan-placeholder",
+ private_note: "private",
+ last_four: "1111"
+ }
+ }
+ )
+
+ assert_equal({ "card_id" => "card_1", "last_four" => "1111" }, sanitized["card_metadata"])
+ refute_includes sanitized.to_s, "test-pan-placeholder"
+ refute_includes sanitized.to_s, "private"
+ end
+
+ test "linked_account uses the cached account association" do
+ brex_account = @item_a.brex_accounts.create!(
+ account_id: "cash_linked_alias",
+ name: "Linked Alias",
+ currency: "USD",
+ account_kind: "cash"
+ )
+ account = @family_a.accounts.create!(
+ name: "Linked Alias",
+ balance: 0,
+ currency: "USD",
+ accountable: Depository.new
+ )
+ AccountProvider.create!(account: account, provider: brex_account)
+
+ assert_equal brex_account.account, brex_account.linked_account
+ end
+end
diff --git a/test/models/brex_entry/processor_test.rb b/test/models/brex_entry/processor_test.rb
new file mode 100644
index 000000000..aa36765dc
--- /dev/null
+++ b/test/models/brex_entry/processor_test.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class BrexEntry::ProcessorTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @brex_item = brex_items(:one)
+ @account = @family.accounts.create!(
+ name: "Brex Card",
+ balance: 0,
+ currency: "USD",
+ accountable: CreditCard.new
+ )
+ @brex_account = @brex_item.brex_accounts.create!(
+ account_id: BrexAccount.card_account_id,
+ account_kind: "card",
+ name: "Brex Card",
+ currency: "USD",
+ current_balance: 0
+ )
+ AccountProvider.create!(account: @account, provider: @brex_account)
+ end
+
+ test "imports card purchase with Brex signed amount preserved" do
+ entry = BrexEntry::Processor.new(card_transaction(amount: 12_34), brex_account: @brex_account).process
+
+ assert_equal BigDecimal("12.34"), entry.amount
+ assert_equal "USD", entry.currency
+ assert_equal "brex", entry.source
+ assert_equal Date.new(2026, 1, 2), entry.date
+ assert_equal "STAPLES", entry.transaction.merchant.name
+ assert_equal "card_1", entry.transaction.extra.dig("brex", "card_id")
+ assert_equal "STAPLES", entry.transaction.extra.dig("brex", "merchant", "raw_descriptor")
+ refute_includes entry.transaction.extra.dig("brex", "merchant").to_s, "test-pan-placeholder"
+ refute_includes entry.transaction.extra.dig("brex", "merchant").to_s, "pan"
+ end
+
+ test "imports card payment as negative amount" do
+ entry = BrexEntry::Processor.new(card_transaction(id: "tx_payment", amount: -50_00, type: "COLLECTION"), brex_account: @brex_account).process
+
+ assert_equal BigDecimal("-50.0"), entry.amount
+ assert_equal "cc_payment", entry.transaction.kind
+ end
+
+ test "is idempotent by external id and source" do
+ transaction = card_transaction(id: "tx_duplicate", amount: 12_34)
+
+ assert_difference -> { @account.entries.where(source: "brex", external_id: "brex_tx_duplicate").count }, 1 do
+ BrexEntry::Processor.new(transaction, brex_account: @brex_account).process
+ BrexEntry::Processor.new(transaction, brex_account: @brex_account).process
+ end
+ end
+
+ test "tolerates nullable Brex fields and unknown types" do
+ transaction = {
+ id: "tx_nullable",
+ amount: nil,
+ description: "Cash movement",
+ posted_at_date: "2026-01-03",
+ initiated_at_date: "2026-01-02",
+ type: "NEW_BREX_TYPE"
+ }
+
+ entry = BrexEntry::Processor.new(transaction, brex_account: @brex_account).process
+
+ assert_equal BigDecimal("0"), entry.amount
+ assert_equal "Cash movement", entry.name
+ assert_equal "NEW_BREX_TYPE", entry.transaction.extra.dig("brex", "type")
+ end
+
+ test "uses localized default transaction name" do
+ transaction = card_transaction(id: "tx_default_name", amount: 12_34)
+ transaction.delete(:description)
+ transaction.delete(:merchant)
+
+ entry = BrexEntry::Processor.new(transaction, brex_account: @brex_account).process
+
+ assert_equal I18n.t("brex_items.entries.default_name"), entry.name
+ end
+
+ test "logs validation failure without re-reading missing external id" do
+ Rails.logger.expects(:error).with(regexp_matches(/Validation error for transaction brex_unknown/)).once
+
+ assert_raises(ArgumentError) do
+ BrexEntry::Processor.new(card_transaction(id: nil, amount: 12_34), brex_account: @brex_account).process
+ end
+ end
+
+ test "logs save failure with cached external id" do
+ Account::ProviderImportAdapter.any_instance
+ .expects(:import_transaction)
+ .raises(ActiveRecord::RecordInvalid.new(Entry.new))
+ Rails.logger.expects(:error).with(regexp_matches(/Failed to save transaction brex_tx_save_failure/)).once
+
+ assert_raises(StandardError) do
+ BrexEntry::Processor.new(card_transaction(id: "tx_save_failure", amount: 12_34), brex_account: @brex_account).process
+ end
+ end
+
+ test "logs missing transaction currency before using account fallback" do
+ Rails.logger.expects(:warn).with(regexp_matches(/Invalid Brex currency nil for transaction tx_missing_currency/)).once
+
+ entry = BrexEntry::Processor.new(
+ card_transaction(id: "tx_missing_currency", amount: 12_34).tap { |transaction| transaction[:amount].delete(:currency) },
+ brex_account: @brex_account
+ ).process
+
+ assert_equal "USD", entry.currency
+ end
+
+ private
+
+ def card_transaction(id: "tx_1", amount:, type: "CARD_EXPENSE")
+ {
+ id: id,
+ amount: { amount: amount, currency: "USD" },
+ description: "Office supplies",
+ posted_at_date: "2026-01-02",
+ initiated_at_date: "2026-01-01",
+ type: type,
+ card_id: "card_1",
+ merchant: {
+ raw_descriptor: "STAPLES",
+ card_metadata: {
+ pan: "test-pan-placeholder"
+ }
+ }
+ }
+ end
+end
diff --git a/test/models/brex_item/account_flow_test.rb b/test/models/brex_item/account_flow_test.rb
new file mode 100644
index 000000000..f8b37be8d
--- /dev/null
+++ b/test/models/brex_item/account_flow_test.rb
@@ -0,0 +1,320 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class BrexItem::AccountFlowTest < ActiveSupport::TestCase
+ setup do
+ SyncJob.stubs(:perform_later)
+ @family = families(:dylan_family)
+ @brex_item = brex_items(:one)
+ end
+
+ test "requires explicit item when multiple credentialed connections exist" do
+ BrexItem.create!(
+ family: @family,
+ name: "Second Brex",
+ token: "second_brex_token",
+ base_url: "https://api.brex.com"
+ )
+
+ flow = BrexItem::AccountFlow.new(family: @family)
+
+ assert_not flow.selected?
+ assert flow.selection_required?
+ end
+
+ test "preload payload returns explicit selection error when multiple connections exist" do
+ BrexItem.create!(
+ family: @family,
+ name: "Second Brex",
+ token: "second_brex_token",
+ base_url: "https://api.brex.com"
+ )
+
+ payload = BrexItem::AccountFlow.new(family: @family).preload_payload
+
+ assert_equal false, payload[:success]
+ assert_equal "select_connection", payload[:error]
+ assert_nil payload[:has_accounts]
+ end
+
+ test "preload payload treats cached empty accounts as a cache hit" do
+ cache_key = BrexItem::AccountFlow.cache_key(@family, @brex_item)
+ Rails.cache.expects(:read).with(cache_key).returns([])
+ Rails.cache.expects(:write).never
+ @brex_item.expects(:brex_provider).never
+
+ payload = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item).preload_payload
+
+ assert payload[:success]
+ assert_equal false, payload[:has_accounts]
+ assert_equal true, payload[:cached]
+ end
+
+ test "link result returns navigation instead of raising expected selection errors" do
+ BrexItem.create!(
+ family: @family,
+ name: "Second Brex",
+ token: "second_brex_token",
+ base_url: "https://api.brex.com"
+ )
+
+ result = BrexItem::AccountFlow.new(family: @family).link_new_accounts_result(
+ account_ids: [ "cash_import_1" ],
+ accountable_type: "Depository"
+ )
+
+ assert_equal :settings_providers, result.target
+ assert_equal :alert, result.flash_type
+ assert_equal I18n.t("brex_items.link_accounts.select_connection"), result.message
+ end
+
+ test "link new accounts rejects unsupported account type before creating accounts" do
+ flow = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item)
+ @brex_item.expects(:brex_provider).never
+
+ assert_no_difference [ "Account.count", "BrexAccount.count", "AccountProvider.count" ] do
+ result = flow.link_new_accounts_result(
+ account_ids: [ "cash_import_1" ],
+ accountable_type: "Investment"
+ )
+
+ assert_equal :new_account, result.target
+ assert_equal :alert, result.flash_type
+ assert_equal I18n.t("brex_items.link_accounts.invalid_account_type"), result.message
+ end
+ end
+
+ test "link new accounts converts unexpected errors into navigation alerts" do
+ flow = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item)
+ flow.expects(:link_new_accounts!).raises(StandardError, "link failure")
+
+ result = flow.link_new_accounts_result(
+ account_ids: [ "cash_import_1" ],
+ accountable_type: "Depository"
+ )
+
+ assert_equal :new_account, result.target
+ assert_equal :alert, result.flash_type
+ assert_equal I18n.t("brex_items.errors.unexpected_error"), result.message
+ end
+
+ test "link existing account converts unexpected errors into navigation alerts" do
+ account = @family.accounts.create!(
+ name: "Manual Checking",
+ balance: 0,
+ currency: "USD",
+ accountable: Depository.new
+ )
+ flow = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item)
+ flow.expects(:link_existing_account!).raises(StandardError, "link existing failure")
+
+ result = flow.link_existing_account_result(account: account, brex_account_id: "cash_import_1")
+
+ assert_equal :accounts, result.target
+ assert_equal :alert, result.flash_type
+ assert_equal I18n.t("brex_items.errors.unexpected_error"), result.message
+ end
+
+ test "imports provider accounts into the selected item" do
+ brex_item = BrexItem.create!(
+ family: @family,
+ name: "Import Brex",
+ token: "import_brex_token",
+ base_url: "https://api.brex.com"
+ )
+
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(
+ accounts: [
+ {
+ id: "cash_import_1",
+ name: "Imported Cash",
+ account_kind: "cash",
+ current_balance: { amount: 12_345, currency: "USD" },
+ account_number: "account-last4-3456"
+ }
+ ]
+ )
+ brex_item.expects(:brex_provider).returns(provider)
+
+ flow = BrexItem::AccountFlow.new(family: @family, brex_item: brex_item)
+
+ assert_difference -> { brex_item.brex_accounts.count }, 1 do
+ assert_nil flow.import_accounts_from_api_if_needed
+ end
+
+ brex_account = brex_item.brex_accounts.find_by!(account_id: "cash_import_1")
+ assert_equal "Imported Cash", brex_account.name
+ assert_equal "3456", brex_account.raw_payload["account_number_last4"]
+ refute_includes brex_account.raw_payload.to_s, "account-last4-3456"
+ end
+
+ test "refreshes existing provider accounts during setup discovery" do
+ brex_item = BrexItem.create!(
+ family: @family,
+ name: "Refresh Brex",
+ token: "refresh_brex_token",
+ base_url: "https://api.brex.com"
+ )
+ brex_item.brex_accounts.create!(
+ account_id: "cash_import_1",
+ name: "Old Cash",
+ currency: "USD",
+ account_kind: "cash",
+ current_balance: 1
+ )
+
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(
+ accounts: [
+ {
+ id: "cash_import_1",
+ name: "Updated Cash",
+ account_kind: "cash",
+ current_balance: { amount: 12_345, currency: "USD" }
+ }
+ ]
+ )
+ brex_item.expects(:brex_provider).returns(provider)
+
+ flow = BrexItem::AccountFlow.new(family: @family, brex_item: brex_item)
+
+ assert_no_difference -> { brex_item.brex_accounts.count } do
+ assert_nil flow.import_accounts_from_api_if_needed
+ end
+
+ brex_account = brex_item.brex_accounts.find_by!(account_id: "cash_import_1")
+ assert_equal "Updated Cash", brex_account.name
+ assert_equal BigDecimal("123.45"), brex_account.current_balance
+ end
+
+ test "complete setup creates account links with default subtype" do
+ brex_account = @brex_item.brex_accounts.create!(
+ account_id: "setup_cash_1",
+ account_kind: "cash",
+ name: "Setup Cash",
+ currency: "USD",
+ current_balance: 100
+ )
+
+ flow = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item)
+
+ assert_difference "AccountProvider.count", 1 do
+ result = flow.complete_setup!(
+ account_types: { brex_account.id => "Depository" },
+ account_subtypes: {}
+ )
+
+ assert_equal 1, result.created_count
+ assert_equal 0, result.skipped_count
+ end
+
+ account = brex_account.reload.account
+ assert_equal "Setup Cash", account.name
+ assert_equal Depository::DEFAULT_SUBTYPE, account.accountable.subtype
+ end
+
+ test "complete setup keeps prior accounts when one account creation fails" do
+ first_brex_account = @brex_item.brex_accounts.create!(
+ account_id: "setup_partial_1",
+ account_kind: "cash",
+ name: "Setup Partial One",
+ currency: "USD",
+ current_balance: 100
+ )
+ second_brex_account = @brex_item.brex_accounts.create!(
+ account_id: "setup_partial_2",
+ account_kind: "cash",
+ name: "Setup Partial Two",
+ currency: "USD",
+ current_balance: 100
+ )
+ second_brex_account.update_column(:name, nil)
+
+ result = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item).complete_setup!(
+ account_types: {
+ first_brex_account.id => "Depository",
+ second_brex_account.id => "Depository"
+ },
+ account_subtypes: {}
+ )
+
+ assert_equal 1, result.created_count
+ assert_equal 1, result.failed_count
+ assert first_brex_account.reload.account_provider.present?
+ assert_nil second_brex_account.reload.account_provider
+ end
+
+ test "link new accounts rolls back account creation when provider link fails" do
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(
+ accounts: [
+ {
+ id: "rollback_cash_1",
+ name: "Rollback Cash",
+ account_kind: "cash",
+ current_balance: { amount: 12_345, currency: "USD" }
+ }
+ ]
+ )
+ @brex_item.expects(:brex_provider).returns(provider)
+ AccountProvider.expects(:create!).raises(ActiveRecord::RecordInvalid.new(AccountProvider.new))
+
+ flow = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item)
+
+ assert_no_difference [ "Account.count", "BrexAccount.count", "AccountProvider.count" ] do
+ assert_raises(ActiveRecord::RecordInvalid) do
+ flow.link_new_accounts!(account_ids: [ "rollback_cash_1" ], accountable_type: "Depository")
+ end
+ end
+ end
+
+ test "link existing account rolls back provider account when link creation fails" do
+ account = @family.accounts.create!(
+ name: "Existing Cash",
+ balance: 0,
+ currency: "USD",
+ accountable: Depository.new
+ )
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(
+ accounts: [
+ {
+ id: "rollback_existing_cash_1",
+ name: "Rollback Existing Cash",
+ account_kind: "cash",
+ current_balance: { amount: 12_345, currency: "USD" }
+ }
+ ]
+ )
+ @brex_item.expects(:brex_provider).returns(provider)
+ AccountProvider.expects(:create!).raises(ActiveRecord::RecordInvalid.new(AccountProvider.new))
+
+ flow = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item)
+
+ assert_no_difference [ "BrexAccount.count", "AccountProvider.count" ] do
+ assert_raises(ActiveRecord::RecordInvalid) do
+ flow.link_existing_account!(account: account, brex_account_id: "rollback_existing_cash_1")
+ end
+ end
+ end
+
+ test "complete setup result returns localized notice" do
+ brex_account = @brex_item.brex_accounts.create!(
+ account_id: "setup_result_cash_1",
+ account_kind: "cash",
+ name: "Setup Result Cash",
+ currency: "USD",
+ current_balance: 100
+ )
+
+ result = BrexItem::AccountFlow.new(family: @family, brex_item: @brex_item).complete_setup_result(
+ account_types: { brex_account.id => "Depository" },
+ account_subtypes: {}
+ )
+
+ assert result.success?
+ assert_equal I18n.t("brex_items.complete_account_setup.success", count: 1), result.message
+ end
+end
diff --git a/test/models/brex_item/importer_test.rb b/test/models/brex_item/importer_test.rb
new file mode 100644
index 000000000..a22d6b5e8
--- /dev/null
+++ b/test/models/brex_item/importer_test.rb
@@ -0,0 +1,234 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class BrexItem::ImporterTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @brex_item = brex_items(:one)
+ @account = @family.accounts.create!(
+ name: "Operating Cash",
+ balance: 0,
+ currency: "USD",
+ accountable: Depository.new(subtype: "checking")
+ )
+ @brex_account = @brex_item.brex_accounts.create!(
+ account_id: "cash_1",
+ account_kind: "cash",
+ name: "Operating Cash",
+ currency: "USD",
+ current_balance: 0
+ )
+ AccountProvider.create!(account: @account, provider: @brex_account)
+ end
+
+ test "imports account discovery and fetches transactions only for linked accounts" do
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(accounts: [ cash_account_payload, card_account_payload ])
+ provider.expects(:get_cash_transactions).with("cash_1", start_date: Date.new(2026, 1, 1)).returns(
+ transactions: [
+ {
+ id: "cash_tx_1",
+ amount: { amount: 12_34, currency: "USD" },
+ description: "Wire fee",
+ posted_at_date: "2026-01-02"
+ }
+ ]
+ )
+ provider.expects(:get_primary_card_transactions).never
+
+ result = BrexItem::Importer.new(@brex_item, brex_provider: provider, sync_start_date: Date.new(2026, 1, 1)).import
+
+ assert result[:success]
+ assert_equal 1, result[:accounts_updated]
+ assert_equal 1, result[:accounts_created]
+ assert_equal [ "cash_tx_1" ], @brex_account.reload.raw_transactions_payload.map { |tx| tx["id"] }
+ assert_equal "card", @brex_item.brex_accounts.find_by!(account_id: BrexAccount.card_account_id).account_kind
+ end
+
+ test "counts only newly stored transactions as imported" do
+ @brex_account.update!(
+ raw_transactions_payload: [
+ {
+ id: "cash_tx_1",
+ amount: { amount: 12_34, currency: "USD" },
+ description: "Existing wire fee",
+ posted_at_date: "2026-01-02"
+ }
+ ]
+ )
+
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(accounts: [ cash_account_payload ])
+ provider.expects(:get_cash_transactions).with("cash_1", start_date: anything).returns(
+ transactions: [
+ {
+ id: "cash_tx_1",
+ amount: { amount: 12_34, currency: "USD" },
+ description: "Existing wire fee",
+ posted_at_date: "2026-01-02"
+ },
+ {
+ id: "cash_tx_2",
+ amount: { amount: 56_78, currency: "USD" },
+ description: "New wire fee",
+ posted_at_date: "2026-01-03"
+ }
+ ]
+ )
+
+ result = BrexItem::Importer.new(@brex_item, brex_provider: provider, sync_start_date: Date.new(2026, 1, 1)).import
+
+ assert result[:success]
+ assert_equal 1, result[:transactions_imported]
+ assert_equal [ "cash_tx_1", "cash_tx_2" ], @brex_account.reload.raw_transactions_payload.map { |tx| tx["id"] }
+ end
+
+ test "keeps raw transaction snapshots bounded to the sync window" do
+ @brex_account.update!(
+ raw_transactions_payload: [
+ {
+ id: "old_cash_tx",
+ amount: { amount: 12_34, currency: "USD" },
+ description: "Old wire fee",
+ posted_at_date: "2025-12-01"
+ },
+ {
+ id: "recent_cash_tx",
+ amount: { amount: 56_78, currency: "USD" },
+ description: "Recent wire fee",
+ posted_at_date: "2026-01-02"
+ }
+ ]
+ )
+
+ sync_start_date = Date.new(2026, 1, 1)
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(accounts: [ cash_account_payload ])
+ provider.expects(:get_cash_transactions).with("cash_1", start_date: sync_start_date).returns(
+ transactions: [
+ {
+ id: "ignored_before_window",
+ amount: { amount: 1_00, currency: "USD" },
+ description: "Ignored old transaction",
+ posted_at_date: "2025-12-31"
+ },
+ {
+ id: "new_cash_tx",
+ amount: { amount: 2_00, currency: "USD" },
+ description: "New transaction",
+ posted_at_date: "2026-01-03"
+ }
+ ]
+ )
+
+ result = BrexItem::Importer.new(@brex_item, brex_provider: provider, sync_start_date: sync_start_date).import
+
+ assert result[:success]
+ assert_equal 1, result[:transactions_imported]
+ assert_equal [ "recent_cash_tx", "new_cash_tx" ], @brex_account.reload.raw_transactions_payload.map { |tx| tx["id"] }
+ end
+
+ test "uses explicit sync start date for cash and card transaction fetches" do
+ card_account = @family.accounts.create!(
+ name: "Brex Card",
+ balance: 0,
+ currency: "USD",
+ accountable: CreditCard.new
+ )
+ brex_card_account = @brex_item.brex_accounts.create!(
+ account_id: BrexAccount.card_account_id,
+ account_kind: "card",
+ name: "Brex Card",
+ currency: "USD",
+ current_balance: 0
+ )
+ AccountProvider.create!(account: card_account, provider: brex_card_account)
+
+ sync_start_date = Date.new(2026, 2, 1)
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(accounts: [ cash_account_payload, card_account_payload ])
+ provider.expects(:get_cash_transactions).with("cash_1", start_date: sync_start_date).returns(transactions: [])
+ provider.expects(:get_primary_card_transactions).with(start_date: sync_start_date).returns(transactions: [])
+
+ result = BrexItem::Importer.new(
+ @brex_item,
+ brex_provider: provider,
+ sync_start_date: sync_start_date
+ ).import
+
+ assert result[:success]
+ end
+
+ test "raises and reports snapshot persistence failures" do
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(accounts: [ cash_account_payload ])
+ @brex_item.expects(:upsert_brex_snapshot!).raises(StandardError.new("snapshot failed"))
+
+ error = assert_raises StandardError do
+ BrexItem::Importer.new(@brex_item, brex_provider: provider).import
+ end
+
+ assert_equal "snapshot failed", error.message
+ end
+
+ test "marks item as requiring update on authorization errors" do
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).raises(
+ Provider::Brex::BrexError.new("Access forbidden", :access_forbidden, http_status: 403, trace_id: "trace_123")
+ )
+
+ result = BrexItem::Importer.new(@brex_item, brex_provider: provider).import
+
+ refute result[:success]
+ assert @brex_item.reload.requires_update?
+ end
+
+ test "clears requires update after a clean import" do
+ @brex_item.update!(status: :requires_update)
+
+ provider = mock("brex_provider")
+ provider.expects(:get_accounts).returns(accounts: [ cash_account_payload ])
+ provider.expects(:get_cash_transactions).with("cash_1", start_date: anything).returns(transactions: [])
+
+ result = BrexItem::Importer.new(@brex_item, brex_provider: provider).import
+
+ assert result[:success]
+ assert @brex_item.reload.good?
+ end
+
+ private
+
+ def cash_account_payload
+ {
+ id: "cash_1",
+ name: "Operating Cash",
+ account_kind: "cash",
+ status: "ACTIVE",
+ current_balance: { amount: 120_000, currency: "USD" },
+ available_balance: { amount: 110_000, currency: "USD" },
+ account_number: "account-last4-9012",
+ routing_number: "routing-last4-0021"
+ }
+ end
+
+ def card_account_payload
+ {
+ id: BrexAccount.card_account_id,
+ name: "Brex Card",
+ account_kind: "card",
+ status: "ACTIVE",
+ current_balance: { amount: 1_234, currency: "USD" },
+ available_balance: { amount: 100_000, currency: "USD" },
+ account_limit: { amount: 150_000, currency: "USD" },
+ raw_card_accounts: [
+ {
+ id: "card_account_1",
+ card_metadata: {
+ pan: "test-pan-placeholder"
+ }
+ }
+ ]
+ }
+ end
+end
diff --git a/test/models/brex_item/syncer_test.rb b/test/models/brex_item/syncer_test.rb
new file mode 100644
index 000000000..ce192c5a5
--- /dev/null
+++ b/test/models/brex_item/syncer_test.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class BrexItem::SyncerTest < ActiveSupport::TestCase
+ setup do
+ @brex_item = brex_items(:one)
+ @syncer = BrexItem::Syncer.new(@brex_item)
+ end
+
+ test "passes sync window start date to importer" do
+ window_start_date = Date.new(2026, 2, 1)
+ sync = mock_sync(window_start_date: window_start_date)
+
+ @brex_item.expects(:import_latest_brex_data).with(sync_start_date: window_start_date).once
+
+ @syncer.perform_sync(sync)
+ end
+
+ test "records localized setup status text and counts" do
+ window_start_date = Date.new(2026, 2, 1)
+ sync = recording_sync(window_start_date: window_start_date)
+
+ @brex_item.expects(:import_latest_brex_data).with(sync_start_date: window_start_date).once
+
+ @syncer.perform_sync(sync)
+
+ assert_equal [
+ I18n.t("brex_items.syncer.importing_accounts"),
+ I18n.t("brex_items.syncer.checking_account_configuration"),
+ I18n.t("brex_items.syncer.accounts_need_setup", count: 1)
+ ], sync.updates.filter_map { |attrs| attrs[:status_text] }
+
+ assert_equal 1, sync.sync_stats["total_accounts"]
+ assert_equal 0, sync.sync_stats["linked_accounts"]
+ assert_equal 1, sync.sync_stats["unlinked_accounts"]
+ end
+
+ test "records importer failure counts in health stats" do
+ sync = recording_sync(window_start_date: Date.new(2026, 2, 1))
+ @brex_item.expects(:import_latest_brex_data).returns(
+ success: false,
+ accounts_failed: 2,
+ transactions_failed: 1
+ )
+
+ @syncer.perform_sync(sync)
+
+ assert_equal 2, sync.sync_stats["total_errors"]
+ assert_equal [
+ I18n.t("brex_items.syncer.accounts_failed", count: 2),
+ I18n.t("brex_items.syncer.transactions_failed", count: 1)
+ ], sync.sync_stats["errors"].map { |error| error["message"] }
+ end
+
+ test "records account processing and scheduling failures in health stats" do
+ account = @brex_item.family.accounts.create!(
+ name: "Linked Brex Checking",
+ balance: 0,
+ currency: "USD",
+ accountable: Depository.new
+ )
+ brex_account = @brex_item.brex_accounts.first
+ AccountProvider.create!(account: account, provider: brex_account)
+
+ sync = recording_sync(window_start_date: Date.new(2026, 2, 1))
+ @brex_item.expects(:import_latest_brex_data).returns(
+ success: true,
+ accounts_failed: 0,
+ transactions_failed: 0
+ )
+ @brex_item.expects(:process_accounts).returns([
+ { brex_account_id: brex_account.id, success: false, error: "processing failure" }
+ ])
+ @brex_item.expects(:schedule_account_syncs).returns([
+ { account_id: account.id, success: false, error: "scheduling failure" }
+ ])
+
+ @syncer.perform_sync(sync)
+
+ assert_equal 2, sync.sync_stats["total_errors"]
+ assert_equal [
+ I18n.t("brex_items.syncer.account_processing_failed", count: 1),
+ I18n.t("brex_items.syncer.account_sync_failed", count: 1)
+ ], sync.sync_stats["errors"].map { |error| error["message"] }
+ end
+
+ test "raises user safe credential error for Brex auth failures" do
+ sync = mock_sync(window_start_date: Date.new(2026, 2, 1))
+ @brex_item.expects(:import_latest_brex_data)
+ .raises(Provider::Brex::BrexError.new("raw upstream auth body", :unauthorized, http_status: 401))
+ Sentry.expects(:capture_exception)
+
+ error = assert_raises(BrexItem::Syncer::SafeSyncError) do
+ @syncer.perform_sync(sync)
+ end
+
+ assert_equal I18n.t("brex_items.syncer.credentials_invalid"), error.message
+ end
+
+ private
+
+ def mock_sync(window_start_date:)
+ sync = mock("sync")
+ sync.stubs(:respond_to?).with(:status_text).returns(true)
+ sync.stubs(:respond_to?).with(:sync_stats).returns(true)
+ sync.stubs(:sync_stats).returns({})
+ sync.stubs(:window_start_date).returns(window_start_date)
+ sync.stubs(:window_end_date).returns(nil)
+ sync.stubs(:update!)
+ sync
+ end
+
+ def recording_sync(window_start_date:)
+ Class.new do
+ attr_accessor :sync_stats, :status_text
+ attr_reader :updates
+
+ define_method(:initialize) do |start_date|
+ @window_start_date = start_date
+ @window_end_date = nil
+ @created_at = Time.current
+ @sync_stats = {}
+ @updates = []
+ end
+
+ attr_reader :window_start_date, :window_end_date, :created_at
+
+ def update!(attributes)
+ @updates << attributes
+ self.sync_stats = attributes[:sync_stats] if attributes.key?(:sync_stats)
+ self.status_text = attributes[:status_text] if attributes.key?(:status_text)
+ end
+ end.new(window_start_date)
+ end
+end
diff --git a/test/models/brex_item_test.rb b/test/models/brex_item_test.rb
new file mode 100644
index 000000000..736a3d09b
--- /dev/null
+++ b/test/models/brex_item_test.rb
@@ -0,0 +1,167 @@
+require "test_helper"
+
+class BrexItemTest < ActiveSupport::TestCase
+ def setup
+ @brex_item = brex_items(:one)
+ end
+
+ test "fixture is valid" do
+ assert @brex_item.valid?
+ end
+
+ test "belongs to family" do
+ assert_equal families(:dylan_family), @brex_item.family
+ end
+
+ test "credentials_configured returns true when token present" do
+ assert @brex_item.credentials_configured?
+ end
+
+ test "credentials_configured returns false when token blank" do
+ @brex_item.token = nil
+ assert_not @brex_item.credentials_configured?
+ end
+
+ test "credentials_configured returns false when token is whitespace" do
+ @brex_item.token = " "
+ assert_not @brex_item.credentials_configured?
+ end
+
+ test "effective_base_url returns custom url when set" do
+ assert_equal "https://api-staging.brex.com", @brex_item.effective_base_url
+ end
+
+ test "effective_base_url returns default when base_url blank" do
+ @brex_item.base_url = nil
+ assert_equal "https://api.brex.com", @brex_item.effective_base_url
+ end
+
+ test "base_url accepts official Brex API roots" do
+ assert BrexItem.new(family: families(:empty), name: "Production", token: "token", base_url: "https://api.brex.com").valid?
+ assert BrexItem.new(family: families(:empty), name: "Staging", token: "token", base_url: "https://api-staging.brex.com").valid?
+ end
+
+ test "base_url normalizes official URL case and trailing slash" do
+ item = BrexItem.create!(
+ family: families(:empty),
+ name: "Normalized Brex",
+ token: "token",
+ base_url: " HTTPS://API.BREX.COM/ "
+ )
+
+ assert_equal "https://api.brex.com", item.base_url
+ end
+
+ test "token is stripped before validation and save" do
+ item = BrexItem.create!(
+ family: families(:empty),
+ name: "Token Normalized Brex",
+ token: " normalized_token ",
+ base_url: "https://api.brex.com"
+ )
+
+ assert_equal "normalized_token", item.token
+ end
+
+ test "token cannot be blanked on update" do
+ original_token = @brex_item.token
+
+ assert_raises(ActiveRecord::RecordInvalid) do
+ @brex_item.update!(token: " ")
+ end
+
+ assert_equal original_token, @brex_item.reload.token
+ assert_includes @brex_item.errors[:token], "can't be blank"
+ end
+
+ test "base_url rejects non-Brex hosts and endpoint paths" do
+ [
+ "http://api.brex.com",
+ "https://evil.example.test",
+ "https://localhost",
+ "https://127.0.0.1",
+ "https://10.0.0.1",
+ "https://api.brex.com.evil.example",
+ "https://api.brex.com@127.0.0.1",
+ "https://api.brex.com:444",
+ "https://api.brex.com/v2",
+ "https://api.brex.com?debug=true",
+ "//api.brex.com"
+ ].each do |base_url|
+ item = BrexItem.new(family: families(:empty), name: "Invalid Brex", token: "token", base_url: base_url)
+
+ refute item.valid?, "Expected #{base_url.inspect} to be invalid"
+ assert_includes item.errors[:base_url], I18n.t("activerecord.errors.models.brex_item.attributes.base_url.official_hosts_only")
+ end
+ end
+
+ test "brex_provider returns Provider::Brex instance" do
+ provider = @brex_item.brex_provider
+ assert_instance_of Provider::Brex, provider
+ assert_equal @brex_item.token, provider.token
+ end
+
+ test "declares Brex token and raw payload as encrypted" do
+ assert_includes BrexItem.encrypted_attributes.map(&:to_s), "token"
+ assert_includes BrexItem.encrypted_attributes.map(&:to_s), "raw_payload"
+ end
+
+ test "schema requires name and token" do
+ columns = BrexItem.columns.index_by(&:name)
+
+ assert_equal false, columns["name"].null
+ assert_equal false, columns["token"].null
+ end
+
+ test "brex_provider returns nil when credentials not configured" do
+ @brex_item.token = nil
+ assert_nil @brex_item.brex_provider
+ end
+
+ test "brex_provider returns nil when persisted base_url is not allowed" do
+ @brex_item.update_column(:base_url, "https://evil.example.test")
+
+ assert_nil @brex_item.reload.brex_provider
+ end
+
+ test "family credential check ignores blank and scheduled for deletion items" do
+ family = families(:empty)
+ blank_item = BrexItem.create!(
+ family: family,
+ name: "Blank Brex",
+ token: "temporary_token",
+ base_url: "https://api-staging.brex.com"
+ )
+ blank_item.update_column(:token, "")
+
+ whitespace_item = BrexItem.create!(
+ family: family,
+ name: "Whitespace Brex",
+ token: "temporary_token",
+ base_url: "https://api-staging.brex.com"
+ )
+ whitespace_item.update_column(:token, " ")
+
+ deleted_item = BrexItem.create!(
+ family: family,
+ name: "Deleted Brex",
+ token: "deleted_token",
+ base_url: "https://api-staging.brex.com",
+ scheduled_for_deletion: true
+ )
+
+ refute family.has_brex_credentials?
+
+ whitespace_item.update_column(:token, "configured_token")
+ assert family.has_brex_credentials?
+
+ whitespace_item.update_column(:token, " ")
+ deleted_item.update!(scheduled_for_deletion: false)
+ assert family.has_brex_credentials?
+ end
+
+ test "syncer returns BrexItem::Syncer instance" do
+ syncer = @brex_item.send(:syncer)
+ assert_instance_of BrexItem::Syncer, syncer
+ end
+end
diff --git a/test/models/family/syncer_test.rb b/test/models/family/syncer_test.rb
index baada992e..be9624109 100644
--- a/test/models/family/syncer_test.rb
+++ b/test/models/family/syncer_test.rb
@@ -5,11 +5,13 @@ 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
+ brex_items_count = @family.brex_items.syncable.count
+ binance_items_count = @family.binance_items.syncable.count
syncer = Family::Syncer.new(@family)
@@ -19,9 +21,19 @@ 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)
+
+ BrexItem.any_instance
+ .expects(:sync_later)
+ .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil)
+ .times(brex_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 +73,8 @@ class Family::SyncerTest < ActiveSupport::TestCase
LunchflowItem.any_instance.stubs(:sync_later)
EnableBankingItem.any_instance.stubs(:sync_later)
SophtronItem.any_instance.stubs(:sync_later)
+ BrexItem.any_instance.stubs(:sync_later)
+ BinanceItem.any_instance.stubs(:sync_later)
syncer.perform_sync(family_sync)
syncer.perform_post_sync
diff --git a/test/models/provider/brex_adapter_test.rb b/test/models/provider/brex_adapter_test.rb
new file mode 100644
index 000000000..fa4adf03c
--- /dev/null
+++ b/test/models/provider/brex_adapter_test.rb
@@ -0,0 +1,208 @@
+require "uri"
+
+require "test_helper"
+
+class Provider::BrexAdapterTest < ActiveSupport::TestCase
+ test "supports Depository accounts" do
+ assert_includes Provider::BrexAdapter.supported_account_types, "Depository"
+ end
+
+ test "supports CreditCard accounts" do
+ assert_includes Provider::BrexAdapter.supported_account_types, "CreditCard"
+ end
+
+ test "does not support Investment accounts" do
+ assert_not_includes Provider::BrexAdapter.supported_account_types, "Investment"
+ end
+
+ test "returns fallback connection config when no credentials exist yet" do
+ # Brex is a per-family provider - any family can connect
+ family = families(:empty)
+ configs = Provider::BrexAdapter.connection_configs(family: family)
+
+ assert_equal 1, configs.length
+ assert_equal "brex", configs.first[:key]
+ assert_equal I18n.t("brex_items.provider_connection.default_name"), configs.first[:name]
+ assert configs.first[:can_connect]
+ end
+
+ test "returns one connection config per credentialed brex item" do
+ family = families(:dylan_family)
+ first_item = brex_items(:one)
+ second_item = BrexItem.create!(
+ family: family,
+ name: "Business Brex",
+ token: "second_brex_token",
+ base_url: "https://api.brex.com"
+ )
+
+ configs = Provider::BrexAdapter.connection_configs(family: family)
+
+ assert_equal 2, configs.length
+ assert_equal [ "brex_#{second_item.id}", "brex_#{first_item.id}" ], configs.map { |config| config[:key] }
+ assert_equal [
+ I18n.t("brex_items.provider_connection.name", name: second_item.name),
+ I18n.t("brex_items.provider_connection.name", name: first_item.name)
+ ], configs.map { |config| config[:name] }
+
+ new_account_uri = URI.parse(configs.first[:new_account_path].call("Depository", "/accounts"))
+ assert_equal "/brex_items/select_accounts", new_account_uri.path
+ assert_includes new_account_uri.query, "brex_item_id=#{second_item.id}"
+
+ existing_account_uri = URI.parse(configs.first[:existing_account_path].call(accounts(:depository).id))
+ assert_equal "/brex_items/select_existing_account", existing_account_uri.path
+ assert_includes existing_account_uri.query, "brex_item_id=#{second_item.id}"
+ end
+
+ test "connection configs ignore items with whitespace-only tokens" do
+ family = families(:dylan_family)
+ BrexItem.create!(
+ family: family,
+ name: "Blank Brex",
+ token: "temporary_token",
+ base_url: "https://api.brex.com"
+ ).update_column(:token, " ")
+
+ configs = Provider::BrexAdapter.connection_configs(family: family)
+
+ assert_equal [ "brex_#{brex_items(:one).id}" ], configs.map { |config| config[:key] }
+ end
+
+ test "build_provider returns nil when family is nil" do
+ assert_nil Provider::BrexAdapter.build_provider(family: nil)
+ end
+
+ test "build_provider returns nil when family has no brex items" do
+ family = families(:empty)
+ assert_nil Provider::BrexAdapter.build_provider(family: family)
+ end
+
+ test "build_provider returns Brex provider when credentials configured" do
+ family = families(:dylan_family)
+ provider = Provider::BrexAdapter.build_provider(family: family)
+
+ assert_instance_of Provider::Brex, provider
+ end
+
+ test "build_provider uses explicit brex item credentials" do
+ family = families(:dylan_family)
+ second_item = BrexItem.create!(
+ family: family,
+ name: "Business Brex",
+ token: "second_brex_token",
+ base_url: "https://api.brex.com"
+ )
+
+ provider = Provider::BrexAdapter.build_provider(family: family, brex_item_id: second_item.id)
+
+ assert_instance_of Provider::Brex, provider
+ assert_equal "second_brex_token", provider.token
+ assert_equal "https://api.brex.com", provider.base_url
+ end
+
+ test "build_provider does not pick the first connection when multiple credentials exist" do
+ family = families(:dylan_family)
+ BrexItem.create!(
+ family: family,
+ name: "Business Brex",
+ token: "second_brex_token",
+ base_url: "https://api.brex.com"
+ )
+
+ assert_nil Provider::BrexAdapter.build_provider(family: family)
+ end
+
+ test "build_provider strips surrounding token whitespace" do
+ family = families(:dylan_family)
+ second_item = BrexItem.create!(
+ family: family,
+ name: "Business Brex",
+ token: " second_brex_token \n",
+ base_url: "https://api.brex.com"
+ )
+
+ provider = Provider::BrexAdapter.build_provider(family: family, brex_item_id: second_item.id)
+
+ assert_equal "second_brex_token", provider.token
+ end
+
+ test "build_provider refuses brex items outside the family" do
+ family = families(:dylan_family)
+ other_item = BrexItem.create!(
+ family: families(:empty),
+ name: "Other Brex",
+ token: "other_brex_token",
+ base_url: "https://api.brex.com"
+ )
+
+ assert_nil Provider::BrexAdapter.build_provider(family: family, brex_item_id: other_item.id)
+ end
+
+ test "build_provider refuses explicit brex item without usable credentials" do
+ family = families(:dylan_family)
+ blank_item = BrexItem.create!(
+ family: family,
+ name: "Blank Brex",
+ token: "temporary_token",
+ base_url: "https://api.brex.com"
+ )
+ blank_item.update_column(:token, " ")
+
+ assert_nil Provider::BrexAdapter.build_provider(family: family, brex_item_id: blank_item.id)
+ end
+
+ test "build_provider refuses explicit brex item with invalid persisted base_url" do
+ family = families(:dylan_family)
+ item = BrexItem.create!(
+ family: family,
+ name: "Invalid URL Brex",
+ token: "token",
+ base_url: "https://api.brex.com"
+ )
+ item.update_column(:base_url, "https://evil.example.test")
+
+ assert_nil Provider::BrexAdapter.build_provider(family: family, brex_item_id: item.id)
+ end
+
+ test "reads institution metadata from brex account column" do
+ brex_account = brex_items(:one).brex_accounts.create!(
+ account_id: "metadata_cash",
+ account_kind: "cash",
+ name: "Metadata Cash",
+ currency: "USD",
+ institution_metadata: {
+ "name" => "Brex",
+ "domain" => "brex.com",
+ "url" => "https://brex.com"
+ }
+ )
+
+ adapter = Provider::BrexAdapter.new(brex_account)
+
+ assert_equal "brex.com", brex_account.institution_metadata["domain"]
+ assert_equal "brex.com", adapter.institution_domain
+ assert_equal "Brex", adapter.institution_name
+ assert_equal "https://brex.com", adapter.institution_url
+ end
+
+ test "falls back to brex item institution metadata" do
+ brex_item = brex_items(:one)
+ brex_item.update!(
+ institution_name: "Brex Item Name",
+ institution_url: "https://brex.com/item",
+ institution_color: "#123456"
+ )
+ brex_account = brex_item.brex_accounts.create!(
+ account_id: "metadata_fallback_cash",
+ account_kind: "cash",
+ name: "Metadata Fallback Cash",
+ currency: "USD"
+ )
+
+ adapter = Provider::BrexAdapter.new(brex_account)
+
+ assert_equal "Brex Item Name", adapter.institution_name
+ assert_equal "https://brex.com/item", adapter.institution_url
+ assert_equal "#123456", adapter.institution_color
+ end
+end
diff --git a/test/models/provider/brex_test.rb b/test/models/provider/brex_test.rb
new file mode 100644
index 000000000..f84185bff
--- /dev/null
+++ b/test/models/provider/brex_test.rb
@@ -0,0 +1,289 @@
+require "test_helper"
+
+class Provider::BrexTest < ActiveSupport::TestCase
+ def setup
+ @provider = Provider::Brex.new("test_token", base_url: "https://api-staging.brex.com")
+ end
+
+ test "initializes with token and default base_url" do
+ provider = Provider::Brex.new("my_token")
+ assert_equal "my_token", provider.token
+ assert_equal "https://api.brex.com", provider.base_url
+ end
+
+ test "initializes with custom base_url" do
+ assert_equal "test_token", @provider.token
+ assert_equal "https://api-staging.brex.com", @provider.base_url
+ end
+
+ test "initializes with stripped token and removes trailing base url slash" do
+ provider = Provider::Brex.new(" test_token \n", base_url: "https://api.brex.com/")
+
+ assert_equal "test_token", provider.token
+ assert_equal "https://api.brex.com", provider.base_url
+ end
+
+ test "initializes with official staging base url" do
+ provider = Provider::Brex.new("test_token", base_url: "https://api-staging.brex.com/")
+
+ assert_equal "https://api-staging.brex.com", provider.base_url
+ end
+
+ test "rejects arbitrary base urls" do
+ [
+ "http://api.brex.com",
+ "https://evil.example.test",
+ "https://localhost",
+ "https://127.0.0.1",
+ "https://10.0.0.1",
+ "https://api.brex.com.evil.example",
+ "https://api.brex.com@127.0.0.1",
+ "https://api.brex.com:444",
+ "https://api.brex.com/v1",
+ "https://api.brex.com?host=evil.example.test",
+ "//api.brex.com"
+ ].each do |base_url|
+ assert_raises ArgumentError do
+ Provider::Brex.new("test_token", base_url: base_url)
+ end
+ end
+ end
+
+ test "BrexError includes error_type" do
+ error = Provider::Brex::BrexError.new("Test error", :unauthorized)
+ assert_equal "Test error", error.message
+ assert_equal :unauthorized, error.error_type
+ end
+
+ test "BrexError defaults error_type to unknown" do
+ error = Provider::Brex::BrexError.new("Test error")
+ assert_equal :unknown, error.error_type
+ end
+
+ test "fetches cash accounts from the v2 endpoint with bearer auth" do
+ response = OpenStruct.new(
+ code: 200,
+ body: { items: [ { id: "cash_1", name: "Operating" } ] }.to_json,
+ headers: {}
+ )
+
+ Provider::Brex.expects(:get)
+ .with(
+ "https://api.brex.com/v2/accounts/cash?limit=1000",
+ headers: {
+ "Authorization" => "Bearer test_token",
+ "Content-Type" => "application/json",
+ "Accept" => "application/json"
+ }
+ )
+ .returns(response)
+
+ accounts = Provider::Brex.new(" test_token ").get_cash_accounts
+
+ assert_equal 1, accounts.length
+ assert_equal "cash_1", accounts.first[:id]
+ assert_equal "cash", accounts.first[:account_kind]
+ end
+
+ test "fetches card accounts from the paginated v2 endpoint" do
+ response = OpenStruct.new(
+ code: 200,
+ body: [ { id: "card_account_1", status: "ACTIVE" } ].to_json,
+ headers: {}
+ )
+
+ Provider::Brex.expects(:get)
+ .with(
+ "https://api.brex.com/v2/accounts/card?limit=1000",
+ headers: {
+ "Authorization" => "Bearer test_token",
+ "Content-Type" => "application/json",
+ "Accept" => "application/json"
+ }
+ )
+ .returns(response)
+
+ accounts = Provider::Brex.new("test_token").get_card_accounts
+
+ assert_equal 1, accounts.length
+ assert_equal "card_account_1", accounts.first[:id]
+ assert_equal "card", accounts.first[:account_kind]
+ end
+
+ test "aggregates card accounts into one provider account" do
+ cash_response = OpenStruct.new(
+ code: 200,
+ body: { items: [] }.to_json,
+ headers: {}
+ )
+ card_response = OpenStruct.new(
+ code: 200,
+ body: {
+ items: [
+ {
+ id: "card_account_1",
+ status: "ACTIVE",
+ current_balance: { amount: 12_345, currency: "USD" },
+ available_balance: { amount: 100_000, currency: "USD" },
+ account_limit: { amount: 250_000, currency: "USD" }
+ }
+ ]
+ }.to_json,
+ headers: {}
+ )
+
+ Provider::Brex.stubs(:get).returns(cash_response, card_response)
+
+ accounts_data = Provider::Brex.new("test_token").get_accounts
+
+ assert_equal [ "card_primary" ], accounts_data[:accounts].map { |account| account[:id] }
+ assert_equal "card", accounts_data[:accounts].first[:account_kind]
+ assert_equal 1, accounts_data[:accounts].first[:card_accounts_count]
+ end
+
+ test "does not aggregate mixed currency card balances" do
+ cash_response = OpenStruct.new(
+ code: 200,
+ body: { items: [] }.to_json,
+ headers: {}
+ )
+ card_response = OpenStruct.new(
+ code: 200,
+ body: [
+ {
+ id: "card_account_1",
+ current_balance: { amount: 12_345, currency: "USD" }
+ },
+ {
+ id: "card_account_2",
+ current_balance: { amount: 6_789, currency: "EUR" }
+ }
+ ].to_json,
+ headers: {}
+ )
+
+ Provider::Brex.stubs(:get).returns(cash_response, card_response)
+
+ accounts_data = Provider::Brex.new("test_token").get_accounts
+
+ assert_nil accounts_data[:accounts].first[:current_balance]
+ end
+
+ test "guards repeated pagination cursors" do
+ first_response = OpenStruct.new(
+ code: 200,
+ body: { items: [ { id: "tx_1" } ], next_cursor: "cursor_1" }.to_json,
+ headers: {}
+ )
+ second_response = OpenStruct.new(
+ code: 200,
+ body: { items: [ { id: "tx_2" } ], next_cursor: "cursor_1" }.to_json,
+ headers: {}
+ )
+
+ Provider::Brex.stubs(:get).returns(first_response, second_response)
+
+ error = assert_raises Provider::Brex::BrexError do
+ Provider::Brex.new("test_token").get_primary_card_transactions
+ end
+
+ assert_equal :pagination_error, error.error_type
+ end
+
+ test "guards pagination page cap" do
+ responses = (1..26).map do |page|
+ OpenStruct.new(
+ code: 200,
+ body: { items: [ { id: "tx_#{page}" } ], next_cursor: "cursor_#{page}" }.to_json,
+ headers: {}
+ )
+ end
+
+ Provider::Brex.stubs(:get).returns(*responses)
+
+ error = assert_raises Provider::Brex::BrexError do
+ Provider::Brex.new("test_token").get_primary_card_transactions
+ end
+
+ assert_equal :pagination_error, error.error_type
+ assert_includes error.message, "exceeded 25 pages"
+ end
+
+ test "sends posted_at_start as RFC3339 date time" do
+ response = OpenStruct.new(
+ code: 200,
+ body: { items: [] }.to_json,
+ headers: {}
+ )
+
+ Provider::Brex.expects(:get)
+ .with(
+ "https://api.brex.com/v2/transactions/card/primary?posted_at_start=2026-01-02T00%3A00%3A00Z&limit=1000",
+ headers: {
+ "Authorization" => "Bearer test_token",
+ "Content-Type" => "application/json",
+ "Accept" => "application/json"
+ }
+ )
+ .returns(response)
+
+ Provider::Brex.new("test_token").get_primary_card_transactions(start_date: Date.new(2026, 1, 2))
+ end
+
+ test "raises clear error for invalid start date" do
+ error = assert_raises ArgumentError do
+ Provider::Brex.new("test_token").get_primary_card_transactions(start_date: "not-a-date")
+ end
+
+ assert_includes error.message, "Invalid start_date"
+ end
+
+ test "maps rate limits and exposes trace id without leaking body" do
+ response = OpenStruct.new(
+ code: 429,
+ body: { message: "secret raw provider body" }.to_json,
+ headers: { "x-brex-trace-id" => "trace_123" }
+ )
+
+ Provider::Brex.stubs(:get).returns(response)
+
+ error = assert_raises Provider::Brex::BrexError do
+ Provider::Brex.new("test_token").get_cash_accounts
+ end
+
+ assert_equal :rate_limited, error.error_type
+ assert_equal 429, error.http_status
+ assert_equal "trace_123", error.trace_id
+ refute_includes error.message, "secret raw provider body"
+ end
+
+ test "maps non-success responses without exposing provider body" do
+ expectations = {
+ 400 => [ :bad_request, "Bad request to Brex API" ],
+ 401 => [ :unauthorized, "Invalid Brex API token or account permissions" ],
+ 403 => [ :access_forbidden, "Access forbidden - check Brex API token scopes" ],
+ 404 => [ :not_found, "Brex resource not found" ],
+ 500 => [ :fetch_failed, "Failed to fetch data from Brex API: HTTP 500" ]
+ }
+
+ expectations.each do |status, (error_type, message)|
+ response = OpenStruct.new(
+ code: status,
+ body: { message: "secret provider body #{status}" }.to_json,
+ headers: { "X-Brex-Trace-Id" => "trace_#{status}" }
+ )
+
+ Provider::Brex.stubs(:get).returns(response)
+
+ error = assert_raises Provider::Brex::BrexError do
+ Provider::Brex.new("test_token").get_cash_accounts
+ end
+
+ assert_equal error_type, error.error_type
+ assert_equal status, error.http_status
+ assert_equal "trace_#{status}", error.trace_id
+ assert_equal message, error.message
+ refute_includes error.message, "secret provider body"
+ end
+ end
+end
diff --git a/test/models/sophtron_item/importer_test.rb b/test/models/sophtron_item/importer_test.rb
index 6fe106777..85449c343 100644
--- a/test/models/sophtron_item/importer_test.rb
+++ b/test/models/sophtron_item/importer_test.rb
@@ -104,6 +104,40 @@ class SophtronItem::ImporterTest < ActiveSupport::TestCase
assert_equal 1, sophtron_account.reload.raw_transactions_payload.count
end
+ test "automatic import skips linked accounts that require manual sync" do
+ account = accounts(:depository)
+ sophtron_account = @item.sophtron_accounts.create!(
+ account_id: "acct-1",
+ name: "Checking",
+ currency: "USD",
+ balance: 100,
+ manual_sync: true
+ )
+ AccountProvider.create!(account: account, provider: sophtron_account)
+
+ provider = mock
+ provider.expects(:get_accounts).with("ui-1").returns({
+ accounts: [
+ {
+ account_id: "acct-1",
+ account_name: "Checking",
+ balance: "100.00",
+ balance_currency: "USD",
+ currency: "USD"
+ }.with_indifferent_access
+ ],
+ total: 1
+ })
+ provider.expects(:refresh_account).never
+ provider.expects(:get_account_transactions).never
+
+ result = SophtronItem::Importer.new(@item, sophtron_provider: provider).import
+
+ assert result[:success]
+ assert_equal 0, result[:transactions_imported]
+ assert_nil sophtron_account.reload.raw_transactions_payload
+ end
+
test "later sync refreshes account after an empty initial transaction fetch" do
account = accounts(:depository)
sophtron_account = @item.sophtron_accounts.create!(
diff --git a/test/models/sophtron_item_test.rb b/test/models/sophtron_item_test.rb
index 7be757e6d..c78285698 100644
--- a/test/models/sophtron_item_test.rb
+++ b/test/models/sophtron_item_test.rb
@@ -162,4 +162,57 @@ class SophtronItemTest < ActiveSupport::TestCase
end
end
end
+ test "manual Sophtron accounts do not remove the whole item from automatic sync scope" do
+ manual_item = @family.sophtron_items.create!(
+ name: "Manual Sophtron",
+ user_id: "manual-user",
+ access_key: Base64.strict_encode64("secret-key")
+ )
+ manual_account = manual_item.sophtron_accounts.create!(
+ account_id: "acct-manual",
+ name: "Manual Sophtron Checking",
+ currency: "USD",
+ balance: 100,
+ manual_sync: true
+ )
+ auto_account = manual_item.sophtron_accounts.create!(
+ account_id: "acct-auto",
+ name: "Automatic Sophtron Checking",
+ currency: "USD",
+ balance: 100
+ )
+ AccountProvider.create!(account: accounts(:depository), provider: manual_account)
+ AccountProvider.create!(account: accounts(:credit_card), provider: auto_account)
+
+ assert_includes SophtronItem.active, manual_item
+ assert_includes SophtronItem.syncable, manual_item
+ assert_equal [ auto_account ], manual_item.automatic_sync_sophtron_accounts.to_a
+ assert_equal [ manual_account ], manual_item.manual_sync_sophtron_accounts.to_a
+ end
+
+ test "whole item manual mode removes linked accounts from automatic sync scope" do
+ manual_item = @family.sophtron_items.create!(
+ name: "Manual Sophtron",
+ user_id: "manual-user",
+ access_key: Base64.strict_encode64("secret-key"),
+ manual_sync: true
+ )
+ first_account = manual_item.sophtron_accounts.create!(
+ account_id: "acct-1",
+ name: "Manual Sophtron Checking",
+ currency: "USD",
+ balance: 100
+ )
+ second_account = manual_item.sophtron_accounts.create!(
+ account_id: "acct-2",
+ name: "Manual Sophtron Card",
+ currency: "USD",
+ balance: 200
+ )
+ AccountProvider.create!(account: accounts(:depository), provider: first_account)
+ AccountProvider.create!(account: accounts(:credit_card), provider: second_account)
+
+ assert_empty manual_item.automatic_sync_sophtron_accounts
+ assert_equal [ first_account, second_account ], manual_item.manual_sync_sophtron_accounts.to_a
+ end
end
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