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 @@
- <%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %> + <%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0 mt-0.5" %> -
- <%= message %> +
+ <% if title.present? %> +

<%= title %>

+ <% end %> + + <% if content.present? %> + <%= content %> + <% elsif message.present? %> + <%= message %> + <% end %>
diff --git a/app/components/DS/alert.rb b/app/components/DS/alert.rb index 22241133f..dc7025eb7 100644 --- a/app/components/DS/alert.rb +++ b/app/components/DS/alert.rb @@ -1,24 +1,32 @@ class DS::Alert < DesignSystemComponent - def initialize(message:, variant: :info) + VARIANTS = %i[info success warning error destructive].freeze + + def initialize(message: nil, title: nil, variant: :info) @message = message - @variant = variant + @title = title + @variant = normalize_variant(variant) end private - attr_reader :message, :variant + attr_reader :message, :title, :variant + + def normalize_variant(raw) + sym = raw.respond_to?(:to_sym) ? raw.to_sym : nil + VARIANTS.include?(sym) ? sym : :info + end def container_classes base_classes = "flex items-start gap-3 p-4 rounded-lg border" variant_classes = case variant when :info - "bg-blue-50 text-blue-700 border-blue-200 theme-dark:bg-blue-900/20 theme-dark:text-blue-400 theme-dark:border-blue-800" + "bg-info/10 border-info/20" when :success - "bg-green-50 text-green-700 border-green-200 theme-dark:bg-green-900/20 theme-dark:text-green-400 theme-dark:border-green-800" + "bg-success/10 border-success/20" when :warning - "bg-yellow-50 text-yellow-700 border-yellow-200 theme-dark:bg-yellow-900/20 theme-dark:text-yellow-400 theme-dark:border-yellow-800" + "bg-warning/10 border-warning/20" when :error, :destructive - "bg-red-50 text-red-700 border-red-200 theme-dark:bg-red-900/20 theme-dark:text-red-400 theme-dark:border-red-800" + "bg-destructive/10 border-destructive/20" end "#{base_classes} #{variant_classes}" @@ -46,7 +54,7 @@ def icon_color when :error, :destructive "destructive" else - "blue-600" + "info" end end end diff --git a/app/components/DS/dialog.rb b/app/components/DS/dialog.rb index e9c4eb3ae..44edc860a 100644 --- a/app/components/DS/dialog.rb +++ b/app/components/DS/dialog.rb @@ -133,8 +133,8 @@ def close_button variant: "icon", class: classes, icon: "x", - title: I18n.t("common.close"), - aria_label: I18n.t("common.close"), + title: I18n.t("defaults.common.close"), + aria_label: I18n.t("defaults.common.close"), data: { action: "DS--dialog#close" } ) end diff --git a/app/components/settings/provider_card.html.erb b/app/components/settings/provider_card.html.erb new file mode 100644 index 000000000..6d0210a17 --- /dev/null +++ b/app/components/settings/provider_card.html.erb @@ -0,0 +1,25 @@ +<%= link_to connect_path, + class: "bg-container shadow-border-xs hover:bg-surface-inset rounded-xl p-4 flex flex-col gap-2.5 text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300", + data: { turbo_frame: "drawer", turbo_prefetch: "false" }.merge(filter_data) do %> +
+
+ <%= logo_text %> +
+
+
+ <%= name %> + <%= render "settings/providers/maturity_badge", label: maturity_label %> +
+ <% if meta_line.present? %> +

<%= meta_line %>

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

<%= tagline %>

+ <% end %> +
+ <%= t("settings.providers.connect") %> + <%= helpers.icon "arrow-right", size: "sm", class: "!w-3.5 !h-3.5" %> +
+<% end %> diff --git a/app/components/settings/provider_card.rb b/app/components/settings/provider_card.rb new file mode 100644 index 000000000..c57657a2e --- /dev/null +++ b/app/components/settings/provider_card.rb @@ -0,0 +1,47 @@ +class Settings::ProviderCard < ApplicationComponent + MATURITY_LABELS = { + beta: "settings.providers.maturity.beta", + alpha: "settings.providers.maturity.alpha" + }.freeze + + def self.maturity_label(maturity) + key = MATURITY_LABELS[maturity&.to_sym] + I18n.t(key) if key + end + + def initialize(provider_key:, name:, tagline: nil, region: nil, kind: nil, tier: nil, + maturity: :stable, logo_bg: "bg-gray-500", logo_text: nil) + @provider_key = provider_key + @name = name + @tagline = tagline + @region = region + @kind = kind + @tier = tier + @maturity = maturity.to_sym + @logo_bg = logo_bg + @logo_text = logo_text || name.first(2).upcase + end + + attr_reader :name, :tagline, :logo_bg, :logo_text + + def maturity_label + self.class.maturity_label(@maturity) + end + + def meta_line + [ @region, @kind, @tier ].compact.join(" · ") + end + + def connect_path + helpers.connect_form_settings_providers_path(provider_key: @provider_key) + end + + def filter_data + { + providers_filter_target: "card", + provider_name: @name.to_s.downcase, + provider_region: @region.to_s.downcase, + provider_kind: @kind.to_s.downcase + } + end +end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index efe9bec08..842afb391 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -18,6 +18,7 @@ def index @enable_banking_items = visible_provider_items(family.enable_banking_items.ordered.includes(:syncs)) @coinstats_items = visible_provider_items(family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs)) @mercury_items = visible_provider_items(family.mercury_items.ordered.includes(:syncs, :mercury_accounts)) + @brex_items = visible_provider_items(family.brex_items.ordered.includes(:accounts, :syncs, brex_accounts: :account_provider)) @coinbase_items = visible_provider_items(family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs)) @snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts)) @indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts)) @@ -315,6 +316,27 @@ def build_sync_stats_maps @mercury_sync_stats_map[item.id] = latest_sync&.sync_stats || {} end + # Brex sync stats + @brex_sync_stats_map = {} + @brex_account_counts_map = {} + @brex_institutions_count_map = {} + @brex_items.each do |item| + latest_sync = item.syncs.ordered.first + @brex_sync_stats_map[item.id] = latest_sync&.sync_stats || {} + brex_accounts = item.brex_accounts.to_a + linked_count = brex_accounts.count { |brex_account| brex_account.account_provider.present? } + total_count = brex_accounts.count + @brex_account_counts_map[item.id] = { + linked: linked_count, + unlinked: total_count - linked_count, + total: total_count + } + @brex_institutions_count_map[item.id] = brex_accounts + .filter_map(&:institution_metadata) + .uniq { |institution| institution["name"] || institution["institution_name"] } + .count + end + # Coinbase sync stats @coinbase_sync_stats_map = {} @coinbase_unlinked_count_map = {} diff --git a/app/controllers/brex_items/account_flows_controller.rb b/app/controllers/brex_items/account_flows_controller.rb new file mode 100644 index 000000000..1a240708b --- /dev/null +++ b/app/controllers/brex_items/account_flows_controller.rb @@ -0,0 +1,132 @@ +class BrexItems::AccountFlowsController < ApplicationController + before_action :require_admin! + + def preload_accounts + render json: brex_account_flow.preload_payload + end + + def select_accounts + @accountable_type = params[:accountable_type] || "Depository" + @return_to = safe_return_to_path + result = brex_account_flow.select_accounts_result(accountable_type: @accountable_type) + + return handle_brex_selection_result(result, empty_path: new_account_path, api_return_path: @return_to) unless result.success? + + @brex_item = result.brex_item + @available_accounts = result.available_accounts + + render "brex_items/select_accounts", layout: false + end + + def link_accounts + result = brex_account_flow.link_new_accounts_result( + account_ids: params[:account_ids] || [], + accountable_type: params[:accountable_type] || "Depository" + ) + + redirect_with_navigation(result, return_to: safe_return_to_path) + end + + def select_existing_account + return redirect_to accounts_path, alert: t("brex_items.select_existing_account.no_account_specified") if params[:account_id].blank? + + @account = Current.family.accounts.find_by(id: params[:account_id]) + return redirect_to accounts_path, alert: t("brex_items.select_existing_account.no_account_specified") unless @account + + result = brex_account_flow.select_existing_account_result(account: @account) + + return handle_brex_selection_result(result, empty_path: accounts_path, api_return_path: accounts_path) unless result.success? + + @brex_item = result.brex_item + @available_accounts = result.available_accounts + @return_to = safe_return_to_path + + render "brex_items/select_existing_account", layout: false + end + + def link_existing_account + return redirect_to accounts_path, alert: t("brex_items.link_existing_account.no_account_specified") if params[:account_id].blank? + + account = Current.family.accounts.find_by(id: params[:account_id]) + return redirect_to accounts_path, alert: t("brex_items.link_existing_account.no_account_specified") unless account + + result = brex_account_flow.link_existing_account_result( + account: account, + brex_account_id: params[:brex_account_id] + ) + + redirect_with_navigation(result, return_to: safe_return_to_path) + end + + private + + def brex_account_flow + @brex_account_flow ||= BrexItem::AccountFlow.new(family: Current.family, brex_item_id: params[:brex_item_id]) + end + + def handle_brex_selection_result(result, empty_path:, api_return_path:) + case result.status + when :empty, :account_already_linked + redirect_to empty_path, alert: result.message + when :no_api_token, :select_connection + redirect_to settings_providers_path, alert: result.message + when :setup_required + if turbo_frame_request? + render partial: "brex_items/setup_required", layout: false + else + redirect_to settings_providers_path, alert: result.message + end + when :api_error, :unexpected_error + render_api_error_partial(result.message, api_return_path) + else + redirect_to settings_providers_path, alert: result.message + end + end + + def redirect_with_navigation(result, return_to:) + redirect_to navigation_path_for(result.target, return_to: return_to), result.flash_type => result.message + end + + def navigation_path_for(target, return_to:) + { + new_account: new_account_path, + settings_providers: settings_providers_path, + return_to_or_accounts: return_to || accounts_path + }.fetch(target, accounts_path) + end + + def render_api_error_partial(error_message, return_path) + render partial: "brex_items/api_error", locals: { error_message: error_message, return_path: return_path }, layout: false + end + + def safe_return_to_path + return nil if params[:return_to].blank? + + return_to = params[:return_to].to_s.strip + return nil unless return_to.start_with?("/") + + second_character = return_to[1] + return nil if second_character.blank? + return nil if second_character == "/" || second_character == "\\" + return nil if second_character.match?(/[[:space:][:cntrl:]]/) + return nil if encoded_path_separator?(return_to) + + uri = URI.parse(return_to) + + return nil if uri.scheme.present? || uri.host.present? + + return_to + rescue URI::InvalidURIError + nil + end + + def encoded_path_separator?(return_to) + encoded_second_character = return_to[1, 3] + return false unless encoded_second_character&.start_with?("%") + + decoded = URI.decode_www_form_component(encoded_second_character) + decoded == "/" || decoded == "\\" + rescue ArgumentError + false + end +end diff --git a/app/controllers/brex_items/account_setups_controller.rb b/app/controllers/brex_items/account_setups_controller.rb new file mode 100644 index 000000000..b5985cfc8 --- /dev/null +++ b/app/controllers/brex_items/account_setups_controller.rb @@ -0,0 +1,100 @@ +class BrexItems::AccountSetupsController < ApplicationController + before_action :require_admin! + before_action :set_brex_item + + def setup_accounts + flow = brex_account_flow + @api_error = flow.import_accounts_with_user_facing_error + @brex_accounts = flow.unlinked_brex_accounts + @account_type_options = flow.account_type_options + @subtype_options = flow.subtype_options + + render "brex_items/setup_accounts" + end + + def complete_account_setup + result = brex_account_flow.complete_setup_result( + account_types: sanitized_account_types, + account_subtypes: sanitized_account_subtypes + ) + + unless result.success? + redirect_to accounts_path, alert: result.message, status: :see_other + return + end + + flash[:notice] = result.message + + if turbo_frame_request? + render_accounts_update_after_setup + else + redirect_to accounts_path, status: :see_other + end + end + + private + + def set_brex_item + @brex_item = Current.family.brex_items.find(params[:id]) + end + + def brex_account_flow + @brex_account_flow ||= BrexItem::AccountFlow.new(family: Current.family, brex_item: @brex_item) + end + + def render_accounts_update_after_setup + @manual_accounts = Account.uncached { Current.family.accounts.visible_manual.order(:name).to_a } + @brex_items = Current.family.brex_items.ordered + + manual_accounts_stream = if @manual_accounts.any? + turbo_stream.update( + "manual-accounts", + partial: "accounts/index/manual_accounts", + locals: { accounts: @manual_accounts } + ) + else + turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts")) + end + + render turbo_stream: [ + manual_accounts_stream, + turbo_stream.replace( + ActionView::RecordIdentifier.dom_id(@brex_item), + partial: "brex_items/brex_item", + locals: { brex_item: @brex_item } + ) + ] + Array(flash_notification_stream_items) + end + + def sanitized_account_types + allowed_account_ids = @brex_item.brex_accounts.pluck(:id).map(&:to_s) + supported_types = Provider::BrexAdapter.supported_account_types + + setup_param_hash(:account_types).each_with_object({}) do |(account_id, selected_type), sanitized| + next unless allowed_account_ids.include?(account_id.to_s) + + normalized_type = selected_type.to_s + sanitized[account_id.to_s] = supported_types.include?(normalized_type) ? normalized_type : "skip" + end + end + + def sanitized_account_subtypes + allowed_account_ids = @brex_item.brex_accounts.pluck(:id).map(&:to_s) + allowed_subtypes = (Depository::SUBTYPES.keys + CreditCard::SUBTYPES.keys).map(&:to_s) + + setup_param_hash(:account_subtypes).each_with_object({}) do |(account_id, selected_subtype), sanitized| + next unless allowed_account_ids.include?(account_id.to_s) + next if selected_subtype.blank? + next unless allowed_subtypes.include?(selected_subtype.to_s) + + sanitized[account_id.to_s] = selected_subtype.to_s + end + end + + def setup_param_hash(key) + raw_params = params.fetch(key, {}) + return {} if raw_params.blank? + + raw_params.respond_to?(:to_unsafe_h) ? raw_params.to_unsafe_h : raw_params.to_h + end +end diff --git a/app/controllers/brex_items_controller.rb b/app/controllers/brex_items_controller.rb new file mode 100644 index 000000000..204fea7c7 --- /dev/null +++ b/app/controllers/brex_items_controller.rb @@ -0,0 +1,98 @@ +class BrexItemsController < ApplicationController + before_action :set_brex_item, only: [ :show, :edit, :update, :destroy, :sync ] + before_action :require_admin!, only: [ :new, :create, :edit, :update, :destroy, :sync ] + + def index + @brex_items = Current.family.brex_items.active.ordered + render layout: "settings" + end + + def show + end + + def new + @brex_item = Current.family.brex_items.build + end + + def create + @brex_item = Current.family.brex_items.build(brex_item_params) + @brex_item.name ||= "Brex Connection" + + if @brex_item.save + @brex_item.sync_later + render_provider_panel_success(t(".success")) + else + render_provider_panel_error + end + end + + def edit + end + + def update + if BrexItem::AccountFlow.update_item_with_cache_expiration(@brex_item, family: Current.family, attributes: brex_item_params) + render_provider_panel_success(t(".success")) + else + render_provider_panel_error + end + end + + def destroy + @brex_item.unlink_all!(dry_run: false) + @brex_item.destroy_later + redirect_to accounts_path, notice: t(".success") + end + + def sync + @brex_item.sync_later unless @brex_item.syncing? + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + private + + def render_provider_panel_success(message) + return redirect_to accounts_path, notice: message, status: :see_other unless turbo_frame_request? + + flash.now[:notice] = message + @brex_items = Current.family.brex_items.active.ordered.includes(:syncs, :brex_accounts) + render_brex_provider_panel(locals: { brex_items: @brex_items }, include_flash: true) + end + + def render_provider_panel_error + @error_message = @brex_item.errors.full_messages.join(", ") + return redirect_to settings_providers_path, alert: @error_message, status: :see_other unless turbo_frame_request? + + render_brex_provider_panel(locals: { error_message: @error_message }, status: :unprocessable_entity) + end + + def render_brex_provider_panel(locals:, status: :ok, include_flash: false) + streams = [ + turbo_stream.replace( + "brex-providers-panel", + partial: "settings/providers/brex_panel", + locals: locals + ) + ] + streams += flash_notification_stream_items if include_flash + render turbo_stream: streams, status: status + end + + def set_brex_item + @brex_item = Current.family.brex_items.find(params[:id]) + end + + def brex_item_params + permitted = params.require(:brex_item).permit(:name, :sync_start_date, :token, :base_url) + permitted.delete(:token) if @brex_item&.persisted? && permitted[:token].blank? + permitted[:token] = permitted[:token].to_s.strip if permitted[:token].present? + if permitted.key?(:base_url) + permitted[:base_url] = permitted[:base_url].to_s.strip + permitted[:base_url] = nil if permitted[:base_url].blank? + end + permitted + end +end diff --git a/app/controllers/settings/bank_sync_controller.rb b/app/controllers/settings/bank_sync_controller.rb deleted file mode 100644 index 91e55a3ef..000000000 --- a/app/controllers/settings/bank_sync_controller.rb +++ /dev/null @@ -1,43 +0,0 @@ -class Settings::BankSyncController < ApplicationController - layout "settings" - - def show - @providers = [ - { - name: "Lunch Flow", - description: "US, Canada, UK, EU, Brazil and Asia through multiple open banking providers.", - path: "https://lunchflow.app/features/sure-integration?atp=BiDIYS", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "Plaid", - description: "US & Canada bank connections with transactions, investments, and liabilities.", - path: "https://github.com/we-promise/sure/blob/main/docs/hosting/plaid.md", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "SimpleFIN", - description: "US & Canada connections via SimpleFIN protocol.", - path: "https://beta-bridge.simplefin.org", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "Enable Banking (beta)", - description: "European bank connections via open banking APIs across multiple countries.", - path: "https://enablebanking.com", - target: "_blank", - rel: "noopener noreferrer" - }, - { - name: "Sophtron (alpha)", - description: "US & Canada bank, credit card, investment, loan, insurance, utility, and other connections.", - path: "https://www.sophtron.com/", - target: "_blank", - rel: "noopener noreferrer" - } - ] - end -end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 7c6428376..2e4427345 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -1,12 +1,12 @@ class Settings::ProvidersController < ApplicationController - layout "settings" + layout -> { turbo_frame_request? ? "turbo_rails/frame" : "settings" } - before_action :ensure_admin, only: [ :show, :update ] + before_action :ensure_admin, only: [ :show, :update, :sync_all, :sync, :connect_form ] def show @breadcrumbs = [ [ "Home", root_path ], - [ "Bank Sync Providers", nil ] + [ "Bank sync", nil ] ] prepare_show_context @@ -77,6 +77,61 @@ def update render :show, status: :unprocessable_entity end + def sync_all + family = Current.family + now = Time.current + + updated_count = Family + .where(id: family.id) + .where("last_sync_all_attempted_at IS NULL OR last_sync_all_attempted_at <= ?", 30.seconds.ago) + .update_all(last_sync_all_attempted_at: now, updated_at: now) + + if updated_count.zero? + return redirect_to settings_providers_path, notice: t("settings.providers.sync_all_recently") + end + + SyncAllProvidersJob.perform_later(family.id) + redirect_to settings_providers_path, notice: t("settings.providers.sync_all_in_progress") + end + + def sync + provider_key = params[:provider_key] + syncable_type = PANEL_SYNCABLE_TYPES[provider_key] + return redirect_to settings_providers_path unless syncable_type + + items = syncable_type.constantize.where(family: Current.family).syncable + scheduled = items.reject(&:syncing?) + scheduled.each(&:sync_later) + + notice_key = scheduled.any? ? "settings.providers.sync_provider_in_progress" : "settings.providers.sync_provider_no_items" + redirect_to settings_providers_path, notice: t(notice_key) + end + + def connect_form + provider_key = params[:provider_key] + + panel = FAMILY_PANELS.find { |p| p[:key] == provider_key } + if panel + @panel_key = panel[:key] + @panel_partial = panel[:partial] + @panel_title = panel[:title] + load_provider_items(provider_key) + return render :connect_form + end + + Provider::Factory.ensure_adapters_loaded + config = Provider::ConfigurationRegistry.all.find { |c| c.provider_key.to_s == provider_key } + if config + @panel_title = Provider::Metadata.for(provider_key)[:name] || provider_key.titleize + @provider_configuration = config + return render :connect_form + end + + redirect_to settings_providers_path, alert: t("settings.providers.not_found") + rescue ActiveRecord::Encryption::Errors::Configuration + redirect_to settings_providers_path, alert: t("settings.providers.encryption_error.title") + end + private def provider_params # Dynamically permit all provider configuration fields @@ -93,7 +148,9 @@ def provider_params end def ensure_admin - redirect_to settings_providers_path, alert: "Not authorized" unless Current.user.admin? + return if Current.user.admin? + + redirect_to root_path, alert: t("settings.providers.not_authorized") end # Reload provider configurations after settings update @@ -119,19 +176,75 @@ def reload_provider_configs(updated_fields) end end + # Hardcoded family-scoped panels — provider connections are managed through + # their own models (SimplefinItem, LunchflowItem, etc.) rather than global + # settings, so they need custom UI per-provider for connection management, + # status display, and sync actions. The configuration registry excludes + # them (see prepare_show_context). + FAMILY_PANELS = [ + { key: "lunchflow", title: "Lunch Flow", turbo_id: "lunchflow", partial: "lunchflow_panel" }, + { key: "simplefin", title: "SimpleFIN", turbo_id: "simplefin", partial: "simplefin_panel" }, + { key: "enable_banking", title: "Enable Banking", turbo_id: "enable_banking", partial: "enable_banking_panel" }, + { key: "coinstats", title: "CoinStats", turbo_id: "coinstats", partial: "coinstats_panel" }, + { key: "mercury", title: "Mercury", turbo_id: "mercury", partial: "mercury_panel" }, + { key: "brex", title: "Brex", turbo_id: "brex", partial: "brex_panel" }, + { key: "coinbase", title: "Coinbase", turbo_id: "coinbase", partial: "coinbase_panel" }, + { key: "binance", title: "Binance", turbo_id: "binance", partial: "binance_panel" }, + { key: "snaptrade", title: "SnapTrade", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" }, + { key: "indexa_capital", title: "Indexa Capital", turbo_id: "indexa_capital", partial: "indexa_capital_panel" }, + { key: "sophtron", title: "Sophtron", turbo_id: "sophtron", partial: "sophtron_panel" } + ].freeze + + FAMILY_PANEL_KEYS = FAMILY_PANELS.map { |p| p[:key] }.freeze + + # Maps panel key → ActiveRecord model name for sync health queries + PANEL_SYNCABLE_TYPES = { + "simplefin" => "SimplefinItem", + "lunchflow" => "LunchflowItem", + "enable_banking" => "EnableBankingItem", + "coinstats" => "CoinstatsItem", + "mercury" => "MercuryItem", + "brex" => "BrexItem", + "coinbase" => "CoinbaseItem", + "binance" => "BinanceItem", + "snaptrade" => "SnaptradeItem", + "indexa_capital" => "IndexaCapitalItem", + "sophtron" => "SophtronItem" + }.freeze + + def load_provider_items(provider_key) + case provider_key + when "simplefin" + @simplefin_items = Current.family.simplefin_items.ordered + when "lunchflow" + @lunchflow_items = Current.family.lunchflow_items.ordered + when "enable_banking" + @enable_banking_items = Current.family.enable_banking_items.ordered + when "coinstats" + @coinstats_items = Current.family.coinstats_items.ordered + when "mercury" + @mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts) + when "brex" + @brex_items = Current.family.brex_items.active.ordered.includes(:syncs, :brex_accounts) + when "coinbase" + @coinbase_items = Current.family.coinbase_items.ordered + when "binance" + @binance_items = Current.family.binance_items.active.ordered + when "snaptrade" + @snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered + when "indexa_capital" + @indexa_capital_items = Current.family.indexa_capital_items.ordered + when "sophtron" + @sophtron_items = Current.family.sophtron_items.ordered + end + end + # Prepares instance vars needed by the show view and partials def prepare_show_context - # Load all provider configurations (exclude SimpleFin and Lunchflow, which have their own family-specific panels below) + # Load all provider configurations (exclude family-scoped panels, which have their own UI below) Provider::Factory.ensure_adapters_loaded @provider_configurations = Provider::ConfigurationRegistry.all.reject do |config| - config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \ - config.provider_key.to_s.casecmp("enable_banking").zero? || \ - config.provider_key.to_s.casecmp("sophtron").zero? || \ - config.provider_key.to_s.casecmp("coinstats").zero? || \ - config.provider_key.to_s.casecmp("mercury").zero? || \ - config.provider_key.to_s.casecmp("coinbase").zero? || \ - config.provider_key.to_s.casecmp("snaptrade").zero? || \ - config.provider_key.to_s.casecmp("indexa_capital").zero? + FAMILY_PANEL_KEYS.any? { |key| config.provider_key.to_s.casecmp(key).zero? } end # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials @@ -141,9 +254,102 @@ def prepare_show_context # Providers page only needs to know whether any Sophtron connections exist with valid credentials @sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id) @coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display - @mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts) + @mercury_items = Current.family.mercury_items.active.ordered + @brex_items = Current.family.brex_items.active.ordered @coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display - @snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered + @snaptrade_items = Current.family.snaptrade_items.ordered @indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id) + @binance_items = Current.family.binance_items.active.ordered + + @provider_sync_health = compute_provider_sync_health(family_panel_items) + + entries = build_provider_entries + + @connected = entries.select { |e| e[:summary][:status] == :ok } + @needs_attention = entries.select { |e| [ :warn, :err ].include?(e[:summary][:status]) } + @available = entries.select { |e| e[:summary][:status] == :off } + + @health = view_context.provider_health_strip(connected: @connected, needs_attention: @needs_attention) + end + + # Maps each family panel key to the loaded item collection. Used by + # compute_provider_sync_health and build_provider_entries to avoid relying + # on instance_variable_get for control flow. + def family_panel_items + { + "simplefin" => @simplefin_items, + "lunchflow" => @lunchflow_items, + "enable_banking" => @enable_banking_items, + "coinstats" => @coinstats_items, + "mercury" => @mercury_items, + "brex" => @brex_items, + "coinbase" => @coinbase_items, + "binance" => @binance_items, + "snaptrade" => @snaptrade_items, + "indexa_capital" => @indexa_capital_items, + "sophtron" => @sophtron_items + } + end + + # Returns a hash mapping provider key → { error:, last_synced_at:, stale: } + # by querying the latest sync per item for each family panel provider. + def compute_provider_sync_health(items_map) + PANEL_SYNCABLE_TYPES.each_with_object({}) do |(key, syncable_type), health| + ids = items_map[key]&.map(&:id)&.compact + next if ids.blank? + + health[key] = sync_health_for(syncable_type, ids) + end + end + + # Determines error/stale status and last successful sync time for a set of items. + def sync_health_for(syncable_type, item_ids) + # Use window function to get the single latest sync per item (same pattern as ProviderConnectionStatus) + ranked_subq = Sync + .where(syncable_type: syncable_type, syncable_id: item_ids) + .select("syncs.*, ROW_NUMBER() OVER (PARTITION BY syncable_id ORDER BY created_at DESC, id DESC) AS sync_rank") + + latest_per_item = Sync.from(ranked_subq, :syncs).where("sync_rank = 1").to_a + + has_error = latest_per_item.any? { |s| s.failed? || s.stale? } + + last_synced = Sync + .where(syncable_type: syncable_type, syncable_id: item_ids, status: "completed") + .maximum(:completed_at) + + stale = !has_error && last_synced.present? && last_synced < 24.hours.ago + + { error: has_error, last_synced_at: last_synced, stale: stale } + end + + # Builds a unified list of provider entries (registry-driven configurations + # and hardcoded family panels) with pre-computed status, sorted + # alphabetically by display title. Each entry carries enough data for the + # view to render either a provider_form or a family panel partial. + def build_provider_entries + configuration_entries = @provider_configurations.map do |config| + meta = Provider::Metadata.for(config.provider_key) + { + provider_key: config.provider_key.to_s, + title: meta[:name] || config.provider_key.to_s.titleize, + configuration: config, + maturity: meta[:maturity], + summary: view_context.provider_summary(config.provider_key) + } + end + + family_entries = FAMILY_PANELS.map do |panel| + { + provider_key: panel[:key], + title: panel[:title], + turbo_id: panel[:turbo_id], + partial: panel[:partial], + auto_open_param: panel[:auto_open], + maturity: Provider::Metadata.for(panel[:key])[:maturity], + summary: view_context.provider_summary(panel[:key]) + } + end + + (configuration_entries + family_entries).sort_by { |entry| entry[:title].downcase } end end diff --git a/app/controllers/sophtron_items_controller.rb b/app/controllers/sophtron_items_controller.rb index 81a86cd60..6222954ef 100644 --- a/app/controllers/sophtron_items_controller.rb +++ b/app/controllers/sophtron_items_controller.rb @@ -1,20 +1,23 @@ class SophtronItemsController < ApplicationController + include SyncStats::Collector + CONNECTION_STATUS_MAX_POLLS = 6 LOGIN_PROGRESS_CONNECTION_STATUS_MAX_POLLS = 15 POST_MFA_CONNECTION_STATUS_MAX_POLLS = 15 CONNECTION_STATUS_POLL_INTERVAL_MS = 4_000 MAX_SECURITY_ANSWERS = 10 MAX_SECURITY_ANSWER_LENGTH = 256 + MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY = "manual_sync_processed_sophtron_account_ids" before_action :set_sophtron_item, only: [ :show, :edit, :update, :destroy, :connect_institution, :sync, - :connection_status, :submit_mfa, + :connection_status, :submit_mfa, :toggle_manual_sync, :setup_accounts, :complete_account_setup ] before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :connect_institution, - :edit, :update, :destroy, :sync, :connection_status, :submit_mfa, + :edit, :update, :destroy, :sync, :connection_status, :submit_mfa, :toggle_manual_sync, :setup_accounts, :complete_account_setup ] @@ -146,6 +149,11 @@ def connection_status @sophtron_item.upsert_job_snapshot!(job) if Provider::Sophtron.job_success?(job) + if manual_sync_flow? + complete_manual_sync_from_job(job) + return + end + @sophtron_item.update!( current_job_id: nil, last_connection_error: nil, @@ -157,18 +165,27 @@ def connection_status @challenge = @sophtron_item.build_mfa_challenge(job) prepare_connection_status_context render :mfa, layout: false - elsif post_mfa_polling? && Provider::Sophtron.job_completed?(job) - return if render_account_selection_if_accounts_available(@sophtron_item) + elsif Provider::Sophtron.job_completed?(job) + if manual_sync_flow? + complete_manual_sync_from_job(job) + return + end + + if post_mfa_polling? + return if render_account_selection_if_accounts_available(@sophtron_item) + end render_pending_connection_status elsif Provider::Sophtron.job_failed?(job) failure_message = sophtron_connection_failure_message(job) @sophtron_item.update!( current_job_id: nil, - user_institution_id: nil, + current_job_sophtron_account_id: nil, + user_institution_id: (manual_sync_flow? ? @sophtron_item.user_institution_id : nil), last_connection_error: failure_message, status: :requires_update ) + fail_manual_sync!(manual_sync_record, failure_message) if manual_sync_flow? render_institution_connection_error(failure_message) else render_pending_connection_status @@ -415,6 +432,11 @@ def destroy end def sync + if @sophtron_item.manual_sync_required? + start_manual_sync + return + end + @sophtron_item.sync_later unless @sophtron_item.syncing? respond_to do |format| @@ -423,6 +445,42 @@ def sync end end + def toggle_manual_sync + toggle_accounts = manual_sync_toggle_sophtron_accounts + + if toggle_accounts.exists? + enabled = if @sophtron_item.manual_sync? + @sophtron_item.sophtron_accounts.where.not(id: toggle_accounts.select(:id)).update_all(manual_sync: true, updated_at: Time.current) + false + else + !toggle_accounts.requires_manual_sync.exists? + end + toggle_accounts.update_all(manual_sync: enabled, updated_at: Time.current) + @sophtron_item.update!(manual_sync: false) unless enabled + elsif params[:institution_key].present? || params[:user_institution_id].present? + redirect_back_or_to accounts_path, alert: t("sophtron_items.sync.no_linked_accounts") + return + else + @sophtron_item.update!(manual_sync: !@sophtron_item.manual_sync?) + enabled = @sophtron_item.manual_sync? + end + + respond_to do |format| + format.html { redirect_back_or_to accounts_path, notice: t(".success_#{enabled ? 'enabled' : 'disabled'}") } + format.turbo_stream do + flash.now[:notice] = t(".success_#{enabled ? 'enabled' : 'disabled'}") + render turbo_stream: [ + turbo_stream.replace( + ActionView::RecordIdentifier.dom_id(@sophtron_item), + partial: "sophtron_items/sophtron_item", + locals: { sophtron_item: @sophtron_item.reload } + ), + *flash_notification_stream_items + ] + end + end + end + def setup_accounts @api_error = fetch_sophtron_accounts_from_api @@ -580,6 +638,277 @@ def complete_account_setup private + def start_manual_sync + if @sophtron_item.current_job_sophtron_account_id.present? + redirect_to active_manual_sync_path, alert: t(".already_running") + return + end + + unless linked_manual_sync_sophtron_accounts.exists? + redirect_back_or_to accounts_path, alert: t(".no_linked_accounts") + return + end + + sync = @sophtron_item.syncs.create! + sync.start! if sync.may_start? + @manual_sync = sync + + provider = @sophtron_item.sophtron_provider + raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider + + reset_manual_sync_progress!(sync) + start_next_manual_sync_account(sync, provider) + rescue Provider::Sophtron::Error => e + clear_or_fail_manual_sync_after_error!(sync, e.message) if defined?(sync) && sync.present? + Rails.logger.error("Sophtron manual sync error: #{e.message}") + redirect_back_or_to accounts_path, alert: t(".api_error", message: e.message) + end + + def active_manual_sync_path + return accounts_path if @sophtron_item.current_job_id.blank? + + connection_status_sophtron_item_path( + @sophtron_item, + connection_context_params.merge( + manual_sync: true, + sync_id: manual_sync_record&.id, + sophtron_account_id: @sophtron_item.current_job_sophtron_account_id + ) + ) + end + + def start_next_manual_sync_account(sync, provider) + sophtron_account = next_manual_sync_sophtron_account(sync) + + unless sophtron_account + @sophtron_item.update!( + current_job_id: nil, + current_job_sophtron_account_id: nil, + last_connection_error: nil, + status: :good + ) + sync.finalize_if_all_children_finalized + flash.discard(:alert) + @manual_sync = sync + render :manual_sync_complete, layout: false + return + end + + start_manual_sync_for_account(sophtron_account, provider, sync) + end + + def start_manual_sync_for_account(sophtron_account, provider, sync) + refresh_response = sophtron_response_data!(provider.refresh_account(sophtron_account.account_id)).with_indifferent_access + job_id = refresh_response[:JobID] || refresh_response[:job_id] + + if job_id.blank? + complete_manual_sync!(sophtron_account, provider, sync) + start_next_manual_sync_account(sync, provider) + return + end + + @sophtron_item.update!( + current_job_id: job_id, + current_job_sophtron_account_id: sophtron_account.id, + raw_job_payload: refresh_response, + job_status: nil, + last_connection_error: nil, + status: :good + ) + + job = sophtron_response_data!(provider.get_job_information(job_id)) + @sophtron_item.upsert_job_snapshot!(job) + + if Provider::Sophtron.job_requires_input?(job) + @challenge = @sophtron_item.build_mfa_challenge(job) + prepare_connection_status_context + render :mfa, layout: false + elsif Provider::Sophtron.job_failed?(job) + failure_message = t(".failed") + fail_manual_sync_and_clear_job!(sync, failure_message) + redirect_back_or_to accounts_path, alert: failure_message + elsif Provider::Sophtron.job_success?(job) || Provider::Sophtron.job_completed?(job) + complete_manual_sync!(sophtron_account, provider, sync) + start_next_manual_sync_account(sync, provider) + else + @poll_attempt = 1 + render_pending_connection_status + end + end + + def complete_manual_sync_from_job(job) + sophtron_account = @sophtron_item.current_job_sophtron_account + sophtron_account ||= linked_manual_sync_sophtron_accounts.find_by(id: params[:sophtron_account_id]) if params[:sophtron_account_id].present? + sync = manual_sync_record + + unless sophtron_account && sync + @sophtron_item.update!(current_job_id: nil, current_job_sophtron_account_id: nil) + render_api_error(t("sophtron_items.sync.no_linked_accounts"), accounts_path) + return + end + + provider = @sophtron_item.sophtron_provider + complete_manual_sync!(sophtron_account, provider, sync) + start_next_manual_sync_account(sync, provider) + rescue Provider::Sophtron::Error => e + fail_manual_sync_and_clear_job!(sync, e.message) if defined?(sync) && sync.present? + render_api_error(t("sophtron_items.sync.api_error", message: e.message), accounts_path) + end + + def complete_manual_sync!(sophtron_account, provider, sync) + raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider + + result = SophtronItem::Importer.new(@sophtron_item, sophtron_provider: provider, sync: sync) + .import_transactions_after_refresh(sophtron_account) + + unless result[:success] + error_message = result[:error] || t("sophtron_items.sync.failed") + fail_manual_sync_and_clear_job!(sync, error_message) + raise Provider::Sophtron::Error.new(error_message, :api_error) + end + + processing_result = process_manual_sync_account!(sync, sophtron_account) + mark_manual_sync_account_processed!(sync, sophtron_account) + collect_manual_sync_stats!(sync, processing_result) + @sophtron_item.update!( + current_job_id: nil, + current_job_sophtron_account_id: nil, + last_connection_error: nil, + status: :good + ) + + if (account = sophtron_account.current_account) + account.sync_later( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + else + sync.finalize_if_all_children_finalized + end + + @manual_sync_account = sophtron_account + @manual_sync = sync + end + + def process_manual_sync_account!(sync, sophtron_account) + SophtronAccount::Processor.new(sophtron_account.reload).process + rescue StandardError => e + Rails.logger.error("Sophtron manual sync processing error: #{e.class} - #{e.message}") + fail_manual_sync_and_clear_job!(sync, e.message) + raise Provider::Sophtron::Error.new(t("sophtron_items.sync.processing_failed"), :api_error) + end + + def fail_manual_sync_and_clear_job!(sync, message) + clear_manual_sync_job!(message, status: :requires_update) + fail_manual_sync!(sync, message) + end + + def clear_or_fail_manual_sync_after_error!(sync, message) + if sync.failed? + clear_manual_sync_job!(@sophtron_item.last_connection_error, status: :requires_update) + else + fail_manual_sync_and_clear_job!(sync, message) + end + end + + def clear_manual_sync_job!(message = nil, status: nil) + attributes = { + current_job_id: nil, + current_job_sophtron_account_id: nil + } + attributes[:last_connection_error] = message if message.present? + attributes[:status] = status if status.present? + + @sophtron_item.update!(attributes) + end + + def fail_manual_sync!(sync, message) + return unless sync + + sync.start! if sync.may_start? + sync.fail! if sync.may_fail? + sync.update!(error: message) + end + + def manual_sync_record + return @manual_sync if defined?(@manual_sync) && @manual_sync.present? + + sync = @sophtron_item.syncs.find_by(id: params[:sync_id]) if params[:sync_id].present? + sync || visible_manual_sync_record + end + + def visible_manual_sync_record + @sophtron_item.syncs.visible.ordered.detect do |sync| + sync.sync_stats.to_h.key?(MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY) + end + end + + def linked_manual_sync_sophtron_accounts + @sophtron_item.manual_sync_sophtron_accounts + end + + def manual_sync_toggle_sophtron_accounts + accounts = @sophtron_item.sophtron_accounts.order(:created_at, :id) + institution_key = params[:institution_key].presence || params[:user_institution_id] + return accounts if institution_key.blank? + + account_ids = accounts.select do |sophtron_account| + sophtron_account.institution_key.to_s == institution_key.to_s + end.map(&:id) + + accounts.where(id: account_ids) + end + + def next_manual_sync_sophtron_account(sync) + processed_ids = manual_sync_processed_sophtron_account_ids(sync) + linked_manual_sync_sophtron_accounts.detect { |sophtron_account| processed_ids.exclude?(sophtron_account.id.to_s) } + end + + def reset_manual_sync_progress!(sync) + sync.update!(sync_stats: { MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY => [] }) + end + + def mark_manual_sync_account_processed!(sync, sophtron_account) + processed_ids = manual_sync_processed_sophtron_account_ids(sync) + processed_ids << sophtron_account.id.to_s + stats = sync.sync_stats.to_h + sync.update!(sync_stats: stats.merge(MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY => processed_ids.uniq)) + end + + def collect_manual_sync_stats!(sync, processing_result) + mark_import_started(sync) + collect_setup_stats(sync, provider_accounts: @sophtron_item.sophtron_accounts.includes(:account_provider, :account)) + + account_ids = @sophtron_item.sophtron_accounts + .where(id: manual_sync_processed_sophtron_account_ids(sync)) + .includes(:account_provider) + .filter_map { |sophtron_account| sophtron_account.current_account&.id } + + collect_transaction_stats( + sync, + account_ids: account_ids, + source: "sophtron", + window_start: sync.syncing_at || sync.created_at, + window_end: Time.current + ) + + collect_manual_sync_health_stats!(sync, processing_result) + end + + def collect_manual_sync_health_stats!(sync, processing_result) + if processing_result.is_a?(Hash) && processing_result[:success] == false + errors = Array(processing_result[:errors]).presence || [ { message: t("sophtron_items.sync.failed"), category: "transaction_import" } ] + collect_health_stats(sync, errors: errors) + elsif sync.sync_stats.to_h["total_errors"].to_i.zero? + collect_health_stats(sync, errors: nil) + end + end + + def manual_sync_processed_sophtron_account_ids(sync) + Array(sync.sync_stats.to_h[MANUAL_SYNC_PROCESSED_ACCOUNT_IDS_KEY]).map(&:to_s) + end + def configured_sophtron_item Current.family.configured_sophtron_item end @@ -746,6 +1075,9 @@ def prepare_connection_status_context @accountable_type = params[:accountable_type] || "Depository" @account_id = params[:account_id] @return_to = safe_return_to_path + @manual_sync_flow = manual_sync_flow? + @manual_sync_id = manual_sync_record&.id if @manual_sync_flow + @manual_sync_sophtron_account_id = params[:sophtron_account_id] || @sophtron_item.current_job_sophtron_account_id @poll_interval_ms = CONNECTION_STATUS_POLL_INTERVAL_MS @post_mfa_polling = post_mfa_polling? @max_poll_attempts = connection_status_max_polls @@ -782,6 +1114,10 @@ def post_mfa_polling? ActiveModel::Type::Boolean.new.cast(params[:post_mfa]) || post_mfa_job_payload?(@sophtron_item.raw_job_payload) end + def manual_sync_flow? + ActiveModel::Type::Boolean.new.cast(params[:manual_sync]) || @sophtron_item.current_job_sophtron_account_id.present? + end + def post_mfa_job_payload?(job_payload) job = (job_payload || {}).with_indifferent_access job[:TokenInput].present? || %w[TokenInput TransactionTable].include?(job[:LastStep].to_s) @@ -812,7 +1148,7 @@ def render_institution_connection_error(message) message, select_accounts_sophtron_items_path(connection_context_params.except(:post_mfa, "post_mfa")), heading: t("sophtron_items.api_error.institution_unable_to_connect"), - issue_keys: %w[bank_credentials verification_code institution_timeout unsupported_mfa], + issue_keys: %w[bad_credentials verification_code institution_timeout unsupported_mfa], action_label: t("sophtron_items.api_error.try_again") ) end @@ -888,7 +1224,7 @@ def sophtron_params end def connection_context_params - params.permit(:accountable_type, :account_id, :return_to, :post_mfa, :connect_new_institution).to_h.compact + params.permit(:accountable_type, :account_id, :return_to, :post_mfa, :connect_new_institution, :manual_sync, :sync_id, :sophtron_account_id, :institution_key, :user_institution_id).to_h.compact end def connect_new_institution_flow? diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d46415e57..f00d876ea 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -17,7 +17,7 @@ def styled_form_with(**options, &block) def icon(key, size: "md", color: "default", custom: false, as_button: false, **opts) extra_classes = opts.delete(:class) sizes = { xs: "w-3 h-3", sm: "w-4 h-4", md: "w-5 h-5", lg: "w-6 h-6", xl: "w-7 h-7", "2xl": "w-8 h-8" } - colors = { default: "text-secondary", white: "text-inverse", success: "text-success", warning: "text-warning", destructive: "text-destructive", current: "text-current" } + colors = { default: "text-secondary", white: "text-inverse", success: "text-success", warning: "text-warning", destructive: "text-destructive", info: "text-info", current: "text-current" } icon_classes = class_names( "shrink-0", diff --git a/app/helpers/brex_items_helper.rb b/app/helpers/brex_items_helper.rb new file mode 100644 index 000000000..a658dad03 --- /dev/null +++ b/app/helpers/brex_items_helper.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module BrexItemsHelper + BrexAccountDisplay = Struct.new( + :id, + :name, + :kind, + :currency, + :status, + :blank_name, + keyword_init: true + ) do + alias_method :blank_name?, :blank_name + end + + def brex_account_display(account) + data = account.with_indifferent_access + kind = BrexAccount.kind_for(data) + name = BrexAccount.name_for(data) + + BrexAccountDisplay.new( + id: data[:id], + name: name, + kind: kind, + currency: BrexAccount.currency_code_from_money(data[:current_balance] || data[:available_balance] || data[:account_limit]), + status: data[:status], + blank_name: name.blank? + ) + end + + def brex_account_metadata(display) + parts = [ + t("brex_items.account_metadata.provider"), + display.currency, + display.kind.to_s.titleize, + display.status.presence&.to_s&.titleize + ].compact + + parts.join(t("brex_items.account_metadata.separator")) + end + + def brex_item_render_locals(brex_item, sync_stats_map: nil, account_counts_map: nil, institutions_count_map: nil) + counts = (account_counts_map || {})[brex_item.id] || {} + + { + brex_item: brex_item, + stats: (sync_stats_map || {})[brex_item.id] || brex_item.syncs.ordered.first&.sync_stats || {}, + unlinked_count: counts[:unlinked] || brex_item.unlinked_accounts_count, + linked_count: counts[:linked] || brex_item.linked_accounts_count, + total_count: counts[:total] || brex_item.total_accounts_count, + institutions_count: (institutions_count_map || {})[brex_item.id] || brex_item.connected_institutions.size + } + end + + def default_brex_depository_subtype(account_name) + normalized_name = account_name.to_s.downcase + + if normalized_name.match?(/\bchecking\b|\bchequing\b|\bck\b|demand\s+deposit/) + "checking" + elsif normalized_name.match?(/\bsavings\b|\bsv\b/) + "savings" + elsif normalized_name.match?(/money\s+market|\bmm\b/) + "money_market" + else + "checking" + end + end +end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 79a3c248a..4338a011f 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -2,7 +2,7 @@ module SettingsHelper SETTINGS_ORDER = [ # General section { name: "Accounts", path: :accounts_path }, - { name: "Bank Sync", path: :settings_bank_sync_path }, + { name: "Bank Sync", path: :settings_providers_path, condition: :admin_user? }, { name: "Preferences", path: :settings_preferences_path }, { name: "Appearance", path: :settings_appearance_path }, { name: "Profile Info", path: :settings_profile_path }, @@ -19,7 +19,6 @@ module SettingsHelper { name: "LLM Usage", path: :settings_llm_usage_path, condition: :admin_user? }, { name: "API Key", path: :settings_api_key_path, condition: :admin_user? }, { name: "Self-Hosting", path: :settings_hosting_path, condition: :self_hosted_and_admin? }, - { name: "Providers", path: :settings_providers_path, condition: :admin_user? }, { name: "Imports", path: :imports_path, condition: :admin_user? }, { name: "Exports", path: :family_exports_path, condition: :admin_user? }, # More section @@ -45,9 +44,73 @@ def adjacent_setting(current_path, offset) } end - def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, &block) + def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil, &block) content = capture(&block) - render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param } + render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param, status: status, meta: meta, actions: actions, badge: badge } + end + + def status_pill_classes(status) + pill = "bg-surface-inset text-primary" + + case status.to_s.to_sym + when :ok + { dot: "bg-success", pill: pill } + when :warn + { dot: "bg-warning", pill: pill } + when :err + { dot: "bg-destructive", pill: pill } + else + { dot: "bg-gray-400", pill: pill } + end + end + + def provider_summary(provider_key) + key = provider_key.to_s.downcase + + case key + when "plaid", "plaid_eu" + configured = @provider_configurations&.find { |c| c.provider_key.to_s.casecmp(key).zero? }&.configured? + configured ? { status: :ok } : { status: :off } + when "simplefin" + return { status: :off } unless @simplefin_items&.any? + sync_based_summary(key) + when "lunchflow" + return { status: :off } unless @lunchflow_items&.any? + sync_based_summary(key) + when "enable_banking" + return { status: :off } unless @enable_banking_items&.any? + enable_banking_summary + when "coinstats" + return { status: :off } unless @coinstats_items&.any? + sync_based_summary(key) + when "mercury" + return { status: :off } unless @mercury_items&.any? + sync_based_summary(key) + when "brex" + return { status: :off } unless @brex_items&.any? + sync_based_summary(key) + when "coinbase" + return { status: :off } unless @coinbase_items&.any? + sync_based_summary(key) + when "binance" + return { status: :off } unless @binance_items&.any? + sync_based_summary(key) + when "snaptrade" + configured_item = @snaptrade_items&.find(&:credentials_configured?) + return { status: :off } unless configured_item + unless configured_item.user_registered? + return { status: :warn, meta: t("settings.providers.meta.registration_needed") } + end + sync_based_summary(key) + when "indexa_capital" + return { status: :off } unless @indexa_capital_items&.any? + sync_based_summary(key) + when "sophtron" + return { status: :off } unless @sophtron_items&.any? + sync_based_summary(key) + else + { status: :off } + end end def settings_nav_footer @@ -70,7 +133,85 @@ def settings_nav_footer_mobile end end + # Below this many synced accounts, the per-row pills already give the user + # enough at-a-glance signal and the strip is redundant chrome. + HEALTH_STRIP_MIN_ACCOUNTS = 10 + + # Slim health-strip data for the providers index. Pulls counts from the + # already-resolved entry summaries plus the family's distinct synced-account + # count for the trailing stat. Returns a hash consumed by the + # `settings/providers/_health_strip` partial, or nil when the family has + # fewer than HEALTH_STRIP_MIN_ACCOUNTS connected accounts. + def provider_health_strip(connected:, needs_attention:) + accounts_count = Current.family.accounts.joins(:account_providers).distinct.count + return nil if accounts_count < HEALTH_STRIP_MIN_ACCOUNTS + + active_entries = connected + needs_attention + last_synced_at = active_entries.map { |e| e[:summary][:last_synced_at] }.compact.max + + { + connected: active_entries.size, + needs_attention: needs_attention.size, + accounts_syncing: accounts_count, + last_synced_at: last_synced_at + } + end + + # Strips the leading "about " from `time_ago_in_words` so copy reads as + # "Synced 6 hours ago" instead of "Synced about 6 hours ago". + def concise_time_ago(time) + time_ago_in_words(time).sub(/\Aabout /, "") + end + private + def sync_based_summary(provider_key) + health = @provider_sync_health&.dig(provider_key) || {} + last_synced_at = health[:last_synced_at] + + base = if health[:error] + { status: :err, meta: t("settings.providers.meta.sync_error") } + elsif health[:stale] + { status: :warn, meta: t("settings.providers.meta.no_recent_sync") } + elsif last_synced_at.present? + { status: :ok, meta: t("settings.providers.meta.last_synced", time: concise_time_ago(last_synced_at)) } + else + { status: :ok } + end + + base.merge(last_synced_at: last_synced_at) + end + + def enable_banking_summary + health = @provider_sync_health&.dig("enable_banking") || {} + last_synced_at = health[:last_synced_at] + + return { status: :err, meta: t("settings.providers.meta.sync_error"), last_synced_at: nil } if health[:error] + + valid_items = @enable_banking_items&.select(&:session_valid?) || [] + + # All items have expired/missing sessions — need re-authorization + if valid_items.empty? + return { status: :warn, meta: t("settings.providers.meta.reconsent_required"), last_synced_at: last_synced_at } + end + + expiring = valid_items.find do |item| + item.session_expires_at.present? && item.session_expires_at < 7.days.from_now + end + + if expiring + days = [ ((expiring.session_expires_at - Time.current) / 1.day).ceil, 1 ].max + return { status: :warn, meta: t("settings.providers.meta.reconsent_needed", count: days), last_synced_at: last_synced_at } + end + + return { status: :warn, meta: t("settings.providers.meta.no_recent_sync"), last_synced_at: last_synced_at } if health[:stale] + + if last_synced_at.present? + { status: :ok, meta: t("settings.providers.meta.last_synced", time: concise_time_ago(last_synced_at)), last_synced_at: last_synced_at } + else + { status: :ok, last_synced_at: nil } + end + end + def not_self_hosted? !self_hosted? end diff --git a/app/javascript/controllers/providers_filter_controller.js b/app/javascript/controllers/providers_filter_controller.js new file mode 100644 index 000000000..54004b2f6 --- /dev/null +++ b/app/javascript/controllers/providers_filter_controller.js @@ -0,0 +1,68 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="providers-filter" +// Filters provider cards by free-text query and a chip-selected kind. +// Updates the visible-count target on the section heading and toggles +// an empty-state target when no card matches. +export default class extends Controller { + static targets = ["input", "chip", "card", "empty", "count"]; + static values = { kind: { type: String, default: "all" } }; + + connect() { + this.syncChipState(); + } + + filter() { + const query = this.hasInputTarget + ? this.inputTarget.value.toLocaleLowerCase().trim() + : ""; + const activeKind = this.kindValue; + let visibleCount = 0; + + this.cardTargets.forEach((card) => { + const name = card.dataset.providerName ?? ""; + const region = card.dataset.providerRegion ?? ""; + const kind = card.dataset.providerKind ?? ""; + const haystack = `${name} ${region} ${kind}`; + const matchesQuery = !query || haystack.includes(query); + const matchesKind = activeKind === "all" || kind === activeKind; + const visible = matchesQuery && matchesKind; + card.classList.toggle("hidden", !visible); + if (visible) visibleCount++; + }); + + if (this.hasCountTarget) { + this.countTarget.textContent = visibleCount; + } + + if (this.hasEmptyTarget) { + this.emptyTarget.classList.toggle("hidden", visibleCount > 0); + } + } + + selectChip(event) { + this.kindValue = event.currentTarget.dataset.kind ?? "all"; + this.syncChipState(); + this.filter(); + } + + clear() { + if (this.hasInputTarget) this.inputTarget.value = ""; + this.kindValue = "all"; + this.syncChipState(); + this.filter(); + if (this.hasInputTarget) this.inputTarget.focus(); + } + + syncChipState() { + if (!this.hasChipTarget) return; + this.chipTargets.forEach((chip) => { + const active = chip.dataset.kind === this.kindValue; + chip.classList.toggle("bg-container", active); + chip.classList.toggle("shadow-border-xs", active); + chip.classList.toggle("text-primary", active); + chip.classList.toggle("text-secondary", !active); + chip.setAttribute("aria-pressed", active ? "true" : "false"); + }); + } +} diff --git a/app/jobs/sync_all_providers_job.rb b/app/jobs/sync_all_providers_job.rb new file mode 100644 index 000000000..431b77cad --- /dev/null +++ b/app/jobs/sync_all_providers_job.rb @@ -0,0 +1,9 @@ +class SyncAllProvidersJob < ApplicationJob + queue_as :high_priority + sidekiq_options lock: :until_executed, lock_args: ->(args) { [ args.first ] }, on_conflict: :log + + def perform(family_id) + family = Family.find_by(id: family_id) + family&.sync_later + end +end diff --git a/app/models/brex_account.rb b/app/models/brex_account.rb new file mode 100644 index 000000000..2d3d5a111 --- /dev/null +++ b/app/models/brex_account.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +class BrexAccount < ApplicationRecord + include CurrencyNormalizable + + CARD_PRIMARY_ACCOUNT_ID = "card_primary" + + encrypts :raw_payload + encrypts :raw_transactions_payload + + belongs_to :brex_item + + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + + validates :name, :currency, presence: true + validates :account_id, uniqueness: { scope: :brex_item_id } + validates :account_kind, inclusion: { in: %w[cash card] } + + def self.card_account_id + CARD_PRIMARY_ACCOUNT_ID + end + + def self.kind_for(account_data) + return account_data.account_kind if account_data.respond_to?(:account_kind) + + data = account_data.with_indifferent_access + kind = data[:account_kind].presence || data[:kind].presence || "cash" + kind.to_s == "credit_card" ? "card" : kind.to_s + end + + def self.name_for(account_data) + data = account_data.with_indifferent_access + kind = kind_for(data) + + if kind == "card" + data[:name].presence || I18n.t("brex_items.default_card_name", default: "Brex Card") + else + data[:name].presence || data[:display_name].presence || I18n.t("brex_items.default_cash_name", id: data[:id], default: "Brex Cash #{data[:id]}") + end + end + + def self.currency_for(account_data) + data = account_data.with_indifferent_access + currency_code_from_money(data[:current_balance] || data[:available_balance] || data[:account_limit]) + end + + def self.default_account_type_for(account_data) + kind_for(account_data) == "card" ? "CreditCard" : "Depository" + end + + def self.default_accountable_attributes(accountable_type) + case accountable_type + when "CreditCard" + { subtype: CreditCard::DEFAULT_SUBTYPE } + when "Depository" + { subtype: Depository::DEFAULT_SUBTYPE } + else + {} + end + end + + def self.money_to_decimal(money_payload) + return nil if money_payload.blank? + + payload = money_payload.is_a?(Hash) ? money_payload.with_indifferent_access : { amount: money_payload, currency: "USD" } + amount = payload[:amount] + return nil if amount.nil? + + currency = currency_code_from_money(payload) + divisor = Money::Currency.new(currency).minor_unit_conversion + BigDecimal(amount.to_s) / BigDecimal(divisor.to_s) + rescue Money::Currency::UnknownCurrencyError, ArgumentError + Rails.logger.warn("Invalid Brex money payload #{money_payload.inspect}, defaulting conversion to USD") + begin + safe_amount = BigDecimal(payload[:amount].to_s) + safe_amount / BigDecimal(Money::Currency.new("USD").minor_unit_conversion.to_s) + rescue ArgumentError, TypeError + BigDecimal("0") + end + end + + def self.currency_code_from_money(money_payload) + payload = money_payload.is_a?(Hash) ? money_payload.with_indifferent_access : {} + currency = payload[:currency].presence || "USD" + Money::Currency.new(currency).iso_code + rescue Money::Currency::UnknownCurrencyError + "USD" + end + + def self.sanitize_payload(payload) + case payload + when Array + payload.map { |value| sanitize_payload(value) } + when Hash + payload.each_with_object({}) do |(key, value), sanitized| + key_string = key.to_s + normalized_key = key_string.downcase + + if sensitive_number_key?(normalized_key) + sanitized["#{key_string}_last4"] = last_four(value) + elsif normalized_key == "card_metadata" + sanitized[key_string] = sanitize_card_metadata(value) + elsif sensitive_secret_key?(normalized_key) + sanitized[key_string] = "[FILTERED]" + else + sanitized[key_string] = sanitize_payload(value) + end + end + else + payload + end + end + + def self.last_four(value) + digits = value.to_s.gsub(/\D/, "") + digits.last(4) if digits.present? + end + + def self.sanitize_card_metadata(value) + return nil unless value.is_a?(Hash) + + metadata = value.with_indifferent_access + { + "card_id" => metadata[:card_id].presence || metadata[:id].presence, + "card_name" => metadata[:card_name].presence || metadata[:name].presence, + "card_type" => metadata[:card_type].presence || metadata[:type].presence, + "last_four" => metadata[:last_four].presence || metadata[:last4].presence || metadata[:card_last_four].presence + }.compact + end + + def current_account + account + end + + def linked_account + account + end + + def cash? + account_kind == "cash" + end + + def card? + account_kind == "card" + end + + def upsert_brex_snapshot!(account_snapshot) + snapshot = account_snapshot.with_indifferent_access + kind = snapshot[:account_kind].presence || snapshot[:kind].presence || "cash" + kind = "card" if kind.to_s == "credit_card" + + update!( + current_balance: self.class.money_to_decimal(snapshot[:current_balance]), + available_balance: self.class.money_to_decimal(snapshot[:available_balance]), + account_limit: self.class.money_to_decimal(snapshot[:account_limit]), + currency: self.class.currency_code_from_money(snapshot[:current_balance] || snapshot[:available_balance] || snapshot[:account_limit]), + name: self.class.name_for(snapshot.merge(account_kind: kind)), + account_id: snapshot[:id]&.to_s, + account_kind: kind, + account_status: snapshot[:status], + account_type: snapshot[:type], + provider: "brex", + institution_metadata: build_institution_metadata(snapshot, kind), + raw_payload: self.class.sanitize_payload(account_snapshot) + ) + end + + def upsert_brex_transactions_snapshot!(transactions_snapshot) + update!( + raw_transactions_payload: self.class.sanitize_payload(transactions_snapshot) + ) + end + + private + + def self.sensitive_number_key?(normalized_key) + normalized_key.in?(%w[account_number routing_number pan primary_account_number card_number]) + end + + def self.sensitive_secret_key?(normalized_key) + normalized_key.include?("token") || + normalized_key.include?("secret") || + normalized_key.in?(%w[api_key access_key authorization cvc cvv security_code]) + end + private_class_method :sensitive_number_key?, :sensitive_secret_key? + + def build_institution_metadata(snapshot, kind) + { + name: "Brex", + domain: "brex.com", + url: "https://brex.com", + account_kind: kind, + account_type: snapshot[:type], + primary: snapshot[:primary], + account_number_last4: self.class.last_four(snapshot[:account_number]), + routing_number_last4: self.class.last_four(snapshot[:routing_number]), + status: snapshot[:status], + current_statement_period: self.class.sanitize_payload(snapshot[:current_statement_period]) + }.compact + end +end diff --git a/app/models/brex_account/processor.rb b/app/models/brex_account/processor.rb new file mode 100644 index 000000000..67c8a4a7b --- /dev/null +++ b/app/models/brex_account/processor.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class BrexAccount::Processor + include CurrencyNormalizable + + attr_reader :brex_account + + def initialize(brex_account) + @brex_account = brex_account + end + + def process + unless brex_account.current_account.present? + Rails.logger.info "BrexAccount::Processor - No linked account for brex_account #{brex_account.id}, skipping processing" + return + end + + process_account! + process_transactions + rescue StandardError => e + Rails.logger.error "BrexAccount::Processor - Failed to process account #{brex_account.id}: #{e.message}" + report_exception(e, "account") + raise + end + + private + + def process_account! + account = brex_account.current_account + balance = brex_account.current_balance + currency = parse_currency(brex_account.currency) + + if balance.nil? + Rails.logger.warn "BrexAccount::Processor - current_balance is nil for brex_account #{brex_account.id}, defaulting to 0" + balance = 0 + end + + if currency.nil? + Rails.logger.warn "BrexAccount::Processor - currency parse failed for brex_account #{brex_account.id}: #{brex_account.currency.inspect}, defaulting to USD" + Sentry.capture_message("BrexAccount currency parse failed", level: :warning) do |scope| + scope.set_tags(brex_account_id: brex_account.id) + scope.set_context("brex_account", { + id: brex_account.id, + currency: brex_account.currency + }) + end + currency = "USD" + end + + account.update!( + balance: balance, + cash_balance: balance, + currency: currency + ) + + if account.accountable_type == "CreditCard" && brex_account.available_balance.present? + account.accountable.update!(available_credit: brex_account.available_balance) + end + end + + # Transaction import errors are logged and swallowed so balance sync can continue. + def process_transactions + BrexAccount::Transactions::Processor.new(brex_account).process + rescue StandardError => e + Rails.logger.error "BrexAccount::Processor - Failed to process transactions for brex_account #{brex_account.id}: #{e.message}" + Rails.logger.error Array(e.backtrace).first(10).join("\n") + report_exception(e, "transactions") + end + + def report_exception(error, context) + Sentry.capture_exception(error) do |scope| + scope.set_tags( + brex_account_id: brex_account.id, + context: context + ) + end + end +end diff --git a/app/models/brex_account/transactions/processor.rb b/app/models/brex_account/transactions/processor.rb new file mode 100644 index 000000000..da0a81e17 --- /dev/null +++ b/app/models/brex_account/transactions/processor.rb @@ -0,0 +1,83 @@ +class BrexAccount::Transactions::Processor + attr_reader :brex_account + + def initialize(brex_account) + @brex_account = brex_account + end + + def process + unless brex_account.raw_transactions_payload.present? + Rails.logger.info "BrexAccount::Transactions::Processor - No transactions in raw_transactions_payload for brex_account #{brex_account.id}" + return { success: true, total: 0, imported: 0, skipped: 0, failed: 0, errors: [], skipped_transactions: [] } + end + + total_count = brex_account.raw_transactions_payload.count + Rails.logger.info "BrexAccount::Transactions::Processor - Processing #{total_count} transactions for brex_account #{brex_account.id}" + + imported_count = 0 + failed_count = 0 + skipped_count = 0 + errors = [] + skipped = [] + + # Each entry is processed inside a transaction, but to avoid locking up the DB when + # there are hundreds or thousands of transactions, we process them individually. + brex_account.raw_transactions_payload.each_with_index do |transaction_data, index| + begin + result = BrexEntry::Processor.new( + transaction_data, + brex_account: brex_account + ).process + + if result == :skipped + skipped_count += 1 + skipped << { index: index, transaction_id: transaction_id_for(transaction_data), reason: "No linked account" } + elsif result.nil? + failed_count += 1 + errors << { index: index, transaction_id: transaction_id_for(transaction_data), error: "No transaction imported" } + else + imported_count += 1 + end + rescue ArgumentError => e + # Validation error - log and continue + failed_count += 1 + transaction_id = transaction_id_for(transaction_data) + error_message = "Validation error: #{e.message}" + Rails.logger.error "BrexAccount::Transactions::Processor - #{error_message} (transaction #{transaction_id})" + errors << { index: index, transaction_id: transaction_id, error: error_message } + rescue => e + # Unexpected error - log with full context and continue + failed_count += 1 + transaction_id = transaction_id_for(transaction_data) + error_message = "#{e.class}: #{e.message}" + Rails.logger.error "BrexAccount::Transactions::Processor - Error processing transaction #{transaction_id}: #{error_message}" + Rails.logger.error Array(e.backtrace).first(10).join("\n") + errors << { index: index, transaction_id: transaction_id, error: error_message } + end + end + + result = { + success: failed_count == 0, + total: total_count, + imported: imported_count, + skipped: skipped_count, + failed: failed_count, + errors: errors, + skipped_transactions: skipped + } + + if failed_count > 0 + Rails.logger.warn "BrexAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions" + else + Rails.logger.info "BrexAccount::Transactions::Processor - Successfully processed #{imported_count} transactions" + end + + result + end + + private + + def transaction_id_for(transaction_data) + transaction_data&.dig(:id) || transaction_data&.dig("id") || "unknown" + end +end diff --git a/app/models/brex_entry/processor.rb b/app/models/brex_entry/processor.rb new file mode 100644 index 000000000..03bbb8689 --- /dev/null +++ b/app/models/brex_entry/processor.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require "digest/md5" + +class BrexEntry::Processor + include CurrencyNormalizable + + def initialize(brex_transaction, brex_account:) + @brex_transaction = brex_transaction + @brex_account = brex_account + end + + def process + cached_external_id = nil + cached_external_id = external_id + + unless account.present? + Rails.logger.warn "BrexEntry::Processor - No linked account for brex_account #{brex_account.id}, skipping transaction #{cached_external_id}" + return :skipped + end + + import_adapter.import_transaction( + external_id: cached_external_id, + amount: amount, + currency: currency, + date: date, + name: name, + source: "brex", + merchant: merchant, + notes: notes, + extra: extra + ) + rescue ArgumentError => e + Rails.logger.error "BrexEntry::Processor - Validation error for transaction #{cached_external_id || safe_external_id}: #{e.message}" + raise + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + Rails.logger.error "BrexEntry::Processor - Failed to save transaction #{cached_external_id || safe_external_id}: #{e.message}" + raise StandardError.new("Failed to import transaction: #{e.message}") + rescue => e + Rails.logger.error "BrexEntry::Processor - Unexpected error processing transaction #{cached_external_id || safe_external_id}: #{e.class} - #{e.message}" + Rails.logger.error Array(e.backtrace).join("\n") + raise StandardError.new("Unexpected error importing transaction: #{e.message}") + end + + private + attr_reader :brex_transaction, :brex_account + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def account + @account ||= brex_account.current_account + end + + def data + @data ||= brex_transaction.with_indifferent_access + end + + def external_id + id = data[:id].presence + raise ArgumentError, "Brex transaction missing required field 'id'" unless id + + "brex_#{id}" + end + + def safe_external_id + external_id + rescue ArgumentError + "brex_unknown" + end + + def name + data[:description].presence || + merchant_payload[:raw_descriptor].presence || + merchant_payload[:name].presence || + I18n.t("brex_items.entries.default_name") + end + + def notes + note_parts = [] + note_parts << data[:type] if data[:type].present? + note_parts << data[:expense_id] if data[:expense_id].present? + note_parts.any? ? note_parts.join(" - ") : nil + end + + def merchant + merchant_name = merchant_payload[:raw_descriptor].presence || merchant_payload[:name].presence + return @merchant if instance_variable_defined?(:@merchant) + return @merchant = nil if merchant_name.blank? + + merchant_name = merchant_name.to_s.strip + return @merchant = nil if merchant_name.blank? + + merchant_id = Digest::MD5.hexdigest(merchant_name.downcase) + + @merchant = import_adapter.find_or_create_merchant( + provider_merchant_id: "brex_merchant_#{merchant_id}", + name: merchant_name, + source: "brex" + ) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "BrexEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}" + @merchant = nil + end + + def merchant_payload + @merchant_payload ||= begin + payload = data[:merchant] + payload.is_a?(Hash) ? payload.with_indifferent_access : {} + end + end + + def amount + BrexAccount.money_to_decimal(data[:amount]) || BigDecimal("0") + rescue ArgumentError => e + Rails.logger.error "Failed to parse Brex transaction amount: #{data[:amount].inspect} - #{e.message}" + raise + end + + def currency + amount_currency = transaction_amount_currency + log_invalid_currency(amount_currency) if amount_currency.blank? && data[:amount].present? + + parse_currency(amount_currency) || + parse_currency(brex_account.currency) || + "USD" + end + + def transaction_amount_currency + amount_payload = data[:amount] + return nil unless amount_payload.is_a?(Hash) + + amount_payload.with_indifferent_access[:currency] + end + + def log_invalid_currency(currency_value) + Rails.logger.warn( + "Invalid Brex currency #{currency_value.inspect} for transaction #{data[:id].presence || 'unknown'} " \ + "on brex_account #{brex_account.id} amount=#{data[:amount].inspect} account_currency=#{brex_account.currency.inspect}; defaulting to fallback" + ) + end + + def date + date_value = data[:posted_at_date].presence || data[:initiated_at_date].presence + + case date_value + when String + Date.parse(date_value) + when Integer, Float + Time.at(date_value).to_date + when Time, DateTime + date_value.to_date + when Date + date_value + else + raise ArgumentError, "Invalid date format: #{date_value.inspect}" + end + rescue ArgumentError, TypeError => e + Rails.logger.error("Failed to parse Brex transaction date '#{date_value}': #{e.message}") + raise ArgumentError, "Unable to parse transaction date: #{date_value.inspect}" + end + + def extra + { + brex: { + transaction_id: data[:id], + account_kind: brex_account.account_kind, + type: data[:type], + card_id: data[:card_id], + transfer_id: data[:transfer_id], + expense_id: data[:expense_id], + card_transaction_operation_reference_id: data[:card_transaction_operation_reference_id], + initiated_at_date: data[:initiated_at_date], + posted_at_date: data[:posted_at_date], + merchant: BrexAccount.sanitize_payload(data[:merchant]) + }.compact + } + end +end diff --git a/app/models/brex_item.rb b/app/models/brex_item.rb new file mode 100644 index 000000000..58c528d11 --- /dev/null +++ b/app/models/brex_item.rb @@ -0,0 +1,186 @@ +class BrexItem < ApplicationRecord + include Syncable, Provided, Unlinking + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + encrypts :token, deterministic: true + encrypts :raw_payload + + def self.encryption_ready? + ActiveRecordEncryptionConfig.ready? + end + + validates :name, presence: true + validates :token, presence: true, on: :create + validate :base_url_must_be_official_brex_url + validate :token_cannot_be_blank_when_changed + before_validation :normalize_token + before_validation :normalize_base_url + + belongs_to :family + has_one_attached :logo, dependent: :purge_later + + has_many :brex_accounts, dependent: :destroy + has_many :accounts, through: :brex_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + scope :with_credentials, -> { where.not(token: [ nil, "" ]) } + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def import_latest_brex_data(sync_start_date: nil) + provider = brex_provider + unless provider + Rails.logger.error "BrexItem #{id} - Cannot import: provider is not configured" + raise Provider::Brex::BrexError.new("Brex provider is not configured", :not_configured) + end + + BrexItem::Importer.new(self, brex_provider: provider, sync_start_date: sync_start_date).import + rescue => e + Rails.logger.error "BrexItem #{id} - Failed to import data: #{e.message}" + raise + end + + def process_accounts + return [] if brex_accounts.empty? + + results = [] + brex_accounts.joins(:account).includes(:account).merge(Account.visible).each do |brex_account| + begin + result = BrexAccount::Processor.new(brex_account).process + results << { brex_account_id: brex_account.id, success: true, result: result } + rescue => e + Rails.logger.error "BrexItem #{id} - Failed to process account #{brex_account.id}: #{e.message}" + results << { brex_account_id: brex_account.id, success: false, error: e.message } + end + end + + results + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + results = [] + accounts.visible.each do |account| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue => e + Rails.logger.error "BrexItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" + results << { account_id: account.id, success: false, error: e.message } + end + end + + results + end + + def upsert_brex_snapshot!(accounts_snapshot) + update!(raw_payload: BrexAccount.sanitize_payload(accounts_snapshot)) + end + + def has_completed_initial_setup? + # Setup is complete if we have any linked accounts + accounts.any? + end + + def sync_status_summary + total_accounts = total_accounts_count + linked_count = linked_accounts_count + unlinked_count = unlinked_accounts_count + + if total_accounts == 0 + I18n.t("brex_items.sync_status.no_accounts") + elsif unlinked_count == 0 + I18n.t("brex_items.sync_status.all_synced", count: linked_count) + else + I18n.t("brex_items.sync_status.partial_setup", synced: linked_count, pending: unlinked_count) + end + end + + def linked_accounts_count + brex_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + brex_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + brex_accounts.count + end + + def institution_display_name + institution_name.presence || institution_domain.presence || name + end + + def connected_institutions + brex_accounts.where.not(institution_metadata: nil) + .pluck(:institution_metadata) + .compact + .uniq { |inst| inst["name"] || inst["institution_name"] } + end + + def institution_summary + institutions = connected_institutions + case institutions.count + when 0 + I18n.t("brex_items.institution_summary.none") + when 1 + name = institutions.first["name"] || + institutions.first["institution_name"] || + I18n.t("brex_items.institution_summary.count", count: 1) + I18n.t("brex_items.institution_summary.one", name: name) + else + I18n.t("brex_items.institution_summary.count", count: institutions.count) + end + end + + def credentials_configured? + token.to_s.strip.present? + end + + def effective_base_url + return Provider::Brex::DEFAULT_BASE_URL if base_url.blank? + + Provider::Brex.normalize_base_url(base_url) + end + + private + def normalize_token + self.token = token&.strip + end + + def token_cannot_be_blank_when_changed + return unless persisted? && will_save_change_to_token? && token.blank? + + errors.add(:token, :blank) + end + + def normalize_base_url + stripped = base_url.to_s.strip + if stripped.blank? + self.base_url = nil + return + end + + normalized = Provider::Brex.normalize_base_url(stripped) + self.base_url = normalized if normalized.present? + end + + def base_url_must_be_official_brex_url + return if base_url.blank? || Provider::Brex.allowed_base_url?(base_url) + + errors.add(:base_url, :official_hosts_only) + end +end diff --git a/app/models/brex_item/account_flow.rb b/app/models/brex_item/account_flow.rb new file mode 100644 index 000000000..1c897e9e7 --- /dev/null +++ b/app/models/brex_item/account_flow.rb @@ -0,0 +1,655 @@ +# frozen_string_literal: true + +class BrexItem::AccountFlow + CACHE_TTL = 5.minutes + + class NoApiTokenError < StandardError; end + class AccountNotFoundError < StandardError; end + class InvalidAccountNameError < StandardError; end + class AccountAlreadyLinkedError < StandardError; end + + NavigationResult = Data.define(:target, :flash_type, :message) + + SelectionResult = Data.define(:status, :brex_item, :available_accounts, :accountable_type, :message) do + def success? = status == :success + def setup_required? = status == :setup_required + def provider_error? = status.in?([ :api_error, :unexpected_error ]) + end + + LinkAccountsResult = Data.define(:created_accounts, :already_linked_names, :invalid_account_ids) do + def created_count = created_accounts.count + def already_linked_count = already_linked_names.count + def invalid_count = invalid_account_ids.count + end + + SetupResult = Data.define(:created_accounts, :skipped_count, :failed_count) do + def created_count = created_accounts.count + end + + SetupCompletion = Data.define(:success, :message) do + def success? = success + end + + attr_reader :family, :brex_item_id, :brex_item, :credentialed_items + + def initialize(family:, brex_item_id: nil, brex_item: nil) + @family = family + @brex_item_id = brex_item_id + @credentialed_items = family.brex_items.active.with_credentials.ordered.select(&:credentials_configured?) + @brex_item = brex_item || resolve_brex_item + end + + def self.cache_key(family, brex_item) + "brex_accounts_#{family.id}_#{brex_item.id}" + end + + def self.cache_sensitive_update?(permitted_params) + permitted_params.key?(:token) || permitted_params.key?(:base_url) + end + + def self.update_item_with_cache_expiration(brex_item, family:, attributes:) + expire_accounts_cache = cache_sensitive_update?(attributes) + updated = brex_item.update(attributes) + + Rails.cache.delete(cache_key(family, brex_item)) if updated && expire_accounts_cache + + updated + end + + def selected? + brex_item.present? + end + + def selection_required? + credentialed_items.count > 1 && brex_item_id.blank? + end + + def preload_payload + return selection_error_payload if !selected? + return { success: false, error: "no_credentials", has_accounts: false } unless brex_item&.credentials_configured? + + cached_accounts = Rails.cache.read(cache_key) + cached = !cached_accounts.nil? + available_accounts = cached ? cached_accounts : fetch_and_cache_accounts + + { success: true, has_accounts: available_accounts.any?, cached: cached } + rescue NoApiTokenError + { success: false, error: "no_api_token", has_accounts: false } + rescue Provider::Brex::BrexError => e + Rails.logger.error("Brex preload error: #{e.message}") + { success: false, error: "api_error", error_message: e.message, has_accounts: nil } + rescue StandardError => e + Rails.logger.error("Unexpected error preloading Brex accounts: #{e.class}: #{e.message}") + { success: false, error: "unexpected_error", error_message: I18n.t("brex_items.errors.unexpected_error"), has_accounts: nil } + end + + def select_accounts_result(accountable_type:) + selection_result_for( + scope: "brex_items.select_accounts", + accountable_type: accountable_type, + empty_message_key: "no_accounts_found", + log_context: "select_accounts" + ) + end + + def select_existing_account_result(account:) + return linked_account_result if account.account_providers.exists? + + selection_result_for( + scope: "brex_items.select_existing_account", + accountable_type: account.accountable_type, + empty_message_key: "all_accounts_already_linked", + log_context: "select_existing_account" + ) + end + + def link_new_accounts_result(account_ids:, accountable_type:) + return navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.no_accounts_selected")) if account_ids.blank? + return navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.invalid_account_type")) unless supported_account_type?(accountable_type) + return navigation(:settings_providers, :alert, I18n.t("brex_items.link_accounts.select_connection")) unless selected? + + link_navigation_result(link_new_accounts!(account_ids: account_ids, accountable_type: accountable_type)) + rescue NoApiTokenError + navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.no_api_token")) + rescue Provider::Brex::BrexError => e + navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.api_error", message: e.message)) + rescue StandardError => e + Rails.logger.error("Brex account linking failed: #{e.class} - #{e.message}") + Rails.logger.error(Array(e.backtrace).first(10).join("\n")) + navigation(:new_account, :alert, I18n.t("brex_items.errors.unexpected_error")) + end + + def link_existing_account_result(account:, brex_account_id:) + return navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.missing_parameters")) if account.blank? || brex_account_id.blank? + return navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.account_already_linked")) if account.account_providers.exists? + return navigation(:settings_providers, :alert, I18n.t("brex_items.link_existing_account.select_connection")) unless selected? + + link_existing_account!(account: account, brex_account_id: brex_account_id) + + navigation(:return_to_or_accounts, :notice, I18n.t("brex_items.link_existing_account.success", account_name: account.name)) + rescue NoApiTokenError + navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.no_api_token")) + rescue AccountNotFoundError + navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.provider_account_not_found")) + rescue InvalidAccountNameError + navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.invalid_account_name")) + rescue AccountAlreadyLinkedError + navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.provider_account_already_linked")) + rescue Provider::Brex::BrexError => e + navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.api_error", message: e.message)) + rescue StandardError => e + Rails.logger.error("Brex existing account linking failed: #{e.class} - #{e.message}") + Rails.logger.error(Array(e.backtrace).first(10).join("\n")) + navigation(:accounts, :alert, I18n.t("brex_items.errors.unexpected_error")) + end + + def link_new_accounts!(account_ids:, accountable_type:) + raise ArgumentError, "Unsupported Brex account type: #{accountable_type}" unless supported_account_type?(accountable_type) + + created_accounts = [] + already_linked_names = [] + invalid_account_ids = [] + accounts_by_id = indexed_accounts + + ActiveRecord::Base.transaction do + account_ids.each do |account_id| + account_data = accounts_by_id[account_id.to_s] + next unless account_data + + account_name = BrexAccount.name_for(account_data) + + if account_name.blank? + invalid_account_ids << account_id + Rails.logger.warn "BrexItem::AccountFlow - Skipping account #{account_id} with blank name" + next + end + + brex_account = upsert_brex_account!(account_id, account_data) + + if brex_account.account_provider.present? + already_linked_names << account_name + next + end + + account = Account.create_and_sync( + { + family: family, + name: account_name, + balance: 0, + currency: BrexAccount.currency_for(account_data), + accountable_type: accountable_type, + accountable_attributes: BrexAccount.default_accountable_attributes(accountable_type) + }, + skip_initial_sync: true + ) + + AccountProvider.create!(account: account, provider: brex_account) + created_accounts << account + end + end + + brex_item.sync_later if created_accounts.any? + + LinkAccountsResult.new( + created_accounts: created_accounts, + already_linked_names: already_linked_names, + invalid_account_ids: invalid_account_ids + ) + end + + def link_existing_account!(account:, brex_account_id:) + account_data = indexed_accounts[brex_account_id.to_s] + raise AccountNotFoundError unless account_data + + account_name = BrexAccount.name_for(account_data) + raise InvalidAccountNameError if account_name.blank? + + brex_account = nil + + ActiveRecord::Base.transaction do + brex_account = upsert_brex_account!(brex_account_id, account_data) + raise AccountAlreadyLinkedError if brex_account.account_provider.present? + + AccountProvider.create!(account: account, provider: brex_account) + end + + brex_item.sync_later + + brex_account + end + + def import_accounts_from_api_if_needed + raise NoApiTokenError unless brex_item.credentials_configured? + + available_accounts = fetch_accounts + return nil if available_accounts.empty? + + existing_accounts = brex_item.brex_accounts.index_by(&:account_id) + + available_accounts.each do |account_data| + account_id = account_data.with_indifferent_access[:id].to_s + account_name = BrexAccount.name_for(account_data) + next if account_id.blank? || account_name.blank? + + brex_account = existing_accounts[account_id] + next if brex_account.present? && !brex_account_snapshot_changed?(brex_account, account_data) + + upsert_brex_account!(account_id, account_data) + end + + nil + end + + def unlinked_brex_accounts + brex_item.brex_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + end + + def account_type_options + supported_types = Provider::BrexAdapter.supported_account_types + account_type_keys = { + "depository" => "Depository", + "credit_card" => "CreditCard", + "investment" => "Investment", + "loan" => "Loan", + "other_asset" => "OtherAsset" + } + + options = account_type_keys.filter_map do |key, type| + next unless supported_types.include?(type) + + [ I18n.t("brex_items.setup_accounts.account_types.#{key}"), type ] + end + + [ [ I18n.t("brex_items.setup_accounts.account_types.skip"), "skip" ] ] + options + end + + def subtype_options + supported_types = Provider::BrexAdapter.supported_account_types + all_subtype_options = { + "Depository" => { + label: I18n.t("brex_items.setup_accounts.subtype_labels.depository"), + options: translate_subtypes("depository", Depository::SUBTYPES) + }, + "CreditCard" => { + label: I18n.t("brex_items.setup_accounts.subtype_labels.credit_card"), + options: [], + message: I18n.t("brex_items.setup_accounts.subtype_messages.credit_card") + }, + "Investment" => { + label: I18n.t("brex_items.setup_accounts.subtype_labels.investment"), + options: translate_subtypes("investment", Investment::SUBTYPES) + }, + "Loan" => { + label: I18n.t("brex_items.setup_accounts.subtype_labels.loan"), + options: translate_subtypes("loan", Loan::SUBTYPES) + }, + "OtherAsset" => { + label: I18n.t("brex_items.setup_accounts.subtype_labels.other_asset").presence, + options: [], + message: I18n.t("brex_items.setup_accounts.subtype_messages.other_asset") + } + } + + all_subtype_options.slice(*supported_types) + end + + def complete_setup!(account_types:, account_subtypes:) + created_accounts = [] + skipped_count = 0 + valid_types = Provider::BrexAdapter.supported_account_types + + failed_count = 0 + + submitted_brex_accounts = brex_item.brex_accounts + .where(id: account_types.keys) + .includes(:account_provider) + .index_by { |brex_account| brex_account.id.to_s } + + account_types.each do |brex_account_id, selected_type| + if selected_type == "skip" || selected_type.blank? + skipped_count += 1 + next + end + + unless valid_types.include?(selected_type) + Rails.logger.warn("Invalid account type '#{selected_type}' submitted for Brex account #{brex_account_id}") + skipped_count += 1 + next + end + + brex_account = submitted_brex_accounts[brex_account_id.to_s] + unless brex_account + Rails.logger.warn("Brex account #{brex_account_id} not found for item #{brex_item.id}") + next + end + + if brex_account.account_provider.present? + Rails.logger.info("Brex account #{brex_account_id} already linked, skipping") + next + end + + selected_subtype = selected_subtype_for( + selected_type: selected_type, + submitted_subtype: account_subtypes[brex_account_id] + ) + + begin + ActiveRecord::Base.transaction do + account = Account.create_and_sync( + { + family: family, + name: brex_account.name, + balance: brex_account.current_balance || 0, + currency: brex_account.currency.presence || family.currency, + accountable_type: selected_type, + accountable_attributes: selected_subtype.present? ? { subtype: selected_subtype } : {} + }, + skip_initial_sync: true + ) + + AccountProvider.create!(account: account, provider: brex_account) + created_accounts << account + end + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + failed_count += 1 + Rails.logger.error("Brex account setup failed for #{brex_account_id}: #{e.class} - #{e.message}") + Rails.logger.error(Array(e.backtrace).first(10).join("\n")) + end + end + + brex_item.sync_later if created_accounts.any? + + SetupResult.new(created_accounts: created_accounts, skipped_count: skipped_count, failed_count: failed_count) + end + + def import_accounts_with_user_facing_error + import_accounts_from_api_if_needed + rescue NoApiTokenError + I18n.t("brex_items.setup_accounts.no_api_token") + rescue Provider::Brex::BrexError => e + Rails.logger.error("Brex API error: #{e.message}") + I18n.t("brex_items.setup_accounts.api_error", message: e.message) + rescue StandardError => e + Rails.logger.error("Unexpected error fetching Brex accounts: #{e.class}: #{e.message}") + I18n.t("brex_items.setup_accounts.api_error", message: I18n.t("brex_items.errors.unexpected_error")) + end + + def complete_setup_result(account_types:, account_subtypes:) + result = complete_setup!(account_types: account_types, account_subtypes: account_subtypes) + + SetupCompletion.new(success: result.failed_count.zero? || result.created_count.positive?, message: setup_notice(result)) + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + Rails.logger.error("Brex account setup failed: #{e.class} - #{e.message}") + Rails.logger.error(Array(e.backtrace).first(10).join("\n")) + SetupCompletion.new( + success: false, + message: I18n.t("brex_items.complete_account_setup.creation_failed", error: e.message) + ) + rescue StandardError => e + Rails.logger.error("Brex account setup failed unexpectedly: #{e.class} - #{e.message}") + Rails.logger.error(Array(e.backtrace).first(10).join("\n")) + SetupCompletion.new( + success: false, + message: I18n.t( + "brex_items.complete_account_setup.creation_failed", + error: I18n.t("brex_items.complete_account_setup.unexpected_error") + ) + ) + end + + private + + def selection_error_payload + return { success: false, error: "no_credentials", has_accounts: false } unless selection_required? + + { + success: false, + error: "select_connection", + error_message: I18n.t("brex_items.select_accounts.select_connection"), + has_accounts: nil + } + end + + def selection_failure_result(scope, accountable_type: nil) + if selection_required? + SelectionResult.new( + status: :select_connection, + brex_item: nil, + available_accounts: [], + accountable_type: accountable_type, + message: I18n.t("#{scope}.select_connection") + ) + else + SelectionResult.new( + status: :setup_required, + brex_item: nil, + available_accounts: [], + accountable_type: accountable_type, + message: I18n.t("#{scope}.no_credentials_configured") + ) + end + end + + def selection_result_for(scope:, accountable_type:, empty_message_key:, log_context:) + return selection_failure_result(scope, accountable_type: accountable_type) unless selected? + + available_accounts = filter_accounts(unlinked_available_accounts, accountable_type) + if available_accounts.empty? + return selection_result( + status: :empty, + accountable_type: accountable_type, + message: I18n.t("#{scope}.#{empty_message_key}") + ) + end + + selection_result(status: :success, accountable_type: accountable_type, available_accounts: available_accounts) + rescue NoApiTokenError + selection_result( + status: :no_api_token, + accountable_type: accountable_type, + message: I18n.t("#{scope}.no_api_token") + ) + rescue Provider::Brex::BrexError => e + Rails.logger.error("Brex API error in #{log_context}: #{e.message}") + selection_result(status: :api_error, accountable_type: accountable_type, message: e.message) + rescue StandardError => e + Rails.logger.error("Unexpected error in #{log_context}: #{e.class}: #{e.message}") + selection_result( + status: :unexpected_error, + accountable_type: accountable_type, + message: I18n.t("#{scope}.unexpected_error") + ) + end + + def selection_result(status:, accountable_type:, available_accounts: [], message: nil) + SelectionResult.new( + status: status, + brex_item: brex_item, + available_accounts: available_accounts, + accountable_type: accountable_type, + message: message + ) + end + + def linked_account_result + SelectionResult.new( + status: :account_already_linked, + brex_item: brex_item, + available_accounts: [], + accountable_type: nil, + message: I18n.t("brex_items.select_existing_account.account_already_linked") + ) + end + + def link_navigation_result(result) + if result.invalid_count.positive? && result.created_count.zero? && result.already_linked_count.zero? + navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.invalid_account_names", count: result.invalid_count)) + elsif result.invalid_count.positive? && (result.created_count.positive? || result.already_linked_count.positive?) + navigation( + :return_to_or_accounts, + :alert, + I18n.t( + "brex_items.link_accounts.partial_invalid", + created_count: result.created_count, + already_linked_count: result.already_linked_count, + invalid_count: result.invalid_count + ) + ) + elsif result.created_count.positive? && result.already_linked_count.positive? + navigation( + :return_to_or_accounts, + :notice, + I18n.t( + "brex_items.link_accounts.partial_success", + created_count: result.created_count, + already_linked_count: result.already_linked_count, + already_linked_names: result.already_linked_names.join(", ") + ) + ) + elsif result.created_count.positive? + navigation(:return_to_or_accounts, :notice, I18n.t("brex_items.link_accounts.success", count: result.created_count)) + elsif result.already_linked_count.positive? + navigation( + :return_to_or_accounts, + :alert, + I18n.t( + "brex_items.link_accounts.all_already_linked", + count: result.already_linked_count, + names: result.already_linked_names.join(", ") + ) + ) + else + navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.link_failed")) + end + end + + def navigation(target, flash_type, message) + NavigationResult.new(target: target, flash_type: flash_type, message: message) + end + + def setup_notice(result) + if result.failed_count.positive? && result.created_count.positive? + I18n.t("brex_items.complete_account_setup.partial_success", created_count: result.created_count, failed_count: result.failed_count) + elsif result.skipped_count.positive? && result.created_count.positive? + I18n.t("brex_items.complete_account_setup.partial_skipped", created_count: result.created_count, skipped_count: result.skipped_count) + elsif result.failed_count.positive? + I18n.t("brex_items.complete_account_setup.creation_failed_count", count: result.failed_count) + elsif result.created_count.positive? + I18n.t("brex_items.complete_account_setup.success", count: result.created_count) + elsif result.skipped_count.positive? + I18n.t("brex_items.complete_account_setup.all_skipped") + else + I18n.t("brex_items.complete_account_setup.no_accounts") + end + end + + def resolve_brex_item + if brex_item_id.present? + item = family.brex_items.active.find_by(id: brex_item_id) + return item if item&.credentials_configured? + + return nil + end + + credentialed_items.first if credentialed_items.one? + end + + def cache_key + self.class.cache_key(family, brex_item) + end + + def fetch_accounts + provider = brex_item&.brex_provider + raise NoApiTokenError unless provider.present? + + accounts_data = provider.get_accounts + accounts_data[:accounts] || [] + end + + def accounts + cached_accounts = Rails.cache.read(cache_key) + return cached_accounts unless cached_accounts.nil? + + fetch_and_cache_accounts + end + + def fetch_and_cache_accounts + available_accounts = fetch_accounts + Rails.cache.write(cache_key, available_accounts, expires_in: CACHE_TTL) + available_accounts + end + + def unlinked_available_accounts + linked_account_ids = brex_item.brex_accounts + .joins(:account_provider) + .pluck("#{BrexAccount.table_name}.account_id") + .map(&:to_s) + accounts.reject { |account| linked_account_ids.include?(account.with_indifferent_access[:id].to_s) } + end + + def filter_accounts(accounts, accountable_type) + return [] unless Provider::BrexAdapter.supported_account_types.include?(accountable_type) + + accounts.select do |account| + case accountable_type + when "CreditCard" + BrexAccount.kind_for(account) == "card" + when "Depository" + BrexAccount.kind_for(account) == "cash" + else + true + end + end + end + + def indexed_accounts + accounts.index_by { |account| account.with_indifferent_access[:id].to_s } + end + + def upsert_brex_account!(account_id, account_data) + brex_account = brex_item.brex_accounts.find_or_initialize_by(account_id: account_id.to_s) + brex_account.upsert_brex_snapshot!(account_data) + brex_account + end + + def supported_account_type?(accountable_type) + Provider::BrexAdapter.supported_account_types.include?(accountable_type) + end + + def brex_account_snapshot_changed?(brex_account, account_data) + snapshot = account_data.with_indifferent_access + balances = snapshot.slice(:current_balance, :available_balance, :account_limit) + + expected = { + account_kind: BrexAccount.kind_for(snapshot), + account_status: snapshot[:status], + account_type: snapshot[:type], + available_balance: BrexAccount.money_to_decimal(balances[:available_balance]), + current_balance: BrexAccount.money_to_decimal(balances[:current_balance]), + account_limit: BrexAccount.money_to_decimal(balances[:account_limit]), + currency: BrexAccount.currency_code_from_money(balances[:current_balance] || balances[:available_balance] || balances[:account_limit]), + name: BrexAccount.name_for(snapshot), + raw_payload: BrexAccount.sanitize_payload(account_data) + } + + expected.any? { |attribute, value| brex_account.public_send(attribute) != value } + end + + def translate_subtypes(type_key, subtypes_hash) + subtypes_hash.map do |key, value| + [ + I18n.t("brex_items.setup_accounts.subtypes.#{type_key}.#{key}", default: value[:long] || key.to_s.humanize), + key + ] + end + end + + def selected_subtype_for(selected_type:, submitted_subtype:) + return CreditCard::DEFAULT_SUBTYPE if selected_type == "CreditCard" && submitted_subtype.blank? + return Depository::DEFAULT_SUBTYPE if selected_type == "Depository" && submitted_subtype.blank? + + submitted_subtype + end +end diff --git a/app/models/brex_item/importer.rb b/app/models/brex_item/importer.rb new file mode 100644 index 000000000..0f5eccc86 --- /dev/null +++ b/app/models/brex_item/importer.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +class BrexItem::Importer + attr_reader :brex_item, :brex_provider, :sync_start_date + + def initialize(brex_item, brex_provider:, sync_start_date: nil) + @brex_item = brex_item + @brex_provider = brex_provider + @sync_start_date = sync_start_date + end + + def import + Rails.logger.info "BrexItem::Importer - Starting import for item #{brex_item.id}" + + accounts_data = fetch_accounts_data + return failed_result("Failed to fetch accounts data") unless accounts_data + + store_item_snapshot(accounts_data) + + account_result = import_accounts(accounts_data[:accounts].to_a) + transaction_result = import_transactions + + brex_item.update!(status: :good) if account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero? + + { + success: account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero?, + **account_result, + **transaction_result + } + end + + private + + def fetch_accounts_data + accounts_data = brex_provider.get_accounts + + unless accounts_data.is_a?(Hash) + Rails.logger.error "BrexItem::Importer - Invalid accounts_data format: expected Hash, got #{accounts_data.class}" + return nil + end + + accounts_data + rescue Provider::Brex::BrexError => e + mark_requires_update_if_credentials_error(e) + Rails.logger.error "BrexItem::Importer - Brex API error: #{e.message} trace_id=#{e.trace_id}" + nil + rescue JSON::ParserError => e + Rails.logger.error "BrexItem::Importer - Failed to parse Brex API response: #{e.message}" + nil + rescue => e + Rails.logger.error "BrexItem::Importer - Unexpected error fetching accounts: #{e.class} - #{e.message}" + Rails.logger.error Array(e.backtrace).join("\n") + nil + end + + def store_item_snapshot(accounts_data) + brex_item.upsert_brex_snapshot!(accounts_data) + rescue => e + Rails.logger.error "BrexItem::Importer - Failed to store accounts snapshot: #{e.message}" + Sentry.capture_exception(e) do |scope| + scope.set_tags(brex_item_id: brex_item.id) + scope.set_context("brex_item_snapshot", { + brex_item_id: brex_item.id, + accounts_data: BrexAccount.sanitize_payload(accounts_data) + }) + end + raise + end + + def import_accounts(accounts) + accounts_updated = 0 + accounts_created = 0 + accounts_failed = 0 + + linked_account_ids = brex_item.brex_accounts + .joins(:account_provider) + .pluck("#{BrexAccount.table_name}.account_id") + .map(&:to_s) + all_existing_ids = brex_item.brex_accounts.pluck("#{BrexAccount.table_name}.account_id").map(&:to_s) + + accounts.each do |account_data| + snapshot = account_data.with_indifferent_access + account_id = snapshot[:id].to_s + account_name = BrexAccount.name_for(snapshot) + next if account_id.blank? || account_name.blank? + + if linked_account_ids.include?(account_id) + import_account(snapshot) + accounts_updated += 1 + elsif !all_existing_ids.include?(account_id) + import_account(snapshot) + accounts_created += 1 + all_existing_ids << account_id + end + rescue => e + accounts_failed += 1 + Rails.logger.error "BrexItem::Importer - Failed to import account #{account_id.presence || 'unknown'}: #{e.message}" + end + + { + accounts_updated: accounts_updated, + accounts_created: accounts_created, + accounts_failed: accounts_failed + } + end + + def import_account(account_data) + account_id = account_data[:id].to_s + raise ArgumentError, "Account ID is required" if account_id.blank? + + brex_account = brex_item.brex_accounts.find_or_initialize_by(account_id: account_id) + brex_account.name ||= BrexAccount.name_for(account_data) + brex_account.currency ||= BrexAccount.currency_code_from_money(account_data[:current_balance] || account_data[:available_balance] || account_data[:account_limit]) + brex_account.upsert_brex_snapshot!(account_data) + brex_account + end + + def import_transactions + transactions_imported = 0 + transactions_failed = 0 + + brex_item.brex_accounts.joins(:account).merge(Account.visible).find_each do |brex_account| + result = fetch_and_store_transactions(brex_account) + if result[:success] + transactions_imported += result[:transactions_count] + else + transactions_failed += 1 + end + rescue => e + transactions_failed += 1 + Rails.logger.error "BrexItem::Importer - Failed to fetch/store transactions for account #{brex_account.account_id}: #{e.message}" + end + + { + transactions_imported: transactions_imported, + transactions_failed: transactions_failed + } + end + + def fetch_and_store_transactions(brex_account) + start_date = determine_sync_start_date(brex_account) + Rails.logger.info "BrexItem::Importer - Fetching #{brex_account.account_kind} transactions for account #{brex_account.account_id} from #{start_date}" + + transactions_data = if brex_account.card? + brex_provider.get_primary_card_transactions(start_date: start_date) + else + brex_provider.get_cash_transactions(brex_account.account_id, start_date: start_date) + end + + unless transactions_data.is_a?(Hash) + Rails.logger.error "BrexItem::Importer - Invalid transactions_data format for account #{brex_account.account_id}" + return { success: false, transactions_count: 0, error: "Invalid response format" } + end + + transactions = transactions_data[:transactions].to_a + created_count = store_new_transactions(brex_account, transactions, window_start_date: start_date) + + { success: true, transactions_count: created_count } + rescue Provider::Brex::BrexError => e + mark_requires_update_if_credentials_error(e) + Rails.logger.error "BrexItem::Importer - Brex API error for account #{brex_account.account_id}: #{e.message} trace_id=#{e.trace_id}" + { success: false, transactions_count: 0, error: e.message } + rescue JSON::ParserError => e + Rails.logger.error "BrexItem::Importer - Failed to parse transaction response for account #{brex_account.account_id}: #{e.message}" + { success: false, transactions_count: 0, error: "Failed to parse response" } + rescue => e + Rails.logger.error "BrexItem::Importer - Unexpected error fetching transactions for account #{brex_account.account_id}: #{e.class} - #{e.message}" + Rails.logger.error Array(e.backtrace).join("\n") + { success: false, transactions_count: 0, error: "Unexpected error: #{e.message}" } + end + + def store_new_transactions(brex_account, transactions, window_start_date:) + existing_payload = brex_account.raw_transactions_payload.to_a + existing_transactions = transactions_in_window(existing_payload, window_start_date) + existing_ids = existing_transactions.map { |tx| tx.with_indifferent_access[:id] }.to_set + + new_transactions = transactions.select do |tx| + tx_id = tx.with_indifferent_access[:id] + tx_id.present? && !existing_ids.include?(tx_id) && transaction_in_window?(tx, window_start_date) + end + + return 0 if new_transactions.empty? && existing_transactions.count == existing_payload.count + + brex_account.upsert_brex_transactions_snapshot!(existing_transactions + new_transactions) + new_transactions.count + end + + def transactions_in_window(transactions, window_start_date) + transactions.select { |transaction| transaction_in_window?(transaction, window_start_date) } + end + + def transaction_in_window?(transaction, window_start_date) + return true if window_start_date.blank? + + transaction_date = transaction_date_for(transaction) + return true if transaction_date.blank? + + transaction_date >= window_start_date.to_date + end + + def transaction_date_for(transaction) + data = transaction.with_indifferent_access + date_value = data[:posted_at_date].presence || data[:initiated_at_date].presence || data[:posted_at].presence || data[:created_at].presence + + case date_value + when Date + date_value + when Time, DateTime + date_value.to_date + when String + Date.parse(date_value) + else + nil + end + rescue ArgumentError, TypeError + nil + end + + def determine_sync_start_date(brex_account) + return sync_start_date if sync_start_date.present? + + if brex_account.raw_transactions_payload.to_a.any? + brex_item.last_synced_at ? brex_item.last_synced_at - 7.days : 90.days.ago + else + account_baseline = brex_account.created_at || Time.current + [ account_baseline - 7.days, 90.days.ago ].max + end + end + + def mark_requires_update_if_credentials_error(error) + return unless error.error_type.in?([ :unauthorized, :access_forbidden ]) + + brex_item.update!(status: :requires_update) + rescue => update_error + Rails.logger.error "BrexItem::Importer - Failed to update item status: #{update_error.message}" + end + + def failed_result(error) + { + success: false, + error: error, + accounts_updated: 0, + accounts_created: 0, + accounts_failed: 0, + transactions_imported: 0, + transactions_failed: 0 + } + end +end diff --git a/app/models/brex_item/provided.rb b/app/models/brex_item/provided.rb new file mode 100644 index 000000000..6e4b22d14 --- /dev/null +++ b/app/models/brex_item/provided.rb @@ -0,0 +1,16 @@ +module BrexItem::Provided + extend ActiveSupport::Concern + + def brex_provider + return nil unless credentials_configured? + + base_url = effective_base_url + return nil unless base_url.present? + + Provider::Brex.new(token.to_s.strip, base_url: base_url) + end + + def syncer + BrexItem::Syncer.new(self) + end +end diff --git a/app/models/brex_item/syncer.rb b/app/models/brex_item/syncer.rb new file mode 100644 index 000000000..3e5de1686 --- /dev/null +++ b/app/models/brex_item/syncer.rb @@ -0,0 +1,148 @@ +class BrexItem::Syncer + include SyncStats::Collector + + SafeSyncError = Class.new(StandardError) + + attr_reader :brex_item + + def initialize(brex_item) + @brex_item = brex_item + end + + def perform_sync(sync) + sync_errors = [] + + # Phase 1: Import data from Brex API + update_status(sync, :importing_accounts) + import_result = brex_item.import_latest_brex_data(sync_start_date: sync.window_start_date) + sync_errors.concat(import_result_errors(import_result)) + + # Phase 2: Collect setup statistics + update_status(sync, :checking_account_configuration) + + linked_count = brex_item.brex_accounts.joins(:account_provider).count + unlinked_count = brex_item.brex_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .count + total_count = linked_count + unlinked_count + collect_brex_setup_stats( + sync, + total_count: total_count, + linked_count: linked_count, + unlinked_count: unlinked_count + ) + + # Set pending_account_setup if there are unlinked accounts + if unlinked_count.positive? + brex_item.update!(pending_account_setup: true) + update_status(sync, :accounts_need_setup, count: unlinked_count) + else + brex_item.update!(pending_account_setup: false) + end + + # Phase 3: Process transactions for linked accounts only + if linked_count.positive? + linked_accounts = brex_item.brex_accounts.joins(:account_provider) + update_status(sync, :processing_transactions) + mark_import_started(sync) + Rails.logger.info "BrexItem::Syncer - Processing #{linked_count} linked accounts" + process_results = brex_item.process_accounts + sync_errors.concat(result_failure_errors(process_results, category: :account_processing_error, message_key: :account_processing_failed)) + Rails.logger.info "BrexItem::Syncer - Finished processing accounts" + + # Phase 4: Schedule balance calculations for linked accounts + update_status(sync, :calculating_balances) + schedule_results = brex_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + sync_errors.concat(result_failure_errors(schedule_results, category: :account_sync_error, message_key: :account_sync_failed)) + + # Phase 5: Collect transaction statistics + account_ids = linked_accounts + .includes(account_provider: :account) + .filter_map { |ma| ma.current_account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "brex") + else + Rails.logger.info "BrexItem::Syncer - No linked accounts to process" + end + + # Mark sync health + collect_health_stats(sync, errors: sync_errors.presence) + rescue => e + safe_message = user_safe_error_message(e) + Rails.logger.error "BrexItem::Syncer - sync failed for Brex item #{brex_item.id}: #{e.class} - #{e.message}" + Rails.logger.error Array(e.backtrace).first(10).join("\n") + Sentry.capture_exception(e) do |scope| + scope.set_tags(brex_item_id: brex_item.id) + end + collect_health_stats(sync, errors: [ { message: safe_message, category: "sync_error" } ]) + raise SafeSyncError, safe_message + end + + def perform_post_sync + # no-op + end + + private + + def update_status(sync, key, **options) + return unless sync.respond_to?(:status_text) + + sync.update!(status_text: I18n.t("brex_items.syncer.#{key}", **options)) + end + + def collect_brex_setup_stats(sync, total_count:, linked_count:, unlinked_count:) + return {} unless sync.respond_to?(:sync_stats) + + setup_stats = { + "total_accounts" => total_count, + "linked_accounts" => linked_count, + "unlinked_accounts" => unlinked_count + } + + merge_sync_stats(sync, setup_stats) + setup_stats + end + + def import_result_errors(result) + return [] if result.is_a?(Hash) && result[:success] + + unless result.is_a?(Hash) + return [ sync_error(:import_error, :import_failed) ] + end + + errors = [] + accounts_failed = result[:accounts_failed].to_i + transactions_failed = result[:transactions_failed].to_i + + errors << sync_error(:account_import_error, :accounts_failed, count: accounts_failed) if accounts_failed.positive? + errors << sync_error(:transaction_import_error, :transactions_failed, count: transactions_failed) if transactions_failed.positive? + errors << sync_error(:import_error, :import_failed) if errors.empty? + errors + end + + def result_failure_errors(results, category:, message_key:) + failed_count = Array(results).count { |result| result.is_a?(Hash) && result[:success] == false } + return [] unless failed_count.positive? + + [ sync_error(category, message_key, count: failed_count) ] + end + + def sync_error(category, message_key, **options) + { + message: I18n.t("brex_items.syncer.#{message_key}", **options), + category: category.to_s + } + end + + def user_safe_error_message(error) + if error.is_a?(Provider::Brex::BrexError) && error.error_type.in?([ :unauthorized, :access_forbidden ]) + I18n.t("brex_items.syncer.credentials_invalid") + else + I18n.t("brex_items.syncer.failed") + end + end +end diff --git a/app/models/brex_item/unlinking.rb b/app/models/brex_item/unlinking.rb new file mode 100644 index 000000000..dcd976768 --- /dev/null +++ b/app/models/brex_item/unlinking.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module BrexItem::Unlinking + # Concern that encapsulates unlinking logic for a Brex item. + extend ActiveSupport::Concern + + # Idempotently remove all connections between this Brex item and local accounts. + # - Detaches any AccountProvider links for each BrexAccount + # - Detaches Holdings that point at the AccountProvider links + # Returns a per-account result payload for observability + def unlink_all!(dry_run: false) + results = [] + + brex_accounts.find_each do |provider_account| + link_ids = AccountProvider.where(provider_type: "BrexAccount", provider_id: provider_account.id).ids + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + links = AccountProvider.where(provider_type: "BrexAccount", provider_id: provider_account.id).to_a + link_ids = links.map(&:id) + result[:provider_link_ids] = link_ids + + # Detach holdings for any provider links found + if link_ids.any? + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) + end + + # Destroy all provider links + links.each do |ap| + ap.destroy! + end + end + rescue StandardError => e + Rails.logger.warn( + "BrexItem Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + # Record error for observability; continue with other accounts + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/concerns/encryptable.rb b/app/models/concerns/encryptable.rb index 0ec5ae923..a04c773de 100644 --- a/app/models/concerns/encryptable.rb +++ b/app/models/concerns/encryptable.rb @@ -6,11 +6,7 @@ module Encryptable # This allows encryption to be optional - if not configured, sensitive fields # are stored in plaintext (useful for development or legacy deployments). def encryption_ready? - creds_ready = Rails.application.credentials.active_record_encryption.present? - env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? - creds_ready || env_ready + ActiveRecordEncryptionConfig.explicitly_configured? end end end diff --git a/app/models/credit_card.rb b/app/models/credit_card.rb index 05bf7746a..4f42beaee 100644 --- a/app/models/credit_card.rb +++ b/app/models/credit_card.rb @@ -1,6 +1,8 @@ class CreditCard < ApplicationRecord include Accountable + DEFAULT_SUBTYPE = "credit_card" + SUBTYPES = { "credit_card" => { short: "Credit Card", long: "Credit Card" } }.freeze diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index db09f81b5..06ca97ec1 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -1,5 +1,5 @@ class DataEnrichment < ApplicationRecord belongs_to :enrichable, polymorphic: true - enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital", sophtron: "sophtron" } + enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", brex: "brex", indexa_capital: "indexa_capital", sophtron: "sophtron" } end diff --git a/app/models/depository.rb b/app/models/depository.rb index b788a6d4e..e78e70a8a 100644 --- a/app/models/depository.rb +++ b/app/models/depository.rb @@ -1,6 +1,8 @@ class Depository < ApplicationRecord include Accountable + DEFAULT_SUBTYPE = "checking" + SUBTYPES = { "checking" => { short: "Checking", long: "Checking" }, "savings" => { short: "Savings", long: "Savings" }, diff --git a/app/models/family.rb b/app/models/family.rb index 3a3547b58..647b8ecc4 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,7 +1,7 @@ class Family < ApplicationRecord include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable - include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, SophtronConnectable + include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, BrexConnectable, SophtronConnectable include IndexaCapitalConnectable DATE_FORMATS = [ diff --git a/app/models/family/brex_connectable.rb b/app/models/family/brex_connectable.rb new file mode 100644 index 000000000..e8bbe0955 --- /dev/null +++ b/app/models/family/brex_connectable.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Family::BrexConnectable + extend ActiveSupport::Concern + + included do + has_many :brex_items, dependent: :destroy + end + + def can_connect_brex? + true + end + + def create_brex_item!(token:, base_url: nil, item_name: nil) + brex_item = brex_items.create!( + name: item_name || "Brex Connection", + token: token, + base_url: base_url + ) + + brex_item.sync_later + + brex_item + end + + def has_brex_credentials? + brex_items.active.with_credentials.any?(&:credentials_configured?) + end +end diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 3eace0b06..7873c5ff0 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -17,6 +17,8 @@ class Family::Syncer coinbase_items coinstats_items mercury_items + brex_items + binance_items snaptrade_items sophtron_items ].freeze diff --git a/app/models/provider/brex.rb b/app/models/provider/brex.rb new file mode 100644 index 000000000..a3efb2865 --- /dev/null +++ b/app/models/provider/brex.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +class Provider::Brex + include HTTParty + extend SslConfigurable + + DEFAULT_BASE_URL = "https://api.brex.com" + STAGING_BASE_URL = "https://api-staging.brex.com" + ALLOWED_BASE_URLS = [ DEFAULT_BASE_URL, STAGING_BASE_URL ].freeze + DEFAULT_LIMIT = 1000 + # Transaction syncs are date-window bounded; this is only a runaway cursor guard. + MAX_PAGES = 25 + + headers "User-Agent" => "Sure Finance Brex Client" + default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options)) + + attr_reader :token, :base_url + + def initialize(token, base_url: DEFAULT_BASE_URL) + @token = token.to_s.strip + @base_url = self.class.normalize_base_url(base_url) + raise ArgumentError, "Brex base URL must be blank or one of: #{ALLOWED_BASE_URLS.join(', ')}" unless @base_url.present? + end + + def self.normalize_base_url(value) + stripped = value.to_s.strip + return DEFAULT_BASE_URL if stripped.blank? + + uri = URI.parse(stripped) + return nil unless uri.is_a?(URI::HTTPS) + return nil if uri.userinfo.present? + return nil if uri.query.present? || uri.fragment.present? + return nil unless uri.path.blank? || uri.path == "/" + return nil unless uri.port == 443 + + # This exact allowlist is the SSRF boundary; arbitrary Brex-like hosts are never accepted. + normalized = "#{uri.scheme.downcase}://#{uri.host.to_s.downcase}" + ALLOWED_BASE_URLS.include?(normalized) ? normalized : nil + rescue URI::InvalidURIError + nil + end + + def self.allowed_base_url?(value) + normalize_base_url(value).present? + end + + def get_accounts + cash_accounts = get_cash_accounts + card_accounts = get_card_accounts + + accounts = cash_accounts.map { |account| account.merge(account_kind: "cash") } + accounts << aggregate_card_account(card_accounts) if card_accounts.any? + + { + accounts: accounts, + cash_accounts: cash_accounts, + card_accounts: card_accounts + } + end + + def get_cash_accounts + get_paginated("/v2/accounts/cash").map { |account| account.with_indifferent_access.merge(account_kind: "cash") } + end + + def get_card_accounts + get_paginated("/v2/accounts/card").map { |account| account.with_indifferent_access.merge(account_kind: "card") } + end + + def get_cash_transactions(account_id, start_date: nil) + path = "/v2/transactions/cash/#{ERB::Util.url_encode(account_id.to_s)}" + { + transactions: get_paginated(path, params: posted_at_start_params(start_date)) + } + end + + def get_primary_card_transactions(start_date: nil) + { + transactions: get_paginated("/v2/transactions/card/primary", params: posted_at_start_params(start_date)) + } + end + + private + + def aggregate_card_account(card_accounts) + totals = %i[current_balance available_balance account_limit].index_with do |field| + sum_money(card_accounts.filter_map { |account| account.with_indifferent_access[field] }) + end + + { + id: BrexAccount.card_account_id, + name: "Brex Card", + account_kind: "card", + status: card_accounts.map { |account| account.with_indifferent_access[:status] }.compact.first, + card_accounts_count: card_accounts.count, + current_balance: totals[:current_balance], + available_balance: totals[:available_balance], + account_limit: totals[:account_limit], + raw_card_accounts: BrexAccount.sanitize_payload(card_accounts) + }.compact + end + + def sum_money(money_values) + normalized = money_values.compact + return nil if normalized.empty? + + currencies = normalized.map { |money| BrexAccount.currency_code_from_money(money) }.uniq + if currencies.many? + Rails.logger.warn "Brex API: Cannot aggregate card balances with mixed currencies: #{currencies.join(', ')}" + return nil + end + + currency = currencies.first + total = normalized.sum do |money| + money.with_indifferent_access[:amount].to_i + end + + { amount: total, currency: currency } + end + + def posted_at_start_params(start_date) + return {} if start_date.blank? + + { posted_at_start: rfc3339_start_date(start_date) } + end + + def get_paginated(path, params: {}) + records = [] + cursor = nil + seen_cursors = Set.new + page_count = 0 + + loop do + page_count += 1 + raise BrexError.new("Brex pagination exceeded #{MAX_PAGES} pages", :pagination_error) if page_count > MAX_PAGES + + page_params = params.compact.merge(limit: DEFAULT_LIMIT) + page_params[:cursor] = cursor if cursor.present? + + response_payload = get_json(path, params: page_params) + if response_payload.is_a?(Array) + records.concat(response_payload) + break + end + + page_records = extract_records(response_payload) + records.concat(page_records) + + next_cursor = response_payload.with_indifferent_access[:next_cursor] + break if next_cursor.blank? + + if seen_cursors.include?(next_cursor) + raise BrexError.new("Brex pagination returned a repeated cursor", :pagination_error) + end + + seen_cursors.add(next_cursor) + cursor = next_cursor + end + + records + end + + def get_json(path, params: {}) + query = params.present? ? "?#{URI.encode_www_form(params)}" : "" + request_path = "#{path}#{query}" + + response = self.class.get( + "#{base_url}#{request_path}", + headers: auth_headers + ) + + handle_response(response, path: path) + rescue BrexError + raise + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "Brex API: GET #{path} failed: #{e.class}: #{e.message}" + raise BrexError.new("Exception during GET request: #{e.message}", :request_failed) + rescue JSON::ParserError => e + Rails.logger.error "Brex API: invalid JSON for GET #{path}: #{e.message}" + raise BrexError.new("Invalid response from Brex API", :invalid_response) + rescue => e + Rails.logger.error "Brex API: Unexpected error during GET #{path}: #{e.class}: #{e.message}" + raise BrexError.new("Exception during GET request: #{e.message}", :request_failed) + end + + def extract_records(response_payload) + return response_payload if response_payload.is_a?(Array) + + payload = response_payload.with_indifferent_access + payload[:items] || + payload[:data] || + payload[:accounts] || + payload[:transactions] || + [] + end + + def auth_headers + { + "Authorization" => "Bearer #{token}", + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + def handle_response(response, path:) + trace_id = brex_trace_id(response) + + case response.code + when 200 + parse_json(response.body) + when 400 + Rails.logger.error "Brex API: bad request for #{path} trace_id=#{trace_id}" + raise BrexError.new("Bad request to Brex API", :bad_request, http_status: 400, trace_id: trace_id) + when 401 + Rails.logger.warn "Brex API: unauthorized for #{path} trace_id=#{trace_id}" + raise BrexError.new("Invalid Brex API token or account permissions", :unauthorized, http_status: 401, trace_id: trace_id) + when 403 + Rails.logger.warn "Brex API: access forbidden for #{path} trace_id=#{trace_id}" + raise BrexError.new("Access forbidden - check Brex API token scopes", :access_forbidden, http_status: 403, trace_id: trace_id) + when 404 + Rails.logger.warn "Brex API: resource not found for #{path} trace_id=#{trace_id}" + raise BrexError.new("Brex resource not found", :not_found, http_status: 404, trace_id: trace_id) + when 429 + Rails.logger.warn "Brex API: rate limited for #{path} trace_id=#{trace_id}" + raise BrexError.new("Brex rate limit exceeded. Please try again later.", :rate_limited, http_status: 429, trace_id: trace_id) + else + Rails.logger.error "Brex API: unexpected response code=#{response.code} path=#{path} trace_id=#{trace_id}" + raise BrexError.new("Failed to fetch data from Brex API: HTTP #{response.code}", :fetch_failed, http_status: response.code, trace_id: trace_id) + end + end + + def parse_json(body) + return {} if body.blank? + + JSON.parse(body, symbolize_names: true) + end + + def rfc3339_start_date(start_date) + time = + case start_date + when Time + start_date + when DateTime + start_date.to_time + when Date + start_date.to_time(:utc) + else + Time.zone.parse(start_date.to_s) + end + + raise ArgumentError, "Invalid start_date: #{start_date.inspect}" if time.nil? + + time.utc.iso8601 + end + + def brex_trace_id(response) + headers = response.respond_to?(:headers) ? response.headers : {} + headers["X-Brex-Trace-Id"].presence || + headers["x-brex-trace-id"].presence + end + + class BrexError < StandardError + attr_reader :error_type, :http_status, :trace_id + + def initialize(message, error_type = :unknown, http_status: nil, trace_id: nil) + super(message) + @error_type = error_type + @http_status = http_status + @trace_id = trace_id + end + end +end diff --git a/app/models/provider/brex_adapter.rb b/app/models/provider/brex_adapter.rb new file mode 100644 index 000000000..e76bee037 --- /dev/null +++ b/app/models/provider/brex_adapter.rb @@ -0,0 +1,132 @@ +class Provider::BrexAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + # Register this adapter with the factory + Provider::Factory.register("BrexAccount", self) + + def self.supported_account_types + %w[Depository CreditCard] + end + + # Returns connection configurations for this provider + def self.connection_configs(family:) + return [] unless family.can_connect_brex? + + brex_items = family.brex_items.active.with_credentials.ordered.select(&:credentials_configured?) + + return [ connection_config_for(nil) ] if brex_items.empty? + + brex_items.map { |brex_item| connection_config_for(brex_item) } + end + + def provider_name + "brex" + end + + # Build a Brex provider instance with family-specific credentials + # @param family [Family] The family to get credentials for (required) + # @return [Provider::Brex, nil] Returns nil if credentials are not configured + def self.build_provider(family: nil, brex_item_id: nil) + return nil unless family.present? + + brex_item = resolve_brex_item(family, brex_item_id) + return nil unless brex_item&.credentials_configured? + + base_url = brex_item.effective_base_url + return nil unless base_url.present? + + Provider::Brex.new( + brex_item.token.to_s.strip, + base_url: base_url + ) + end + + def self.connection_config_for(brex_item) + path_params = ->(extra = {}) do + brex_item.present? ? extra.merge(brex_item_id: brex_item.id) : extra + end + + { + key: brex_item.present? ? "brex_#{brex_item.id}" : "brex", + name: brex_item.present? ? I18n.t("brex_items.provider_connection.name", name: brex_item.name) : I18n.t("brex_items.provider_connection.default_name"), + description: brex_item.present? ? I18n.t("brex_items.provider_connection.description", name: brex_item.name) : I18n.t("brex_items.provider_connection.default_description"), + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_brex_items_path( + path_params.call(accountable_type: accountable_type, return_to: return_to) + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_brex_items_path( + path_params.call(account_id: account_id) + ) + } + } + end + private_class_method :connection_config_for + + def self.resolve_brex_item(family, brex_item_id) + if brex_item_id.present? + item = family.brex_items.active.find_by(id: brex_item_id) + return item if item&.credentials_configured? + + return nil + end + + credentialed_items = family.brex_items.active.with_credentials.ordered.select(&:credentials_configured?) + return credentialed_items.first if credentialed_items.one? + + nil + end + private_class_method :resolve_brex_item + + def sync_path + Rails.application.routes.url_helpers.sync_brex_item_path(item) + end + + def item + provider_account.brex_item + end + + def can_delete_holdings? + false + end + + def institution_domain + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + domain = metadata["domain"] + url = metadata["url"] + + # Derive domain from URL if missing + if domain.blank? && url.present? + begin + domain = URI.parse(url).host&.gsub(/^www\./, "") + rescue URI::InvalidURIError + Rails.logger.warn("Invalid institution URL for Brex account #{provider_account.id}: #{url}") + end + end + + domain + end + + def institution_name + metadata = provider_account.institution_metadata + + metadata&.dig("name") || item&.institution_name + end + + def institution_url + metadata = provider_account.institution_metadata + + metadata&.dig("url") || item&.institution_url + end + + def institution_color + metadata = provider_account.institution_metadata + + metadata&.dig("color") || item&.institution_color + end +end diff --git a/app/models/provider/metadata.rb b/app/models/provider/metadata.rb new file mode 100644 index 000000000..5960aed3c --- /dev/null +++ b/app/models/provider/metadata.rb @@ -0,0 +1,23 @@ +class Provider + module Metadata + REGISTRY = { + simplefin: { region: "US", kind: "Bank", maturity: :stable, logo_text: "SF", logo_bg: "bg-blue-600" }, + lunchflow: { region: "US", kind: "Bank", maturity: :stable, logo_text: "LF", logo_bg: "bg-orange-500" }, + enable_banking: { region: "EU", kind: "Bank", maturity: :beta, logo_text: "EB", logo_bg: "bg-purple-600" }, + coinstats: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CS", logo_bg: "bg-pink-600" }, + mercury: { region: "US", kind: "Bank", maturity: :beta, logo_text: "ME", logo_bg: "bg-cyan-600" }, + brex: { region: "US", kind: "Bank", maturity: :beta, logo_text: "BX", logo_bg: "bg-emerald-600" }, + coinbase: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CB", logo_bg: "bg-blue-500" }, + binance: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "BI", logo_bg: "bg-yellow-600" }, + snaptrade: { region: "US / CA", kind: "Investment", maturity: :beta, logo_text: "ST", logo_bg: "bg-green-600" }, + indexa_capital: { region: "ES", kind: "Investment", maturity: :alpha, logo_text: "IC", logo_bg: "bg-red-600" }, + sophtron: { region: "US", kind: "Bank", maturity: :alpha, logo_text: "SO", logo_bg: "bg-teal-600" }, + plaid: { region: "US", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" }, + plaid_eu: { name: "Plaid EU", region: "EU", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" } + }.freeze + + def self.for(provider_key) + REGISTRY[provider_key.to_sym] || { logo_text: provider_key.to_s.first(2).upcase, logo_bg: "bg-gray-500" } + end + end +end diff --git a/app/models/provider/plaid_adapter.rb b/app/models/provider/plaid_adapter.rb index ac4fd8187..f75c5d97f 100644 --- a/app/models/provider/plaid_adapter.rb +++ b/app/models/provider/plaid_adapter.rb @@ -80,13 +80,6 @@ def self.connection_configs(family:) # Configuration for Plaid US configure do - description <<~DESC - Setup instructions: - 1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials - 2. Your Client ID and Secret Key are required to enable Plaid bank sync for US/CA banks - 3. For production use, set environment to 'production', for testing use 'sandbox' - DESC - field :client_id, label: "Client ID", required: false, diff --git a/app/models/provider/plaid_eu_adapter.rb b/app/models/provider/plaid_eu_adapter.rb index 497bb13c4..6db5331a0 100644 --- a/app/models/provider/plaid_eu_adapter.rb +++ b/app/models/provider/plaid_eu_adapter.rb @@ -19,13 +19,6 @@ class Provider::PlaidEuAdapter # Configuration for Plaid EU configure do - description <<~DESC - Setup instructions: - 1. Visit the [Plaid Dashboard](https://dashboard.plaid.com/team/keys) to get your API credentials - 2. Your Client ID and Secret Key are required to enable Plaid bank sync for European banks - 3. For production use, set environment to 'production', for testing use 'sandbox' - DESC - field :client_id, label: "Client ID", required: false, diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb index e1a4b4100..6ab972be2 100644 --- a/app/models/provider_connection_status.rb +++ b/app/models/provider_connection_status.rb @@ -11,6 +11,7 @@ class ProviderConnectionStatus { key: "coinstats", type: "CoinstatsItem", association: :coinstats_items, accounts: :coinstats_accounts }, { key: "snaptrade", type: "SnaptradeItem", association: :snaptrade_items, accounts: :snaptrade_accounts, linked_accounts: :linked_accounts }, { key: "mercury", type: "MercuryItem", association: :mercury_items, accounts: :mercury_accounts }, + { key: "brex", type: "BrexItem", association: :brex_items, accounts: :brex_accounts }, { key: "sophtron", type: "SophtronItem", association: :sophtron_items, accounts: :sophtron_accounts }, { key: "indexa_capital", type: "IndexaCapitalItem", association: :indexa_capital_items, accounts: :indexa_capital_accounts } ].freeze diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb index 089d937eb..5cfb2fdf9 100644 --- a/app/models/provider_merchant.rb +++ b/app/models/provider_merchant.rb @@ -1,5 +1,5 @@ class ProviderMerchant < Merchant - enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital", sophtron: "sophtron" } + enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", brex: "brex", indexa_capital: "indexa_capital", sophtron: "sophtron" } validates :name, uniqueness: { scope: [ :source ] } validates :source, presence: true diff --git a/app/models/sophtron_account.rb b/app/models/sophtron_account.rb index d6f111b3a..e22769883 100644 --- a/app/models/sophtron_account.rb +++ b/app/models/sophtron_account.rb @@ -26,6 +26,9 @@ class SophtronAccount < ApplicationRecord has_one :account, through: :account_provider, source: :account has_one :linked_account, through: :account_provider, source: :account + scope :requires_manual_sync, -> { where(manual_sync: true) } + scope :automatic_sync, -> { where(manual_sync: false) } + validates :name, :currency, presence: true validate :has_balance # Returns the linked Maybe Account for this Sophtron account. @@ -35,6 +38,18 @@ def current_account account end + def institution_name + institution_metadata.to_h["name"].presence || sophtron_item&.institution_name + end + + def institution_user_institution_id + institution_metadata.to_h["user_institution_id"].presence || sophtron_item&.user_institution_id + end + + def institution_key + institution_user_institution_id.presence || institution_name + end + # Updates this SophtronAccount with fresh data from the Sophtron API. # # Maps Sophtron field names to our database schema and saves the changes. @@ -78,6 +93,7 @@ def upsert_sophtron_snapshot!(account_snapshot) customer_id: first_present(snapshot, :customer_id, :CustomerID) || customer_id, member_id: first_present(snapshot, :member_id, :MemberID) || member_id ) + self.manual_sync = true if new_record? && sophtron_item&.manual_sync? save! end diff --git a/app/models/sophtron_item.rb b/app/models/sophtron_item.rb index dc278fe65..aa6194681 100644 --- a/app/models/sophtron_item.rb +++ b/app/models/sophtron_item.rb @@ -43,6 +43,7 @@ def self.encryption_ready? validates :access_key, presence: true, on: :create belongs_to :family + belongs_to :current_job_sophtron_account, class_name: "SophtronAccount", optional: true has_one_attached :logo has_many :sophtron_accounts, dependent: :destroy @@ -83,12 +84,57 @@ def import_latest_sophtron_data(sync: nil) raise end - def process_accounts - return [] if sophtron_accounts.empty? + def linked_visible_sophtron_accounts + sophtron_accounts.joins(:account).merge(Account.visible) + end + + def automatic_sync_sophtron_accounts + return linked_visible_sophtron_accounts.none if manual_sync? + + linked_visible_sophtron_accounts.automatic_sync + end + + def manual_sync_required? + manual_sync? || sophtron_accounts.requires_manual_sync.exists? + end + + def manual_sync_sophtron_accounts + linked_accounts = sophtron_accounts.joins(:account_provider).order(:created_at, :id) + manual_accounts = linked_accounts.requires_manual_sync + + return manual_accounts if manual_accounts.exists? + + manual_sync? ? linked_accounts : linked_accounts.none + end + + def connected_institution_options + sophtron_accounts.order(:created_at, :id).filter_map do |sophtron_account| + institution_key = sophtron_account.institution_key + next if institution_key.blank? + + { + institution_key: institution_key, + name: sophtron_account.institution_name.presence || institution_display_name + } + end.uniq { |institution| institution[:institution_key].to_s } + end + + def manual_sync_required_for_institution?(institution_key) + institution_accounts = sophtron_accounts.select do |sophtron_account| + sophtron_account.institution_key.to_s == institution_key.to_s + end + + return manual_sync? if institution_accounts.empty? + + institution_accounts.any?(&:manual_sync?) || (manual_sync? && !sophtron_accounts.requires_manual_sync.exists?) + end + + def process_accounts(sophtron_accounts_scope: linked_visible_sophtron_accounts) + return [] if sophtron_accounts_scope.empty? results = [] # Only process accounts that are linked and have active status - sophtron_accounts.joins(:account).merge(Account.visible).each do |sophtron_account| + sophtron_accounts_scope.each do |sophtron_account| begin result = SophtronAccount::Processor.new(sophtron_account).process results << { sophtron_account_id: sophtron_account.id, success: true, result: result } @@ -102,12 +148,13 @@ def process_accounts results end - def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) - return [] if accounts.empty? + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil, sophtron_accounts_scope: linked_visible_sophtron_accounts) + linked_accounts = sophtron_accounts_scope.includes(:account_provider).filter_map(&:current_account) + return [] if linked_accounts.empty? results = [] # Only schedule syncs for active accounts - accounts.visible.each do |account| + linked_accounts.each do |account| begin account.sync_later( parent_sync: parent_sync, diff --git a/app/models/sophtron_item/importer.rb b/app/models/sophtron_item/importer.rb index 7a58308e6..403024cf6 100644 --- a/app/models/sophtron_item/importer.rb +++ b/app/models/sophtron_item/importer.rb @@ -139,7 +139,7 @@ def import transactions_imported = 0 transactions_failed = 0 - linked_accounts = sophtron_item.sophtron_accounts.joins(:account).merge(Account.visible) + linked_accounts = sophtron_item.automatic_sync_sophtron_accounts linked_accounts.each do |sophtron_account| begin result = fetch_and_store_transactions(sophtron_account) diff --git a/app/models/sophtron_item/syncer.rb b/app/models/sophtron_item/syncer.rb index 3f74f5b54..2c21be53f 100644 --- a/app/models/sophtron_item/syncer.rb +++ b/app/models/sophtron_item/syncer.rb @@ -44,7 +44,7 @@ def perform_sync(sync) collect_setup_stats(sync, provider_accounts: sophtron_item.sophtron_accounts) # Check for unlinked accounts - linked_accounts = sophtron_item.sophtron_accounts.joins(:account_provider) + linked_accounts = sophtron_item.automatic_sync_sophtron_accounts unlinked_accounts = sophtron_item.sophtron_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) # Set pending_account_setup if there are unlinked accounts @@ -61,7 +61,7 @@ def perform_sync(sync) sync.update!(status_text: t("sophtron_items.syncer.processing_transactions")) if sync.respond_to?(:status_text) mark_import_started(sync) Rails.logger.info "SophtronItem::Syncer - Processing #{linked_accounts.count} linked accounts" - sophtron_item.process_accounts + sophtron_item.process_accounts(sophtron_accounts_scope: linked_accounts) Rails.logger.info "SophtronItem::Syncer - Finished processing accounts" # Phase 4: Schedule balance calculations for linked accounts @@ -69,13 +69,15 @@ def perform_sync(sync) sophtron_item.schedule_account_syncs( parent_sync: sync, window_start_date: sync.window_start_date, - window_end_date: sync.window_end_date + window_end_date: sync.window_end_date, + sophtron_accounts_scope: linked_accounts ) # Phase 5: Collect transaction statistics account_ids = linked_accounts.includes(:account_provider).filter_map { |la| la.current_account&.id } collect_transaction_stats(sync, account_ids: account_ids, source: "sophtron") else + sync.update!(status_text: t("sophtron_items.syncer.manual_sync_required")) if sophtron_item.manual_sync_required? && sync.respond_to?(:status_text) Rails.logger.info "SophtronItem::Syncer - No linked accounts to process" end diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 2dcd38118..cc4efa70d 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -17,7 +17,7 @@ ) %> <% end %> -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? %> <%= render "empty" %> <% else %>
@@ -49,6 +49,10 @@ <%= render @mercury_items.sort_by(&:created_at) %> <% end %> + <% if @brex_items.any? %> + <%= render @brex_items.sort_by(&:created_at) %> + <% end %> + <% if @coinbase_items.any? %> <%= render @coinbase_items.sort_by(&:created_at) %> <% end %> diff --git a/app/views/brex_items/_api_error.html.erb b/app/views/brex_items/_api_error.html.erb new file mode 100644 index 000000000..8f05f813b --- /dev/null +++ b/app/views/brex_items/_api_error.html.erb @@ -0,0 +1,36 @@ +<%# locals: (error_message:, return_path:) %> +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + <% dialog.with_body do %> +
+
+ <%= icon("alert-circle", class: "text-destructive w-5 h-5 shrink-0 mt-0.5") %> +
+

<%= t(".heading") %>

+

<%= error_message %>

+
+
+ +
+

<%= t(".common_issues") %>

+
    +
  • <%= t(".invalid_token_label") %> <%= t(".invalid_token") %>
  • +
  • <%= t(".expired_credentials_label") %> <%= t(".expired_credentials") %>
  • +
  • <%= t(".permissions_label") %> <%= t(".permissions") %>
  • +
  • <%= t(".network_label") %> <%= t(".network") %>
  • +
  • <%= t(".service_label") %> <%= t(".service") %>
  • +
+
+ +
+ <%= link_to return_path.presence || settings_providers_path, + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-colors", + data: { turbo: false } do %> + <%= t(".settings_link") %> + <% end %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/brex_items/_brex_item.html.erb b/app/views/brex_items/_brex_item.html.erb new file mode 100644 index 000000000..4b97ceb84 --- /dev/null +++ b/app/views/brex_items/_brex_item.html.erb @@ -0,0 +1,132 @@ +<%# locals: (brex_item:) %> +<% render_locals = brex_item_render_locals( + brex_item, + sync_stats_map: @brex_sync_stats_map, + account_counts_map: @brex_account_counts_map, + institutions_count_map: @brex_institutions_count_map + ) %> +<% stats = render_locals[:stats] %> +<% unlinked_count = render_locals[:unlinked_count] %> +<% linked_count = render_locals[:linked_count] %> +<% total_count = render_locals[:total_count] %> +<% institutions_count = render_locals[:institutions_count] %> + +<%= tag.div id: dom_id(brex_item) do %> +
+ +
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
+ <% if brex_item.logo.attached? %> + <%= image_tag brex_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> + <% else %> +
+ <%= tag.p brex_item.name.first.upcase, class: "text-primary text-xs font-medium" %> +
+ <% end %> +
+ +
+
+ <%= tag.p brex_item.name, class: "font-medium text-primary" %> + <% if brex_item.scheduled_for_deletion? %> + <%= tag.p t(".deletion_in_progress"), class: "text-destructive text-sm animate-pulse" %> + <% end %> +
+ <% if brex_item.accounts.any? %> +

+ <%= brex_item.institution_summary %> +

+ <% end %> + <% if brex_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif brex_item.sync_error.present? %> +
+ <%= render DS::Tooltip.new(text: brex_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> + <%= tag.span t(".error"), class: "text-destructive" %> +
+ <% else %> +

+ <% 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 %> +
+
+ + <% if Current.user&.admin? %> +
+ <% if Rails.env.development? %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_brex_item_path(brex_item) + ) %> + <% end %> + + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: brex_item_path(brex_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(brex_item.name, high_severity: true) + ) %> + <% end %> +
+ <% end %> +
+ + <% unless brex_item.scheduled_for_deletion? %> +
+ <% if brex_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: brex_item.accounts %> + <% end %> + + <%= render ProviderSyncSummary.new( + stats: stats, + provider_item: brex_item, + institutions_count: institutions_count + ) %> + + <% if unlinked_count > 0 %> +
+

<%= 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 + ) %> +
+ <% elsif brex_item.accounts.empty? && total_count == 0 %> +
+

<%= 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 + ) %> +
+ <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/brex_items/_setup_required.html.erb b/app/views/brex_items/_setup_required.html.erb new file mode 100644 index 000000000..cce66fce2 --- /dev/null +++ b/app/views/brex_items/_setup_required.html.erb @@ -0,0 +1,34 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + <% dialog.with_body do %> +
+
+ <%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %> +
+

<%= t(".heading") %>

+

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

+
+
+ +
+

<%= t(".setup_steps") %>

+
    +
  1. <%= t(".steps.open_settings_html") %>
  2. +
  3. <%= t(".steps.find_section_html") %>
  4. +
  5. <%= t(".steps.enter_token") %>
  6. +
  7. <%= t(".steps.return_to_link") %>
  8. +
+
+ +
+ <%= link_to settings_providers_path, + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse button-bg-primary hover:button-bg-primary-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-colors", + data: { turbo: false } do %> + <%= t(".settings_link") %> + <% end %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/brex_items/_subtype_select.html.erb b/app/views/brex_items/_subtype_select.html.erb new file mode 100644 index 000000000..d9b5b160d --- /dev/null +++ b/app/views/brex_items/_subtype_select.html.erb @@ -0,0 +1,16 @@ + diff --git a/app/views/brex_items/select_accounts.html.erb b/app/views/brex_items/select_accounts.html.erb new file mode 100644 index 000000000..26625c29d --- /dev/null +++ b/app/views/brex_items/select_accounts.html.erb @@ -0,0 +1,59 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+

+ <%= 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? } %> + +
+ <% account_displays.each do |account_display| %> + + <% end %> +
+ +
+ <%= link_to t(".cancel"), @return_to || new_account_path, + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover", + data: { turbo_frame: "_top" } %> + <%= submit_tag t(".link_accounts"), + disabled: !has_selectable, + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled disabled:cursor-not-allowed" %> +
+ <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/brex_items/select_existing_account.html.erb b/app/views/brex_items/select_existing_account.html.erb new file mode 100644 index 000000000..2f6daa850 --- /dev/null +++ b/app/views/brex_items/select_existing_account.html.erb @@ -0,0 +1,59 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title", account_name: @account.name)) %> + + <% dialog.with_body do %> +
+

+ <%= 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? } %> + +
+ <% account_displays.each do |account_display| %> + + <% end %> +
+ +
+ <%= link_to t(".cancel"), @return_to || accounts_path, + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover", + data: { turbo_frame: "_top" } %> + <%= submit_tag t(".link_account"), + disabled: !has_selectable, + class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled disabled:cursor-not-allowed" %> +
+ <% end %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/brex_items/setup_accounts.html.erb b/app/views/brex_items/setup_accounts.html.erb new file mode 100644 index 000000000..df231a7ed --- /dev/null +++ b/app/views/brex_items/setup_accounts.html.erb @@ -0,0 +1,106 @@ +<% content_for :title, t(".title") %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) do %> +
+ <%= icon "building-2", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> + <%= form_with url: complete_account_setup_brex_item_path(@brex_item), + method: :post, + local: true, + data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: t(".creating_accounts"), + turbo_frame: "_top" + }, + class: "space-y-6" do |form| %> + +
+ <% if @api_error.present? %> +
+ <%= icon "alert-circle", size: "lg", class: "text-destructive" %> +

<%= t(".fetch_failed") %>

+

<%= @api_error %>

+
+ <% elsif @brex_accounts.empty? %> +
+ <%= icon "check-circle", size: "lg", class: "text-success" %> +

<%= t(".no_accounts_to_setup") %>

+

<%= t(".all_accounts_linked") %>

+
+ <% else %> +
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +
+

+ <%= t(".choose_account_type") %> +

+
    + <% @account_type_options.reject { |_, type| type == "skip" }.each do |label, type| %> +
  • <%= label %>
  • + <% end %> +
+
+
+
+ + <% @brex_accounts.each do |brex_account| %> +
+
+
+

+ <%= brex_account.name %> +

+
+
+ +
+
+ <%= label_tag "account_types[#{brex_account.id}]", t(".account_type_label"), + class: "block text-sm font-medium text-primary mb-2" %> + <% default_account_type = brex_account.card? ? "CreditCard" : "Depository" %> + <%= select_tag "account_types[#{brex_account.id}]", + options_for_select(@account_type_options, default_account_type), + { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full", + data: { + action: "change->account-type-selector#updateSubtype" + } } %> +
+ + +
+ <% @subtype_options.each do |account_type, subtype_config| %> + <%= render "brex_items/subtype_select", account_type: account_type, subtype_config: subtype_config, brex_account: brex_account %> + <% end %> +
+
+
+ <% end %> + <% end %> +
+ +
+ <%= render DS::Button.new( + text: t(".create_accounts"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1", + disabled: @api_error.present? || @brex_accounts.empty?, + data: { loading_button_target: "button" } + ) %> + <%= render DS::Link.new( + text: t(".cancel"), + variant: "secondary", + href: accounts_path + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/lunchflow_items/_api_error.html.erb b/app/views/lunchflow_items/_api_error.html.erb index cec9db68c..b1d6cee13 100644 --- a/app/views/lunchflow_items/_api_error.html.erb +++ b/app/views/lunchflow_items/_api_error.html.erb @@ -4,13 +4,11 @@ <% dialog.with_header(title: "Lunch Flow Connection Error") %> <% dialog.with_body do %>
-
- <%= icon("alert-circle", class: "text-destructive w-5 h-5 shrink-0 mt-0.5") %> -
-

Unable to connect to Lunch Flow

-

<%= error_message %>

-
-
+ <%= render DS::Alert.new( + title: "Unable to connect to Lunch Flow", + message: error_message, + variant: :error + ) %>

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 %>
-
- <%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %> -
-

API Key Not Configured

-

Before you can link Lunch Flow accounts, you need to configure your Lunch Flow API key.

-
-
+ <%= render DS::Alert.new( + title: "API Key Not Configured", + message: "Before you can link Lunch Flow accounts, you need to configure your Lunch Flow API key.", + variant: :warning + ) %>

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 %>
class="group bg-container shadow-border-xs rounded-xl p-4" @@ -7,12 +7,24 @@
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %>
-

<%= title %>

+
+

<%= title %>

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

<%= subtitle %>

<% end %>
+ <% if status.present? %> +
+ <% if meta.present? %> + <%= meta %> + <% end %> + <%= status %> + <%= actions if actions.present? %> +
+ <% end %>
<%= content %> diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index bc4f9dcd0..94df67ae2 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -4,7 +4,7 @@ nav_sections = [ header: t(".general_section_title"), items: [ { label: t(".accounts_label"), path: accounts_path, icon: "layers" }, - { label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" }, + { label: t(".bank_sync_label"), path: settings_providers_path, icon: "banknote", if: Current.user&.admin? }, { label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" }, { label: t(".appearance_label"), path: settings_appearance_path, icon: "palette" }, { label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" }, @@ -30,7 +30,6 @@ nav_sections = [ { label: "LLM Usage", path: settings_llm_usage_path, icon: "activity" }, { label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" }, { label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? }, - { label: "Providers", path: settings_providers_path, icon: "plug" }, { label: t(".imports_label"), path: imports_path, icon: "download" }, { label: t(".exports_label"), path: family_exports_path, icon: "upload" }, { label: "SSO Providers", path: admin_sso_providers_path, icon: "key-round", if: Current.user&.super_admin? }, diff --git a/app/views/settings/api_keys/created.html.erb b/app/views/settings/api_keys/created.html.erb index 05f7eceb0..d37189000 100644 --- a/app/views/settings/api_keys/created.html.erb +++ b/app/views/settings/api_keys/created.html.erb @@ -62,18 +62,12 @@
-
-
- <%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %> -
-

Important Security Note

-

- 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. -

-
-
-
+ <%= render DS::Alert.new(title: "Important Security Note", variant: :warning) do %> +

+ 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 %>

How to use your API key

diff --git a/app/views/settings/api_keys/created.turbo_stream.erb b/app/views/settings/api_keys/created.turbo_stream.erb index 89dab090b..dec5baeb9 100644 --- a/app/views/settings/api_keys/created.turbo_stream.erb +++ b/app/views/settings/api_keys/created.turbo_stream.erb @@ -67,18 +67,12 @@
-
-
- <%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %> -
-

Important Security Note

-

- 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. -

-
-
-
+ <%= render DS::Alert.new(title: "Important Security Note", variant: :warning) do %> +

+ 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 %>

How to use your API key

diff --git a/app/views/settings/api_keys/new.html.erb b/app/views/settings/api_keys/new.html.erb index 20e322981..34def86b0 100644 --- a/app/views/settings/api_keys/new.html.erb +++ b/app/views/settings/api_keys/new.html.erb @@ -30,18 +30,12 @@
-
-
- <%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %> -
-

Security Warning

-

- 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. -

-
-
-
+ <%= render DS::Alert.new(title: "Security Warning", variant: :warning) do %> +

+ 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 %>
<%= render DS::Link.new( diff --git a/app/views/settings/bank_sync/_provider_link.html.erb b/app/views/settings/bank_sync/_provider_link.html.erb deleted file mode 100644 index 6cb50df92..000000000 --- a/app/views/settings/bank_sync/_provider_link.html.erb +++ /dev/null @@ -1,33 +0,0 @@ -<%# locals: (provider_link:) %> - -<%# Assign distinct colors to each provider %> -<% provider_colors = { - "Lunch Flow" => "#6471eb", - "Plaid" => "#4da568", - "SimpleFin" => "#e99537", - "Enable Banking" => "#6471eb", - "CoinStats" => "#FF9332", # https://coinstats.app/press-kit/ - "Sophtron" => "#1E90FF" -} %> -<% provider_color = provider_colors[provider_link[:name]] || "#6B7280" %> - -<%= link_to provider_link[:path], - target: provider_link[:target], - rel: provider_link[:rel], - class: "flex justify-between items-center p-4 bg-container hover:bg-container-hover transition-colors" do %> -
- <%= render partial: "shared/color_avatar", locals: { name: provider_link[:name], color: provider_color } %> - -
-

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

-

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

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

PROVIDERS

- · -

<%= @providers.count %>

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

No providers configured

-

Configure providers to link your bank accounts.

-
-
- <% end %> -
diff --git a/app/views/settings/hostings/_alpha_vantage_settings.html.erb b/app/views/settings/hostings/_alpha_vantage_settings.html.erb index 47c67eff5..8a9249541 100644 --- a/app/views/settings/hostings/_alpha_vantage_settings.html.erb +++ b/app/views/settings/hostings/_alpha_vantage_settings.html.erb @@ -34,13 +34,8 @@ data: { "auto-submit-form-target": "auto" } %> <% end %> -
-
- <%= icon("alert-triangle", size: "sm", class: "text-amber-600 mt-0.5") %> -
-

<%= t(".rate_limit_warning") %>

-

<%= t(".no_health_check_note") %>

-
-
-
+ <%= render DS::Alert.new(variant: :warning) do %> +

<%= t(".rate_limit_warning") %>

+

<%= t(".no_health_check_note") %>

+ <% end %>
diff --git a/app/views/settings/hostings/_eodhd_settings.html.erb b/app/views/settings/hostings/_eodhd_settings.html.erb index e9ff03bba..0636da678 100644 --- a/app/views/settings/hostings/_eodhd_settings.html.erb +++ b/app/views/settings/hostings/_eodhd_settings.html.erb @@ -35,12 +35,5 @@ data: { "auto-submit-form-target": "auto" } %> <% end %> -
-
- <%= icon("alert-triangle", size: "sm", class: "text-amber-600 mt-0.5") %> -

- <%= t(".rate_limit_warning") %> -

-
-
+ <%= render DS::Alert.new(message: t(".rate_limit_warning"), variant: :warning) %>
diff --git a/app/views/settings/hostings/_provider_selection.html.erb b/app/views/settings/hostings/_provider_selection.html.erb index 147d082ab..4e38bb38f 100644 --- a/app/views/settings/hostings/_provider_selection.html.erb +++ b/app/views/settings/hostings/_provider_selection.html.erb @@ -71,13 +71,6 @@
<% if ENV["EXCHANGE_RATE_PROVIDER"].present? || ENV["SECURITIES_PROVIDERS"].present? || ENV["SECURITIES_PROVIDER"].present? %> -
-
- <%= icon("alert-triangle", size: "sm", class: "text-amber-600 mt-0.5 shrink-0") %> -

- <%= t(".env_configured_message") %> -

-
-
+ <%= render DS::Alert.new(message: t(".env_configured_message"), variant: :warning) %> <% end %>
diff --git a/app/views/settings/hostings/_twelve_data_settings.html.erb b/app/views/settings/hostings/_twelve_data_settings.html.erb index 91ee30aca..9dbb77289 100644 --- a/app/views/settings/hostings/_twelve_data_settings.html.erb +++ b/app/views/settings/hostings/_twelve_data_settings.html.erb @@ -57,30 +57,26 @@ <% if @plan_restricted_securities.present? %> -
-
- <%= icon("alert-triangle", size: "sm", class: "text-amber-600 mt-0.5") %> -
-

<%= t(".plan_upgrade_warning_title") %>

-

<%= t(".plan_upgrade_warning_description") %>

-
    - <% @plan_restricted_securities.each do |security| %> -
  • - <%= security[:ticker] %> - <% if security[:name].present? %> - (<%= security[:name] %>) - <% end %> - — <%= t(".requires_plan", plan: security[:required_plan]) %> -
  • - <% end %> -
-

- - <%= t(".view_pricing") %> - -

-
-
+
+ <%= render DS::Alert.new(title: t(".plan_upgrade_warning_title"), variant: :warning) do %> +

<%= t(".plan_upgrade_warning_description") %>

+ +

+ + <%= t(".view_pricing") %> + +

+ <% end %>
<% end %>
diff --git a/app/views/settings/hostings/_yahoo_finance_settings.html.erb b/app/views/settings/hostings/_yahoo_finance_settings.html.erb index 2d9c0bb0e..1ed8044b6 100644 --- a/app/views/settings/hostings/_yahoo_finance_settings.html.erb +++ b/app/views/settings/hostings/_yahoo_finance_settings.html.erb @@ -20,15 +20,11 @@ <%= t(".status_inactive") %>

-
-
- <%= icon("alert-triangle", size: "sm", class: "text-amber-600 mt-0.5 shrink-0") %> -
-

<%= t(".connection_failed") %>

-

<%= t(".troubleshooting") %>

-
-
-
+ <%= render DS::Alert.new( + title: t(".connection_failed"), + message: t(".troubleshooting"), + variant: :warning + ) %> <% end %> diff --git a/app/views/settings/providers/_binance_panel.html.erb b/app/views/settings/providers/_binance_panel.html.erb index 05378904a..f93eeea47 100644 --- a/app/views/settings/providers/_binance_panel.html.erb +++ b/app/views/settings/providers/_binance_panel.html.erb @@ -1,32 +1,39 @@
<% items = local_assigns[:binance_items] || @binance_items || Current.family.binance_items.active.ordered %> -
-

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

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

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

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

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

-

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

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

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

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

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

+

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

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

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

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

<%= error_msg %>

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

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

- <% else %> -
-

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

- <% end %> -
diff --git a/app/views/settings/providers/_brex_panel.html.erb b/app/views/settings/providers/_brex_panel.html.erb new file mode 100644 index 000000000..3954c60f3 --- /dev/null +++ b/app/views/settings/providers/_brex_panel.html.erb @@ -0,0 +1,154 @@ +
+ <% active_items = local_assigns[:brex_items] || @brex_items || Current.family.brex_items.active.ordered %> + <% credentialed_items = active_items.select(&:credentials_configured?) %> + +
+

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

+
    +
  1. <%= t("brex_items.provider_panel.instructions.sign_in_html", link: link_to("Brex", "https://brex.com", target: "_blank", rel: "noopener noreferrer", class: "link")) %>
  2. +
  3. <%= t("brex_items.provider_panel.instructions.open_tokens") %>
  4. +
  5. <%= t("brex_items.provider_panel.instructions.create_token") %>
  6. +
  7. <%= t("brex_items.provider_panel.instructions.copy_token_html") %>
  8. +
+ +

+ <%= t("brex_items.provider_panel.sandbox_note_html") %> +

+
+ + <% unless BrexItem.encryption_ready? %> +
+
+ <%= icon "shield-alert", size: "sm", class: "mt-0.5 shrink-0" %> +
+

<%= t("brex_items.provider_panel.encryption_warning.title") %>

+

<%= t("brex_items.provider_panel.encryption_warning.message") %>

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

<%= error_msg %>

+
+ <% end %> + + <% if active_items.any? %> +
+ <% active_items.each do |item| %> +
+ +
+
+

<%= item.name.to_s.first.to_s.upcase %>

+
+
+

<%= item.name %>

+

<%= item.sync_status_summary %>

+
+
+
+ +
+
+ <%= button_to sync_brex_item_path(item), + method: :post, + class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg hover:border-primary", + disabled: item.syncing? do %> + <%= icon "refresh-cw", size: "sm" %> + <%= t("brex_items.provider_panel.sync") %> + <% end %> + <%= button_to brex_item_path(item), + method: :delete, + class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-destructive hover:bg-destructive/10 rounded-lg", + aria: { label: t("brex_items.provider_panel.disconnect_label", name: item.name) }, + data: { turbo_confirm: t("brex_items.provider_panel.disconnect_confirm", name: item.name) } do %> + <%= icon "trash-2", size: "sm" %> + <% end %> +
+ + <%= styled_form_with model: item, + url: brex_item_path(item), + scope: :brex_item, + method: :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :name, + label: t("brex_items.provider_panel.connection_name_label"), + placeholder: t("brex_items.provider_panel.connection_name_placeholder") %> + + <%= form.text_field :token, + label: t("brex_items.provider_panel.token_label"), + placeholder: t("brex_items.provider_panel.keep_token_placeholder"), + type: :password, + value: nil %> + + <%= form.text_field :base_url, + label: t("brex_items.provider_panel.base_url_label"), + placeholder: t("brex_items.provider_panel.base_url_placeholder"), + value: item.base_url %> + +
+ <%= render DS::Link.new( + text: t("brex_items.provider_panel.setup_accounts"), + icon: "settings", + variant: "secondary", + href: setup_accounts_brex_item_path(item), + frame: :modal + ) %> + <%= form.submit t("brex_items.provider_panel.update_connection"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %> +
+ <% end %> +
+
+ <% end %> +
+ <% end %> + +
class="group bg-container p-4 shadow-border-xs rounded-xl"> + + <%= icon "plus" %> + <%= t("brex_items.provider_panel.add_connection") %> + + + <% brex_item = Current.family.brex_items.build(name: t("brex_items.provider_panel.default_connection_name")) %> + <%= styled_form_with model: brex_item, + url: brex_items_path, + scope: :brex_item, + method: :post, + data: { turbo: true }, + class: "space-y-3 mt-4" do |form| %> + <%= form.text_field :name, + label: t("brex_items.provider_panel.connection_name_label"), + placeholder: t("brex_items.provider_panel.connection_name_placeholder") %> + + <%= form.text_field :token, + label: t("brex_items.provider_panel.token_label"), + placeholder: t("brex_items.provider_panel.token_placeholder"), + type: :password, + value: nil %> + + <%= form.text_field :base_url, + label: t("brex_items.provider_panel.base_url_label"), + placeholder: t("brex_items.provider_panel.base_url_placeholder") %> + +
+ <%= form.submit t("brex_items.provider_panel.add_connection"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %> +
+ <% end %> +
+ +
+ <% if credentialed_items.any? %> +
+

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

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

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

<%= error_msg %>

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

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

- <% else %> -
-

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

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

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

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

<%= error_msg %>

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

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

- <% else %> -
-

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

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

<%= entry[:title] %>

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

<%= title %>

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

Setup instructions:

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

Field descriptions:

- -
+ <% + eb_link = link_to("Enable Banking", "https://enablebanking.com", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline") + %> + <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.enable_banking_panel.step_1_html", link: eb_link), + t("settings.providers.enable_banking_panel.step_2"), + t("settings.providers.enable_banking_panel.step_3"), + t("settings.providers.enable_banking_panel.callback_url_instruction", callback_url: enable_banking_callback_url) + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

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

Configuration locked

-

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

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

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

+ <% if description.present? %> +

<%= description %>

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

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

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

<%= error_msg %>

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

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

-

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

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

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

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

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

- <% else %> -
-

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

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

Setup instructions:

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

Field descriptions:

- -
+ <% + lf_link = link_to("Lunch Flow", "https://www.lunchflow.app/?atp=BiDIYS", target: "_blank", rel: "noopener noreferrer", class: "text-primary font-medium underline") + %> + <%= render "settings/providers/setup_steps", + steps: [ + t("settings.providers.lunchflow_panel.step_1_html", link: lf_link), + t("settings.providers.lunchflow_panel.step_2"), + t("settings.providers.lunchflow_panel.step_3") + ] %> <% error_msg = local_assigns[:error_message] || @error_message %> <% if error_msg.present? %> -
-

<%= error_msg %>

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

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

- <% else %> -
-

Not configured

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

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

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

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

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

<%= error_msg %>

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

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

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

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

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

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

- <% else %> -
-

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

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

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

- <% end %> + <% if setup_steps_data %> + <%= render "settings/providers/setup_steps", steps: setup_steps_data %> + <% elsif configuration.provider_description.present? %> +
+ <%= markdown(configuration.provider_description).html_safe %> +
+ <% end %> - <% if configuration.fields.any? { |f| f.description.present? } %> -

Field descriptions:

- - <% end %> -
+ <% env_configured = configuration.fields.any? { |f| f.env_key && ENV[f.env_key].present? } %> + <% if env_configured %> +

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

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

<%= field.description %>

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

Configured and ready to use

- <% else %> -
-

Not configured

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

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

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

Setup instructions:

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

Field descriptions:

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

<%= @error_message %>

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

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

- <% else %> -
-

Not configured

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

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

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

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

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

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

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

<%= error_msg %>

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

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

-
- - <%= t("providers.snaptrade.manage_connections") %> - <%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %> - -
+ <% if items&.any? %> + <% item = items.first %> + <% unless item.user_registered? %> +
+ +

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

+
+ <% end %> + <% end %> -
-

- <%= 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.manage_connections") %> + <%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %> + +
-
- <%= icon "loader-2", class: "w-4 h-4 animate-spin" %> - <%= t("providers.snaptrade.loading_connections") %> -
+
+
+ <%= icon "loader-2", class: "w-4 h-4 animate-spin" %> + <%= t("providers.snaptrade.loading_connections") %> +
-
-
+
-
- <% else %> -
-
-

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

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

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

-
- <% end %> -
+
+
+ <% end %>
diff --git a/app/views/settings/providers/_sophtron_panel.html.erb b/app/views/settings/providers/_sophtron_panel.html.erb index 878d4dd3f..e17c8a816 100644 --- a/app/views/settings/providers/_sophtron_panel.html.erb +++ b/app/views/settings/providers/_sophtron_panel.html.erb @@ -1,25 +1,14 @@
-
-

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

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

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

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

<%= error_msg %>

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

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

- <% else %> -
-

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

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

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

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

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

-

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

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

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

-
- <% end %> - - <% unless @encryption_error %> - <% @provider_configurations.each do |config| %> - <%= settings_section title: config.provider_key.titleize, collapsible: true, open: false do %> - <%= render "settings/providers/provider_form", configuration: config %> +
+

<%= 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 %> - - <%= render "settings/providers/lunchflow_panel" %> - - <% end %> +
- <%= settings_section title: "SimpleFIN", collapsible: true, open: false do %> - - <%= render "settings/providers/simplefin_panel" %> - - <% end %> + <% all_connections = @needs_attention + @connected %> - <%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/enable_banking_panel" %> - - <% end %> - - <%= settings_section title: "CoinStats (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/coinstats_panel" %> - - <% end %> + <% if all_connections.any? %> + <% if @health %> + <%= render "settings/providers/health_strip", + connected: @health[:connected], + needs_attention: @health[:needs_attention], + accounts_syncing: @health[:accounts_syncing], + last_synced_at: @health[:last_synced_at] %> + <% end %> - <%= settings_section title: "Mercury (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/mercury_panel" %> - - <% end %> + <%= render "settings/providers/group_heading", + title: t("settings.providers.groups.your_connections"), + count: all_connections.size %> - <%= settings_section title: "Coinbase (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/coinbase_panel" %> - +
+ <% all_connections.each do |entry| %> + <% auto_open = all_connections.size == 1 %> + <%= render "settings/providers/connection_row", entry: entry, open: auto_open %> + <% end %> +
<% end %> - <%= settings_section title: "Binance (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/binance_panel" %> - - <% end %> + <% if @available.any? %> +
+ <%= render "settings/providers/search_filters" %> - <%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %> - - <%= render "settings/providers/snaptrade_panel" %> - - <% end %> +
+

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

+
- <%= settings_section title: "Indexa Capital (alpha)", collapsible: true, open: false do %> - - <%= render "settings/providers/indexa_capital_panel" %> - - <% end %> + - <%= settings_section title: "Sophtron (alpha)", collapsible: true, open: false do %> - - <%= render "settings/providers/sophtron_panel" %> - +
+ <% @available.each do |entry| %> + <% meta = Provider::Metadata.for(entry[:provider_key]) %> + <%= render Settings::ProviderCard.new( + provider_key: entry[:provider_key], + name: entry[:title], + tagline: t("settings.providers.taglines.#{entry[:provider_key]}", default: nil), + region: meta[:region], + kind: meta[:kind], + tier: meta[:tier], + maturity: meta[:maturity] || :stable, + logo_bg: meta[:logo_bg], + logo_text: meta[:logo_text] + ) %> + <% end %> +
+
+ <% else %> + <%= render "settings/providers/group_heading", + title: t("settings.providers.groups.available"), + count: 0, + anchor: "available" %> +

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

<% end %> <% end %>
diff --git a/app/views/sophtron_items/_mfa_context_fields.html.erb b/app/views/sophtron_items/_mfa_context_fields.html.erb new file mode 100644 index 000000000..84dab88f7 --- /dev/null +++ b/app/views/sophtron_items/_mfa_context_fields.html.erb @@ -0,0 +1,6 @@ +<%= hidden_field_tag :accountable_type, @accountable_type %> +<%= hidden_field_tag :account_id, @account_id %> +<%= hidden_field_tag :return_to, @return_to %> +<%= hidden_field_tag :manual_sync, @manual_sync_flow if @manual_sync_flow.present? %> +<%= hidden_field_tag :sync_id, @manual_sync_id if @manual_sync_id.present? %> +<%= hidden_field_tag :sophtron_account_id, @manual_sync_sophtron_account_id if @manual_sync_sophtron_account_id.present? %> diff --git a/app/views/sophtron_items/_sophtron_item.html.erb b/app/views/sophtron_items/_sophtron_item.html.erb index f360de15a..a8c71d900 100644 --- a/app/views/sophtron_items/_sophtron_item.html.erb +++ b/app/views/sophtron_items/_sophtron_item.html.erb @@ -2,6 +2,8 @@ <%= tag.div id: dom_id(sophtron_item) do %> <% provider_display_name = sophtron_item.provider_display_name %> + <% manual_sync_required = sophtron_item.manual_sync_required? %> + <% connected_institution_options = sophtron_item.connected_institution_options %>
@@ -20,6 +22,9 @@
<%= tag.p provider_display_name, class: "font-medium text-primary" %> + <% if manual_sync_required %> + <%= t(".manual_sync") %> + <% end %> <% if sophtron_item.scheduled_for_deletion? %>

<%= t(".deletion_in_progress") %>

<% end %> @@ -56,15 +61,37 @@
- <% if Rails.env.development? %> - <%= icon( - "refresh-cw", - as_button: true, - href: sync_sophtron_item_path(sophtron_item) + <% if manual_sync_required || Rails.env.development? %> + <%= render DS::Button.new( + variant: :icon, + icon: "refresh-cw", + href: sync_sophtron_item_path(sophtron_item), + frame: (manual_sync_required ? "modal" : nil), + title: t(".sync_now") ) %> <% end %> <%= render DS::Menu.new do |menu| %> + <% if connected_institution_options.many? %> + <% connected_institution_options.each do |institution| %> + <% institution_manual_sync_required = sophtron_item.manual_sync_required_for_institution?(institution[:institution_key]) %> + <% menu.with_item( + variant: "button", + text: t(institution_manual_sync_required ? ".automatic_sync_for" : ".manual_sync_action_for", institution: institution[:name]), + icon: institution_manual_sync_required ? "refresh-cw" : "pause-circle", + href: toggle_manual_sync_sophtron_item_path(sophtron_item, institution_key: institution[:institution_key]), + method: :post + ) %> + <% end %> + <% else %> + <% menu.with_item( + variant: "button", + text: t(manual_sync_required ? ".automatic_sync" : ".manual_sync_action"), + icon: manual_sync_required ? "refresh-cw" : "pause-circle", + href: toggle_manual_sync_sophtron_item_path(sophtron_item), + method: :post + ) %> + <% end %> <% menu.with_item( variant: "button", text: t(".delete"), diff --git a/app/views/sophtron_items/connection_status.html.erb b/app/views/sophtron_items/connection_status.html.erb index 6c7ffa368..5a2e8b5b6 100644 --- a/app/views/sophtron_items/connection_status.html.erb +++ b/app/views/sophtron_items/connection_status.html.erb @@ -4,7 +4,7 @@ { controller: "polling", polling_frame_id_value: "modal", - polling_url_value: connection_status_sophtron_item_path(@sophtron_item, accountable_type: @accountable_type, account_id: @account_id, return_to: @return_to, poll_attempt: @next_poll_attempt, post_mfa: @post_mfa_polling), + polling_url_value: connection_status_sophtron_item_path(@sophtron_item, accountable_type: @accountable_type, account_id: @account_id, return_to: @return_to, poll_attempt: @next_poll_attempt, post_mfa: @post_mfa_polling, manual_sync: @manual_sync_flow, sync_id: @manual_sync_id, sophtron_account_id: @manual_sync_sophtron_account_id), polling_interval_value: @poll_interval_ms } end %> @@ -39,7 +39,7 @@
<%= render DS::Link.new( text: t(".check_again"), - href: connection_status_sophtron_item_path(@sophtron_item, accountable_type: @accountable_type, account_id: @account_id, return_to: @return_to, poll_attempt: check_again_attempt, post_mfa: @post_mfa_polling), + href: connection_status_sophtron_item_path(@sophtron_item, accountable_type: @accountable_type, account_id: @account_id, return_to: @return_to, poll_attempt: check_again_attempt, post_mfa: @post_mfa_polling, manual_sync: @manual_sync_flow, sync_id: @manual_sync_id, sophtron_account_id: @manual_sync_sophtron_account_id), variant: :primary, data: { turbo_frame: "modal", turbo_prefetch: false } ) %> diff --git a/app/views/sophtron_items/manual_sync_complete.html.erb b/app/views/sophtron_items/manual_sync_complete.html.erb new file mode 100644 index 000000000..70b0f3e57 --- /dev/null +++ b/app/views/sophtron_items/manual_sync_complete.html.erb @@ -0,0 +1,23 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+
+
+ <%= icon "check-circle", size: "sm", color: "success", class: "mt-0.5" %> +
+

<%= t(".message") %>

+

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

+
+
+
+ +
+ <%= render DS::Link.new(text: t(".close"), href: accounts_path, variant: :primary, data: { turbo_frame: "_top" }) %> +
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/sophtron_items/mfa.html.erb b/app/views/sophtron_items/mfa.html.erb index 6100c6075..2247c7c29 100644 --- a/app/views/sophtron_items/mfa.html.erb +++ b/app/views/sophtron_items/mfa.html.erb @@ -11,9 +11,7 @@ <% if security_questions.any? %> <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, class: "space-y-3", data: { turbo_frame: "modal" } do %> <%= hidden_field_tag :mfa_type, "security_answer" %> - <%= 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" %> <% security_questions.each_with_index do |question, index| %> <% answer_field_id = "security_answer_#{index}" %> @@ -35,9 +33,7 @@ <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, data: { turbo_frame: "modal" } do %> <%= hidden_field_tag :mfa_type, "token_choice" %> <%= hidden_field_tag :token_choice, method %> - <%= 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" %> <%= button_tag type: "submit", class: "w-full rounded-lg border border-primary bg-container-inset p-3 text-left text-sm text-primary transition-colors hover:bg-container-inset-hover" do %> <%= method %> <% end %> @@ -47,9 +43,7 @@ <% elsif @challenge[:token_sent] %> <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, class: "space-y-3", data: { turbo_frame: "modal" } do %> <%= hidden_field_tag :mfa_type, "token_input" %> - <%= 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" %>
<%= label_tag :token_input, t(".token"), class: "form-field__label" %> @@ -65,18 +59,14 @@

<%= @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 %>
<% elsif safe_captcha_image.present? %> <%= form_with url: submit_mfa_sophtron_item_path(@sophtron_item), method: :post, class: "space-y-3", data: { turbo_frame: "modal" } do %> <%= hidden_field_tag :mfa_type, "captcha" %> - <%= 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" %>
<%= image_tag "data:image/png;base64,#{safe_captcha_image}", alt: t(".captcha_alt"), class: "max-w-full rounded-md" %>
diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 8f8dad3c3..c0859d4b0 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.7.1-alpha.5 -appVersion: "0.7.1-alpha.5" +version: 0.7.1-alpha.6 +appVersion: "0.7.1-alpha.6" kubeVersion: ">=1.25.0-0" diff --git a/config/initializers/active_record_encryption.rb b/config/initializers/active_record_encryption.rb index 0c6da99ef..35e6b865f 100644 --- a/config/initializers/active_record_encryption.rb +++ b/config/initializers/active_record_encryption.rb @@ -1,3 +1,5 @@ +require Rails.root.join("lib/active_record_encryption_config").to_s + # Configure Active Record encryption keys # Priority order: # 1. Environment variables (works for both managed and self-hosted modes) @@ -9,8 +11,12 @@ deterministic_key = ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"] key_derivation_salt = ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"] +if ActiveRecordEncryptionConfig.partial_env? + raise ActiveRecordEncryptionConfig.partial_env_message +end + # If all environment variables are present, use them (works for both managed and self-hosted) -if primary_key.present? && deterministic_key.present? && key_derivation_salt.present? +if ActiveRecordEncryptionConfig.complete_env? Rails.application.config.active_record.encryption.primary_key = primary_key Rails.application.config.active_record.encryption.deterministic_key = deterministic_key Rails.application.config.active_record.encryption.key_derivation_salt = key_derivation_salt diff --git a/config/locales/breadcrumbs/es.yml b/config/locales/breadcrumbs/es.yml index f0a45a491..905ac305d 100644 --- a/config/locales/breadcrumbs/es.yml +++ b/config/locales/breadcrumbs/es.yml @@ -1,6 +1,8 @@ --- es: breadcrumbs: + categorize: Categorizar exports: Exportaciones home: Inicio imports: Importaciones + transactions: Transacciones diff --git a/config/locales/defaults/en.yml b/config/locales/defaults/en.yml index bf860dde4..2a3b5285a 100644 --- a/config/locales/defaults/en.yml +++ b/config/locales/defaults/en.yml @@ -3,6 +3,8 @@ en: defaults: brand_name: "%{brand_name}" product_name: "%{product_name}" + common: + close: "Close" global: expand: "Expand" activerecord: diff --git a/config/locales/defaults/es.yml b/config/locales/defaults/es.yml index 82b73c583..47cc3b860 100644 --- a/config/locales/defaults/es.yml +++ b/config/locales/defaults/es.yml @@ -153,6 +153,8 @@ es: helpers: select: prompt: Por favor seleccione + search_placeholder: Buscar + default_label: Seleccionar... submit: create: Crear %{model} submit: Guardar %{model} diff --git a/config/locales/models/brex_item/en.yml b/config/locales/models/brex_item/en.yml new file mode 100644 index 000000000..4d8bf067b --- /dev/null +++ b/config/locales/models/brex_item/en.yml @@ -0,0 +1,14 @@ +--- +en: + activerecord: + attributes: + brex_item: + base_url: Base URL + name: Connection name + token: Token + errors: + models: + brex_item: + attributes: + base_url: + official_hosts_only: must be blank, https://api.brex.com, or https://api-staging.brex.com diff --git a/config/locales/models/chat/es.yml b/config/locales/models/chat/es.yml new file mode 100644 index 000000000..22e6494be --- /dev/null +++ b/config/locales/models/chat/es.yml @@ -0,0 +1,8 @@ +--- +es: + chat: + errors: + rate_limited: "El proveedor de IA está limitando acceso a su API. Vuelve a intentarlo en unos minutos." + temporarily_unavailable: "El proveedor de IA no está disponible temporalmente. Vuelve a intentarlo en unos minutos." + misconfigured: "El proveedor de IA no está configurado correctamente. Ponte en contacto con tu administrador." + default: "No se pudo generar una respuesta. Inténtalo de nuevo." diff --git a/config/locales/models/transaction/es.yml b/config/locales/models/transaction/es.yml new file mode 100644 index 000000000..896b2cdfe --- /dev/null +++ b/config/locales/models/transaction/es.yml @@ -0,0 +1,11 @@ +--- +es: + activerecord: + errors: + models: + transaction: + attributes: + attachments: + too_many: "no puede superar %{max} archivos por transacción" + too_large: "el archivo %{index} es demasiado grande (máximo %{max_mb} MB)" + invalid_format: "el archivo %{index} tiene un formato no compatible (%{file_format})" diff --git a/config/locales/views/accounts/es.yml b/config/locales/views/accounts/es.yml index 613bf93ce..5101705eb 100644 --- a/config/locales/views/accounts/es.yml +++ b/config/locales/views/accounts/es.yml @@ -1,11 +1,13 @@ --- es: accounts: + not_authorized: No tienes permiso para gestionar esta cuenta account: edit: Editar link_lunchflow: Vincular con Lunch Flow link_provider: Vincular con proveedor unlink_provider: Desvincular de proveedor + change_simplefin_account: Cambiar cuenta de SimpleFIN troubleshoot: Solucionar problemas enable: Activar cuenta disable: Desactivar cuenta @@ -13,6 +15,7 @@ es: remove_default: Quitar predeterminada default_label: Predeterminada delete: Eliminar cuenta + sharing: Compartir chart: data_not_available: Datos no disponibles para el período seleccionado create: @@ -22,12 +25,14 @@ es: destroy: success: "Cuenta %{type} programada para eliminación" cannot_delete_linked: "No se puede eliminar una cuenta vinculada. Por favor, desvincúlela primero." + failed: "No se pudo eliminar el recurso. Inténtalo de nuevo más tarde." empty: empty_message: Añade una cuenta mediante conexión, importación o introducción manual. new_account: Nueva cuenta no_accounts: Aún no hay cuentas form: balance: Saldo actual + opening_balance_date_label: Fecha del saldo inicial name_label: Nombre de la cuenta name_placeholder: Ejemplo de nombre de cuenta additional_details: Detalles adicionales @@ -56,6 +61,7 @@ es: title: ¿Cómo te gustaría añadirla? title: ¿Qué te gustaría añadir? show: + limited_fx_history_warning: "El historial de tipos de cambio solo está disponible desde %{date}. Las transacciones anteriores a esa fecha usan conversiones de divisa aproximadas; esto puede ocurrir cuando el proveedor de tipos de cambio solo ofrece una ventana histórica limitada." activity: amount: Cantidad balance: Saldo @@ -128,6 +134,7 @@ es: ca: Canadá au: Australia eu: Europa + in: India generic: General confirm_unlink: title: ¿Desvincular cuenta del proveedor? diff --git a/config/locales/views/brex_items/en.yml b/config/locales/views/brex_items/en.yml new file mode 100644 index 000000000..4bb0c1132 --- /dev/null +++ b/config/locales/views/brex_items/en.yml @@ -0,0 +1,266 @@ +--- +en: + brex_items: + account_metadata: + provider: Brex + separator: " • " + create: + success: Brex connection created successfully + default_card_name: Brex Card + default_cash_name: "Brex Cash %{id}" + destroy: + success: Brex connection removed + index: + title: Brex Connections + institution_summary: + none: No institutions connected + one: "%{name}" + count: + one: "%{count} institution" + other: "%{count} institutions" + sync_status: + no_accounts: No accounts found + all_synced: + one: "%{count} account synced" + other: "%{count} accounts synced" + partial_setup: "%{synced} synced, %{pending} need setup" + api_error: + common_issues: "Common issues:" + expired_credentials: Generate a new API token from Brex. + expired_credentials_label: "Expired credentials:" + heading: Unable to connect to Brex + invalid_token: Check your API token in Provider Settings. + invalid_token_label: "Invalid API token:" + network: Check your internet connection. + network_label: "Network issue:" + permissions: Ensure your token has the required read-only account and transaction scopes. + permissions_label: "Insufficient permissions:" + service: Brex API may be temporarily unavailable. + service_label: "Service down:" + settings_link: Check Provider Settings + title: Brex Connection Error + errors: + unexpected_error: An unexpected error occurred. Please try again later. + entries: + default_name: Brex transaction + loading: + loading_message: Loading Brex accounts... + loading_title: Loading + link_accounts: + all_already_linked: + one: "The selected account (%{names}) is already linked" + other: "All %{count} selected accounts are already linked: %{names}" + api_error: "API error: %{message}" + invalid_account_names: + one: "Cannot link account with blank name" + other: "Cannot link %{count} accounts with blank names" + invalid_account_type: Unsupported Brex account type + link_failed: Failed to link accounts + no_accounts_selected: Please select at least one account + no_api_token: Brex API token not found. Please configure it in Provider Settings. + partial_invalid: "Successfully linked %{created_count} account(s), %{already_linked_count} account(s) were already linked, %{invalid_count} account(s) had invalid names" + partial_success: "Successfully linked %{created_count} account(s). %{already_linked_count} account(s) were already linked: %{already_linked_names}" + select_connection: Choose a Brex connection before linking accounts. + success: + one: "Successfully linked %{count} account" + other: "Successfully linked %{count} accounts" + brex_item: + accounts_need_setup: Accounts need setup + 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 + setup_action: Set Up New Accounts + setup_description: "%{linked} of %{total} accounts linked. Choose account types for your newly imported Brex 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}" + syncing: Syncing... + total: Total + unlinked: Unlinked + provider_panel: + accounts_link: Accounts + add_connection: Add Brex connection + base_url_label: Base URL (optional) + base_url_placeholder: https://api.brex.com + configured_html: "Configured and ready to use. Visit the %{accounts_link} tab to manage and set up accounts." + connection_name_label: Connection name + connection_name_placeholder: Business checking + default_connection_name: Brex Connection + disconnect_label: "Disconnect %{name}" + disconnect_confirm: "Disconnect %{name}?" + encryption_warning: + title: Brex tokens require database encryption + message: Configure Active Record encryption keys before adding Brex tokens. Sure will not store Brex provider credentials unless encryption is configured. + instructions: + copy_token_html: "Copy the token and add it as a named connection below. Sure stores the token only for syncing this family." + create_token: "Create an API token with these read-only scopes: accounts.cash.readonly, accounts.card.readonly, transactions.cash.readonly, transactions.card.readonly" + open_tokens: Go to the Brex developer/API token settings for the company you want to connect + sign_in_html: "Visit %{link} and log in to the account you want to connect" + keep_token_placeholder: Leave blank to keep the current token + not_configured: Not configured + sandbox_note_html: "Use a separate named connection for each Brex company/API token you want to sync. Leave Base URL blank for production. Staging is limited to Brex-approved testing and does not work with customer tokens." + setup_accounts: Set up accounts + setup_title: "Setup instructions:" + sync: Sync + token_label: Token + token_placeholder: Paste token here + update_connection: Update connection + provider_connection: + default_description: Connect to your Brex account + default_name: Brex + description: "Connect using %{name}" + name: "Brex - %{name}" + select_accounts: + accounts_selected: accounts selected + api_error: "API error: %{message}" + cancel: Cancel + configure_name_in_brex: Cannot import - please configure account name in Brex + description: Select the accounts you want to link to your %{product_name} account. + link_accounts: Link selected accounts + no_accounts_found: No accounts found. Please check your API token configuration. + no_api_token: Brex API token is not configured. Please configure it in Settings. + no_credentials_configured: Please configure your Brex API token first in Provider Settings. + no_name_placeholder: "(No name)" + select_connection: Choose a Brex connection in Provider Settings. + title: Select Brex Accounts + unexpected_error: An unexpected error occurred. Please try again later. + select_existing_account: + account_already_linked: This account is already linked to a provider + all_accounts_already_linked: All Brex accounts are already linked + api_error: "API error: %{message}" + cancel: Cancel + configure_name_in_brex: Cannot import - please configure account name in Brex + description: Select a Brex account to link with this account. Transactions will be synced and deduplicated automatically. + link_account: Link account + no_account_specified: No account specified + no_accounts_found: No Brex accounts found. Please check your API token configuration. + no_api_token: Brex API token is not configured. Please configure it in Settings. + no_credentials_configured: Please configure your Brex API token first in Provider Settings. + no_name_placeholder: "(No name)" + select_connection: Choose a Brex connection in Provider Settings. + title: "Link %{account_name} with Brex" + unexpected_error: An unexpected error occurred. Please try again later. + setup_required: + description: Before you can link Brex accounts, you need to configure your Brex API token. + heading: API Token Not Configured + settings_link: Go to Provider Settings + setup_steps: "Setup steps:" + steps: + enter_token: Enter your Brex API token + find_section_html: "Find the Brex section" + open_settings_html: "Go to Settings > Providers" + return_to_link: Return here to link your accounts + title: Brex Setup Required + subtype_select: + placeholder: + subtype: Select subtype + type: Select type + link_existing_account: + account_already_linked: This account is already linked to a provider + api_error: "API error: %{message}" + invalid_account_name: Cannot link account with blank name + missing_parameters: Missing required parameters + no_account_specified: No account specified + no_api_token: Brex API token not found. Please configure it in Provider Settings. + provider_account_already_linked: This Brex account is already linked to another account + provider_account_not_found: Brex account not found + select_connection: Choose a Brex connection before linking accounts. + success: "Successfully linked %{account_name} with Brex" + setup_accounts: + account_type_label: "Account Type:" + all_accounts_linked: "All your Brex accounts have already been set up." + api_error: "API error: %{message}" + fetch_failed: "Failed to Fetch Accounts" + no_accounts_to_setup: "No Accounts to Set Up" + no_api_token: "Brex API token is not configured. Please check your connection settings." + account_types: + skip: Skip this account + depository: Checking or Savings Account + credit_card: Credit Card + investment: Investment Account + loan: Loan or Mortgage + other_asset: Other Asset + subtype_labels: + depository: "Account Subtype:" + credit_card: "" + investment: "Investment Type:" + loan: "Loan Type:" + other_asset: "" + subtype_messages: + credit_card: "Credit cards will be automatically set up as credit card accounts." + other_asset: "No additional options needed for Other Assets." + subtypes: + depository: + checking: Checking + savings: Savings + hsa: Health Savings Account + cd: Certificate of Deposit + money_market: Money Market + investment: + brokerage: Brokerage + pension: Pension + retirement: Retirement + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "529 Plan" + hsa: Health Savings Account + mutual_fund: Mutual Fund + ira: Traditional IRA + roth_ira: Roth IRA + angel: Angel + loan: + mortgage: Mortgage + student: Student Loan + auto: Auto Loan + other: Other Loan + balance: Balance + cancel: Cancel + choose_account_type: "Choose the correct account type for each Brex account:" + create_accounts: Create Accounts + creating_accounts: Creating Accounts... + historical_data_range: "Historical Data Range:" + subtitle: Choose the correct account types for your imported accounts + sync_start_date_help: Select how far back you want to sync transaction history. Maximum 3 years of history available. + sync_start_date_label: "Start syncing transactions from:" + title: Set Up Your Brex Accounts + complete_account_setup: + all_skipped: "All accounts were skipped. No accounts were created." + creation_failed: "Failed to create accounts: %{error}" + creation_failed_count: "Failed to create %{count} account(s)." + no_accounts: "No accounts to set up." + partial_skipped: "Successfully created %{created_count} account(s); %{skipped_count} account(s) were skipped." + partial_success: "Successfully created %{created_count} account(s), but %{failed_count} account(s) failed." + success: "Successfully created %{count} account(s)." + unexpected_error: An unexpected error occurred. + sync: + success: Sync started + syncer: + account_processing_failed: + one: "%{count} Brex account failed while processing." + other: "%{count} Brex accounts failed while processing." + account_sync_failed: + one: "%{count} Brex account sync could not be scheduled." + other: "%{count} Brex account syncs could not be scheduled." + accounts_need_setup: + one: "%{count} account needs setup..." + other: "%{count} accounts need setup..." + accounts_failed: + one: "%{count} Brex account failed to import." + other: "%{count} Brex accounts failed to import." + calculating_balances: Calculating balances... + checking_account_configuration: Checking account configuration... + credentials_invalid: Invalid Brex API token or account permissions + failed: Sync failed. Please try again or contact support. + import_failed: Brex import failed. + importing_accounts: Importing accounts from Brex... + processing_transactions: Processing transactions... + transactions_failed: + one: "%{count} Brex account had transaction import failures." + other: "%{count} Brex accounts had transaction import failures." + update: + success: Brex connection updated diff --git a/config/locales/views/coinstats_items/ca.yml b/config/locales/views/coinstats_items/ca.yml index 470fd3add..66140452a 100644 --- a/config/locales/views/coinstats_items/ca.yml +++ b/config/locales/views/coinstats_items/ca.yml @@ -50,8 +50,6 @@ ca: not_configured_step3_html: Segueix les instruccions de configuració proporcionades per completar la configuració del proveïdor not_configured_title: La connexió amb el proveïdor CoinStats no està configurada setup_instructions: "Instruccions de configuració:" - status_configured_html: Llest per utilitzar - status_not_configured: No configurat step1_html: Ves al Panell de l'API Pública de CoinStats per obtenir una clau API. step2: Introdueix la teva clau API a continuació i fes clic a Configura. step3_html: Després d'una connexió reeixida, ves a la pestanya Comptes per configurar les carteres de criptomonedes. diff --git a/config/locales/views/coinstats_items/de.yml b/config/locales/views/coinstats_items/de.yml index 56ce87bd8..d54f1e4fe 100644 --- a/config/locales/views/coinstats_items/de.yml +++ b/config/locales/views/coinstats_items/de.yml @@ -41,8 +41,6 @@ de: configure: Konfigurieren update_configuration: Neu konfigurieren default_name: CoinStats-Verbindung - status_configured_html: Bereit zur Nutzung - status_not_configured: Nicht konfiguriert coinstats_item: deletion_in_progress: Krypto-Wallet-Daten werden gelöscht… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/en.yml b/config/locales/views/coinstats_items/en.yml index ecd41109b..982aa8db3 100644 --- a/config/locales/views/coinstats_items/en.yml +++ b/config/locales/views/coinstats_items/en.yml @@ -55,8 +55,6 @@ en: configure: Configure update_configuration: Reconfigure default_name: CoinStats Connection - status_configured_html: Ready to use - status_not_configured: Not configured coinstats_item: deletion_in_progress: Crypto wallet data is being deleted… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/es.yml b/config/locales/views/coinstats_items/es.yml index 36744d53e..ca54f463b 100644 --- a/config/locales/views/coinstats_items/es.yml +++ b/config/locales/views/coinstats_items/es.yml @@ -41,8 +41,6 @@ es: configure: Configurar update_configuration: Reconfigurar default_name: Conexión de CoinStats - status_configured_html: Listo para usar - status_not_configured: No configurado coinstats_item: deletion_in_progress: Los datos de la cartera de criptomonedas se están eliminando… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/fr.yml b/config/locales/views/coinstats_items/fr.yml index b2b365510..84a89d621 100644 --- a/config/locales/views/coinstats_items/fr.yml +++ b/config/locales/views/coinstats_items/fr.yml @@ -55,8 +55,6 @@ fr: configure: Configurer update_configuration: Reconfigurer default_name: Connexion CoinStats - status_configured_html: Prêt à utiliser - status_not_configured: Non configuré coinstats_item: deletion_in_progress: Les données du portefeuille crypto sont en cours de suppression… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/hu.yml b/config/locales/views/coinstats_items/hu.yml index 8de8131a9..3a3dbad56 100644 --- a/config/locales/views/coinstats_items/hu.yml +++ b/config/locales/views/coinstats_items/hu.yml @@ -55,8 +55,6 @@ hu: configure: Konfigurálás update_configuration: Újrakonfigurálás default_name: CoinStats kapcsolat - status_configured_html: Használatra kész - status_not_configured: Nincs beállítva coinstats_item: deletion_in_progress: A kriptó pénztárca adatai törlés alatt… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/nl.yml b/config/locales/views/coinstats_items/nl.yml index 7c0918599..da6b19b55 100644 --- a/config/locales/views/coinstats_items/nl.yml +++ b/config/locales/views/coinstats_items/nl.yml @@ -43,8 +43,6 @@ nl: configure: Configureren update_configuration: Opnieuw configureren default_name: CoinStats Verbinding - status_configured_html: Klaar voor gebruik - status_not_configured: Niet geconfigureerd coinstats_item: deletion_in_progress: Crypto wallet gegevens worden verwijderd… provider_name: CoinStats diff --git a/config/locales/views/coinstats_items/pl.yml b/config/locales/views/coinstats_items/pl.yml index 968c9aa0b..15d0c66b4 100644 --- a/config/locales/views/coinstats_items/pl.yml +++ b/config/locales/views/coinstats_items/pl.yml @@ -45,8 +45,6 @@ pl: configure: Skonfiguruj update_configuration: Skonfiguruj ponownie default_name: Połączenie CoinStats - status_configured_html: Gotowe do użycia - status_not_configured: Nieskonfigurowane coinstats_item: deletion_in_progress: Trwa usuwanie danych portfela kryptowalutowego… provider_name: CoinStats diff --git a/config/locales/views/indexa_capital_items/de.yml b/config/locales/views/indexa_capital_items/de.yml index 09d0e2a22..e9c50dbfa 100644 --- a/config/locales/views/indexa_capital_items/de.yml +++ b/config/locales/views/indexa_capital_items/de.yml @@ -39,8 +39,6 @@ de: alternative_auth: "Oder nutzen Sie Benutzername/Passwort-Anmeldung..." save_button: "Konfiguration speichern" update_button: "Konfiguration aktualisieren" - status_configured_html: "Konfiguriert und einsatzbereit. Besuchen Sie die Registerkarte Konten, um Konten zu verwalten und einzurichten." - status_not_configured: "Nicht konfiguriert" fields: api_token: label: "API-Token" diff --git a/config/locales/views/indexa_capital_items/en.yml b/config/locales/views/indexa_capital_items/en.yml index a3a448b7c..774bca112 100644 --- a/config/locales/views/indexa_capital_items/en.yml +++ b/config/locales/views/indexa_capital_items/en.yml @@ -40,8 +40,6 @@ en: alternative_auth: "Or use username/password authentication instead..." save_button: "Save Configuration" update_button: "Update Configuration" - status_configured_html: "Configured and ready to use. Visit the Accounts tab to manage and set up accounts." - status_not_configured: "Not configured" fields: api_token: label: "API Token" diff --git a/config/locales/views/indexa_capital_items/es.yml b/config/locales/views/indexa_capital_items/es.yml index f63db32d8..82f9247a1 100644 --- a/config/locales/views/indexa_capital_items/es.yml +++ b/config/locales/views/indexa_capital_items/es.yml @@ -37,8 +37,6 @@ es: alternative_auth: "O usa la autenticación por usuario/contraseña en su lugar..." save_button: "Guardar configuración" update_button: "Actualizar configuración" - status_configured_html: "Configurado y listo para usar. Visita la pestaña de Cuentas para gestionar y configurar tus cuentas." - status_not_configured: "No configurado" fields: api_token: label: "Token de API" diff --git a/config/locales/views/indexa_capital_items/fr.yml b/config/locales/views/indexa_capital_items/fr.yml index 1e46d9bac..66dce0d18 100644 --- a/config/locales/views/indexa_capital_items/fr.yml +++ b/config/locales/views/indexa_capital_items/fr.yml @@ -40,8 +40,6 @@ fr: alternative_auth: "Ou utilisez plutôt l'authentification par nom d'utilisateur / mot de passe…" save_button: "Enregistrer la configuration" update_button: "Mettre à jour la configuration" - status_configured_html: "Configuré et prêt à l'emploi. Rendez-vous sur l'onglet Comptes pour gérer et configurer les comptes." - status_not_configured: "Non configuré" fields: api_token: label: "Jeton API" diff --git a/config/locales/views/indexa_capital_items/hu.yml b/config/locales/views/indexa_capital_items/hu.yml index 02ef29c59..be7bf5ff5 100644 --- a/config/locales/views/indexa_capital_items/hu.yml +++ b/config/locales/views/indexa_capital_items/hu.yml @@ -37,8 +37,6 @@ hu: alternative_auth: "Vagy használj felhasználónév/jelszó hitelesítést helyette..." save_button: "Konfiguráció mentése" update_button: "Konfiguráció frissítése" - status_configured_html: "Beállítva és használatra kész. A számlák kezeléséhez és beállításához látogass el a Számlák lapra." - status_not_configured: "Nincs beállítva" fields: api_token: label: "API token" diff --git a/config/locales/views/indexa_capital_items/pl.yml b/config/locales/views/indexa_capital_items/pl.yml index abab63caa..4ae96d462 100644 --- a/config/locales/views/indexa_capital_items/pl.yml +++ b/config/locales/views/indexa_capital_items/pl.yml @@ -39,8 +39,6 @@ pl: alternative_auth: Lub użyj uwierzytelniania loginem i hasłem... save_button: Zapisz konfigurację update_button: Zaktualizuj konfigurację - status_configured_html: Skonfigurowano i gotowe do użycia. Przejdź do zakładki Konta, aby zarządzać kontami i je konfigurować. - status_not_configured: Nie skonfigurowano fields: api_token: label: Token API diff --git a/config/locales/views/loans/es.yml b/config/locales/views/loans/es.yml index bb6e526ce..42e397484 100644 --- a/config/locales/views/loans/es.yml +++ b/config/locales/views/loans/es.yml @@ -10,6 +10,8 @@ es: rate_type: Tipo de interés term_months: Plazo (meses) term_months_placeholder: '360' + subtype_prompt: Selecciona el tipo de préstamo + subtype_none: Ninguno new: title: Introduce los detalles del préstamo overview: diff --git a/config/locales/views/mercury_items/en.yml b/config/locales/views/mercury_items/en.yml index f3916fae5..2625bc40f 100644 --- a/config/locales/views/mercury_items/en.yml +++ b/config/locales/views/mercury_items/en.yml @@ -44,11 +44,9 @@ en: total: Total unlinked: Unlinked provider_panel: - accounts_link: Accounts add_connection: Add Mercury connection base_url_label: Base URL (optional) base_url_placeholder: https://api.mercury.com/api/v1 (default) - configured_html: "Configured and ready to use. Visit the %{accounts_link} tab to manage and set up accounts." connection_name_label: Connection name connection_name_placeholder: Business checking default_connection_name: Mercury Connection @@ -60,7 +58,6 @@ en: sign_in_html: "Visit %{link} and log in to the account you want to connect" whitelist_ip_html: "Important: Add your server's IP address to the token's whitelist" keep_token_placeholder: Leave blank to keep the current token - not_configured: Not configured sandbox_note_html: "Use a separate named connection for each Mercury login/API token you want to sync. For sandbox testing, use https://api-sandbox.mercury.com/api/v1 as the Base URL. Mercury requires IP whitelisting - make sure to add your IP in the Mercury dashboard." setup_accounts: Set up accounts setup_title: "Setup instructions:" diff --git a/config/locales/views/mercury_items/hu.yml b/config/locales/views/mercury_items/hu.yml index 75091c34c..8bf7271a7 100644 --- a/config/locales/views/mercury_items/hu.yml +++ b/config/locales/views/mercury_items/hu.yml @@ -44,11 +44,9 @@ hu: total: Összesen unlinked: Nincs összekapcsolva provider_panel: - accounts_link: Számlák add_connection: Mercury kapcsolat hozzáadása base_url_label: Alap URL (opcionális) base_url_placeholder: https://api.mercury.com/api/v1 (alapértelmezett) - configured_html: "Beállítva és használatra kész. A számlák kezeléséhez és beállításához látogass el a %{accounts_link} lapra." connection_name_label: Kapcsolat neve connection_name_placeholder: Üzleti folyószámla default_connection_name: Mercury kapcsolat @@ -60,7 +58,6 @@ hu: sign_in_html: "Látogass el a(z) %{link} oldalra, és lépj be az összekapcsolni kívánt fiókba" whitelist_ip_html: "Fontos: Add a szervered IP-címét a token engedélyezési listájához" keep_token_placeholder: Hagyd üresen az aktuális token megtartásához - not_configured: Nincs beállítva sandbox_note_html: "Minden Mercury bejelentkezéshez/API tokenhez használj külön elnevezett kapcsolatot. Sandbox teszteléshez használd a https://api-sandbox.mercury.com/api/v1 alap URL-t. A Mercury IP engedélyezési listát igényel — győződj meg róla, hogy hozzáadtad az IP-d a Mercury irányítópulton." setup_accounts: Számlák beállítása setup_title: "Beállítási utasítások:" diff --git a/config/locales/views/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