From 95f6451b3983df64f02e0ff25b999a7f31a92878 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Wed, 13 May 2026 09:13:48 -0700 Subject: [PATCH] feat(sync): add Brex provider connections (#1752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sync): add Brex provider schema Adds Brex item and account tables with per-family credentials, scoped upstream account uniqueness, encrypted token storage, and sanitized provider payload columns. * feat(sync): add Brex provider core Adds Brex item/account models, provider client and adapter support, family connection helpers, and provider enum registration for read-only Brex cash and card data. * feat(sync): add Brex import pipeline Adds Brex account discovery, linked-account sync, cash/card balance processors, transaction import, sanitized metadata handling, and idempotent provider entry processing. * feat(sync): add Brex connection flows Adds Mercury-style Brex connection management, explicit item-scoped account selection and linking, settings provider UI, account index visibility, localized copy, and per-item cache handling. * test(sync): cover Brex provider workflows Adds targeted coverage for Brex provider requests, adapter config, item/account guards, importer behavior, entry processing, and Mercury-style controller flows. * fix(sync): align Brex API edge cases Tightens Brex account fetching against the official card-account response shape, sends transaction start filters as RFC3339 date-times, and keeps provider error bodies out of user-facing messages while expanding provider client guard coverage. * fix(sync): harden Brex provider integration Restrict Brex API base URLs to official hosts, tighten account-selection UI behavior, and add tests for invalid credentials, cache scoping, and provider setup edge cases. * test(sync): avoid Brex secret-shaped fixtures * refactor(sync): extract Brex account flows * fix(sync): address Brex provider review feedback * fix(sync): address Brex review follow-ups Move remaining Brex review cleanup into focused model behavior, tighten link/setup edge cases, localize summaries, and add regression coverage from CodeRabbit feedback. Also records the security-review pass as no-findings after diff-scoped inspection and Brakeman validation. * refactor(sync): split Brex account flow controllers Route Brex account selection and setup actions through small namespaced controllers while keeping existing URLs and helpers stable. Business flow remains in BrexItem::AccountFlow; the main Brex item controller now only handles connection CRUD, provider-panel rendering, destroy, and sync. * fix(sync): address Brex CodeRabbit review * fix(sync): address Brex follow-up review * fix(sync): address Brex review follow-ups * fix(sync): address Brex sync review findings * fix(sync): polish Brex review copy and errors * fix(sync): register Brex provider health * fix(sync): polish Brex bank sync presentation * fix(sync): address Brex review follow-ups * fix(sync): tighten Brex setup params * test(api): stabilize usage rate-limit window * fix(sync): polish Brex setup flow nits * fix(sync): harden Brex setup params * fix(sync): finalize Brex review cleanup --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata --- .devcontainer/Dockerfile | 1 + .gitignore | 3 - app/controllers/accounts_controller.rb | 22 + .../brex_items/account_flows_controller.rb | 132 +++++ .../brex_items/account_setups_controller.rb | 109 ++++ app/controllers/brex_items_controller.rb | 98 ++++ .../settings/providers_controller.rb | 6 + app/helpers/brex_items_helper.rb | 76 +++ app/helpers/settings_helper.rb | 3 + .../account_type_selector_controller.js | 8 +- app/models/brex_account.rb | 204 ++++++++ app/models/brex_account/processor.rb | 78 +++ .../brex_account/transactions/processor.rb | 83 +++ app/models/brex_entry/processor.rb | 180 +++++++ app/models/brex_item.rb | 197 +++++++ app/models/brex_item/account_flow.rb | 425 +++++++++++++++ app/models/brex_item/account_flow/setup.rb | 242 +++++++++ app/models/brex_item/importer.rb | 245 +++++++++ app/models/brex_item/provided.rb | 16 + app/models/brex_item/syncer.rb | 148 ++++++ app/models/brex_item/unlinking.rb | 56 ++ app/models/concerns/encryptable.rb | 6 +- app/models/credit_card.rb | 2 + app/models/data_enrichment.rb | 1 + app/models/depository.rb | 2 + app/models/family.rb | 2 +- app/models/family/brex_connectable.rb | 29 ++ app/models/family/syncer.rb | 1 + app/models/provider/brex.rb | 271 ++++++++++ app/models/provider/brex_adapter.rb | 119 +++++ app/models/provider/metadata.rb | 1 + app/models/provider_connection_status.rb | 1 + app/models/provider_merchant.rb | 2 +- app/views/accounts/index.html.erb | 10 +- app/views/brex_items/_api_error.html.erb | 36 ++ app/views/brex_items/_brex_item.html.erb | 132 +++++ app/views/brex_items/_setup_required.html.erb | 34 ++ app/views/brex_items/_subtype_select.html.erb | 16 + app/views/brex_items/select_accounts.html.erb | 59 +++ .../select_existing_account.html.erb | 59 +++ app/views/brex_items/setup_accounts.html.erb | 106 ++++ .../settings/providers/_brex_panel.html.erb | 154 ++++++ .../providers/_drawer_header.html.erb | 4 +- .../initializers/active_record_encryption.rb | 8 +- config/locales/defaults/en.yml | 2 + config/locales/models/brex_item/en.yml | 14 + config/locales/views/brex_items/en.yml | 277 ++++++++++ config/locales/views/settings/en.yml | 1 + config/locales/views/valuations/nb.yml | 4 +- config/routes.rb | 16 + ...05010000_create_brex_items_and_accounts.rb | 59 +++ db/schema.rb | 45 ++ lib/active_record_encryption_config.rb | 58 +++ .../provider_connections_controller_test.rb | 18 + .../api/v1/usage_controller_test.rb | 38 +- .../controllers/brex_items_controller_test.rb | 488 ++++++++++++++++++ .../settings/providers_controller_test.rb | 36 ++ test/fixtures/brex_accounts.yml | 7 + test/fixtures/brex_items.yml | 7 + test/helpers/brex_items_helper_test.rb | 29 ++ .../active_record_encryption_config_test.rb | 56 ++ .../transactions/processor_test.rb | 92 ++++ test/models/brex_account_test.rb | 210 ++++++++ test/models/brex_entry/processor_test.rb | 131 +++++ test/models/brex_item/account_flow_test.rb | 394 ++++++++++++++ test/models/brex_item/importer_test.rb | 331 ++++++++++++ test/models/brex_item/syncer_test.rb | 136 +++++ test/models/brex_item_test.rb | 198 +++++++ test/models/family/syncer_test.rb | 7 + test/models/provider/brex_adapter_test.rb | 224 ++++++++ test/models/provider/brex_test.rb | 289 +++++++++++ 71 files changed, 6515 insertions(+), 39 deletions(-) create mode 100644 app/controllers/brex_items/account_flows_controller.rb create mode 100644 app/controllers/brex_items/account_setups_controller.rb create mode 100644 app/controllers/brex_items_controller.rb create mode 100644 app/helpers/brex_items_helper.rb create mode 100644 app/models/brex_account.rb create mode 100644 app/models/brex_account/processor.rb create mode 100644 app/models/brex_account/transactions/processor.rb create mode 100644 app/models/brex_entry/processor.rb create mode 100644 app/models/brex_item.rb create mode 100644 app/models/brex_item/account_flow.rb create mode 100644 app/models/brex_item/account_flow/setup.rb create mode 100644 app/models/brex_item/importer.rb create mode 100644 app/models/brex_item/provided.rb create mode 100644 app/models/brex_item/syncer.rb create mode 100644 app/models/brex_item/unlinking.rb create mode 100644 app/models/family/brex_connectable.rb create mode 100644 app/models/provider/brex.rb create mode 100644 app/models/provider/brex_adapter.rb create mode 100644 app/views/brex_items/_api_error.html.erb create mode 100644 app/views/brex_items/_brex_item.html.erb create mode 100644 app/views/brex_items/_setup_required.html.erb create mode 100644 app/views/brex_items/_subtype_select.html.erb create mode 100644 app/views/brex_items/select_accounts.html.erb create mode 100644 app/views/brex_items/select_existing_account.html.erb create mode 100644 app/views/brex_items/setup_accounts.html.erb create mode 100644 app/views/settings/providers/_brex_panel.html.erb create mode 100644 config/locales/models/brex_item/en.yml create mode 100644 config/locales/views/brex_items/en.yml create mode 100644 db/migrate/20260505010000_create_brex_items_and_accounts.rb create mode 100644 lib/active_record_encryption_config.rb create mode 100644 test/controllers/brex_items_controller_test.rb create mode 100644 test/fixtures/brex_accounts.yml create mode 100644 test/fixtures/brex_items.yml create mode 100644 test/helpers/brex_items_helper_test.rb create mode 100644 test/lib/active_record_encryption_config_test.rb create mode 100644 test/models/brex_account/transactions/processor_test.rb create mode 100644 test/models/brex_account_test.rb create mode 100644 test/models/brex_entry/processor_test.rb create mode 100644 test/models/brex_item/account_flow_test.rb create mode 100644 test/models/brex_item/importer_test.rb create mode 100644 test/models/brex_item/syncer_test.rb create mode 100644 test/models/brex_item_test.rb create mode 100644 test/models/provider/brex_adapter_test.rb create mode 100644 test/models/provider/brex_test.rb diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b871287d4..e024ef870 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -11,6 +11,7 @@ RUN apt-get update -qq \ git \ imagemagick \ iproute2 \ + libvips42 \ libpq-dev \ libyaml-dev \ libyaml-0-2 \ 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/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 4b4f34a31..084a37217 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -18,6 +18,7 @@ class AccountsController < ApplicationController @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)) @ibkr_items = visible_provider_items(family.ibkr_items.ordered.includes(:syncs, :ibkr_accounts)) @@ -317,6 +318,27 @@ class AccountsController < ApplicationController @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..45678aef0 --- /dev/null +++ b/app/controllers/brex_items/account_setups_controller.rb @@ -0,0 +1,109 @@ +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 + @displayable_account_type_options = flow.displayable_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 + supported_types = Provider::BrexAdapter.supported_account_types + + setup_param_hash(:account_types, allowed_account_ids).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_subtypes = (Depository::SUBTYPES.keys + CreditCard::SUBTYPES.keys).map(&:to_s) + + setup_param_hash(:account_subtypes, allowed_account_ids).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, allowed_keys) + raw_params = params.fetch(key, {}) + return {} if raw_params.blank? + + if raw_params.is_a?(ActionController::Parameters) + raw_params.permit(*allowed_keys).to_h + elsif raw_params.is_a?(Hash) + raw_params.slice(*allowed_keys) + else + {} + end + end + + def allowed_account_ids + @allowed_account_ids ||= @brex_item.brex_accounts.pluck(:id).map(&:to_s) + end +end diff --git a/app/controllers/brex_items_controller.rb b/app/controllers/brex_items_controller.rb new file mode 100644 index 000000000..551a36c47 --- /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 = t("brex_items.default_connection_name") if @brex_item.name.blank? + + 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/providers_controller.rb b/app/controllers/settings/providers_controller.rb index b8852065f..bdd9b485e 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -187,6 +187,7 @@ class Settings::ProvidersController < ApplicationController { 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: "kraken", title: "Kraken", turbo_id: "kraken", partial: "kraken_panel" }, @@ -205,6 +206,7 @@ class Settings::ProvidersController < ApplicationController "enable_banking" => "EnableBankingItem", "coinstats" => "CoinstatsItem", "mercury" => "MercuryItem", + "brex" => "BrexItem", "coinbase" => "CoinbaseItem", "binance" => "BinanceItem", "kraken" => "KrakenItem", @@ -226,6 +228,8 @@ class Settings::ProvidersController < ApplicationController @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" @@ -259,6 +263,7 @@ class Settings::ProvidersController < ApplicationController @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 + @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.ordered @ibkr_items = Current.family.ibkr_items.ordered.select(:id) @@ -287,6 +292,7 @@ class Settings::ProvidersController < ApplicationController "enable_banking" => @enable_banking_items, "coinstats" => @coinstats_items, "mercury" => @mercury_items, + "brex" => @brex_items, "coinbase" => @coinbase_items, "binance" => @binance_items, "kraken" => @kraken_items, diff --git a/app/helpers/brex_items_helper.rb b/app/helpers/brex_items_helper.rb new file mode 100644 index 000000000..be30ecb40 --- /dev/null +++ b/app/helpers/brex_items_helper.rb @@ -0,0 +1,76 @@ +# 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, + translated_brex_metadata_value("kinds", display.kind), + translated_brex_metadata_value("statuses", display.status) + ].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 + + private + def translated_brex_metadata_value(scope, value) + key = value.to_s + return nil if key.blank? + + t("brex_items.#{scope}.#{key}", default: key.titleize) + end +end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index b4e927292..1cc3f32e2 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -86,6 +86,9 @@ module SettingsHelper 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) diff --git a/app/javascript/controllers/account_type_selector_controller.js b/app/javascript/controllers/account_type_selector_controller.js index 4504c41b7..ef9ced189 100644 --- a/app/javascript/controllers/account_type_selector_controller.js +++ b/app/javascript/controllers/account_type_selector_controller.js @@ -18,7 +18,8 @@ export default class extends Controller { // Hide all subtype selects const subtypeSelects = container.querySelectorAll('.subtype-select') subtypeSelects.forEach(select => { - select.style.display = 'none' + select.classList.add('hidden') + select.style.removeProperty('display') // Clear the name attribute so it doesn't get submitted const selectElement = select.querySelector('select') if (selectElement) { @@ -34,7 +35,8 @@ export default class extends Controller { // Show the relevant subtype select const relevantSubtype = container.querySelector(`[data-type="${selectedType}"]`) if (relevantSubtype) { - relevantSubtype.style.display = 'block' + relevantSubtype.classList.remove('hidden') + relevantSubtype.style.removeProperty('display') // Re-add the name attribute so it gets submitted const selectElement = relevantSubtype.querySelector('select') if (selectElement) { @@ -65,4 +67,4 @@ export default class extends Controller { } } } -} \ No newline at end of file +} diff --git a/app/models/brex_account.rb b/app/models/brex_account.rb new file mode 100644 index 000000000..743fcc01a --- /dev/null +++ b/app/models/brex_account.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +class BrexAccount < ApplicationRecord + include CurrencyNormalizable, Encryptable + + CARD_PRIMARY_ACCOUNT_ID = "card_primary" + + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end + + 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" => 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..865797e65 --- /dev/null +++ b/app/models/brex_item.rb @@ -0,0 +1,197 @@ +class BrexItem < ApplicationRecord + include Syncable, Provided, Unlinking, Encryptable + + BLANK_TOKEN_SENTINELS = [ "", " ", " ", " ", "\t", "\n", "\r" ].freeze + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + if encryption_ready? + encrypts :token, deterministic: true + encrypts :raw_payload + 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, *BLANK_TOKEN_SENTINELS ]).where("BTRIM(token) <> ''") } + + def self.resolve_for(family:, brex_item_id: nil) + normalized_id = brex_item_id.to_s.strip.presence + + if normalized_id.present? + return family.brex_items.active.with_credentials.find_by(id: normalized_id) + end + + credentialed_items = family.brex_items.active.with_credentials.ordered + credentialed_items.first if credentialed_items.one? + end + + 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..7fc08ed0d --- /dev/null +++ b/app/models/brex_item/account_flow.rb @@ -0,0 +1,425 @@ +# frozen_string_literal: true + +class BrexItem::AccountFlow + require_dependency "brex_item/account_flow/setup" + + include Setup + + 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.to_s.strip.presence + @credentialed_items = family.brex_items.active.with_credentials.ordered + @brex_item = brex_item || BrexItem.resolve_for(family: family, brex_item_id: @brex_item_id) + 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 + + private + + def selection_error_payload + if brex_item_id.present? + return { + success: false, + error: "select_connection", + error_message: I18n.t("brex_items.select_accounts.select_connection"), + has_accounts: nil + } + end + + 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 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 +end diff --git a/app/models/brex_item/account_flow/setup.rb b/app/models/brex_item/account_flow/setup.rb new file mode 100644 index 000000000..730892b4e --- /dev/null +++ b/app/models/brex_item/account_flow/setup.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +class BrexItem::AccountFlow + module Setup + 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 displayable_account_type_options + account_type_options.reject { |_, type| type == "skip" } + 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", default: "Other asset"), + 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 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 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 +end diff --git a/app/models/brex_item/importer.rb b/app/models/brex_item/importer.rb new file mode 100644 index 000000000..a053c16e2 --- /dev/null +++ b/app/models/brex_item/importer.rb @@ -0,0 +1,245 @@ +# 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 + + 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 all_existing_ids.include?(account_id) + import_account(snapshot) + accounts_updated += 1 + else + 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..a2c1d3703 --- /dev/null +++ b/app/models/brex_item/unlinking.rb @@ -0,0 +1,56 @@ +# 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| + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: [] + } + results << result + + if dry_run + result[:provider_link_ids] = AccountProvider.where(provider_type: "BrexAccount", provider_id: provider_account.id).ids + next + end + + link_ids = [] + + 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 817b05fb2..d14b47eb5 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -11,6 +11,7 @@ class DataEnrichment < ApplicationRecord enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", + brex: "brex", indexa_capital: "indexa_capital", sophtron: "sophtron", ibkr: "ibkr" 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 7ebec6c5d..fb211d78c 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, KrakenConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, SophtronConnectable + include CoinbaseConnectable, BinanceConnectable, KrakenConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, BrexConnectable, SophtronConnectable include IndexaCapitalConnectable, IbkrConnectable DATE_FORMATS = [ diff --git a/app/models/family/brex_connectable.rb b/app/models/family/brex_connectable.rb new file mode 100644 index 000000000..49fe3e560 --- /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.presence || I18n.t("brex_items.default_connection_name"), + token: token, + base_url: base_url + ) + + brex_item.sync_later + + brex_item + end + + def has_brex_credentials? + brex_items.active.with_credentials.exists? + end +end diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 6b909ebcb..7873c5ff0 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -17,6 +17,7 @@ class Family::Syncer coinbase_items coinstats_items mercury_items + brex_items binance_items snaptrade_items sophtron_items diff --git a/app/models/provider/brex.rb b/app/models/provider/brex.rb new file mode 100644 index 000000000..969dfee50 --- /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.dup + 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..dbed8c7ca --- /dev/null +++ b/app/models/provider/brex_adapter.rb @@ -0,0 +1,119 @@ +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 + + 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 = BrexItem.resolve_for(family: family, brex_item_id: 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 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 + parsed_host = URI.parse(url).host + Rails.logger.warn("Brex account #{provider_account.id} institution URL has no host: #{url}") if parsed_host.nil? + domain = parsed_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 index 3d8472d70..4c8263b8a 100644 --- a/app/models/provider/metadata.rb +++ b/app/models/provider/metadata.rb @@ -6,6 +6,7 @@ class Provider 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" }, kraken: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "KR", logo_bg: "bg-violet-600" }, diff --git a/app/models/provider_connection_status.rb b/app/models/provider_connection_status.rb index 47ce145ea..0563d6f77 100644 --- a/app/models/provider_connection_status.rb +++ b/app/models/provider_connection_status.rb @@ -13,6 +13,7 @@ class ProviderConnectionStatus { key: "snaptrade", type: "SnaptradeItem", association: :snaptrade_items, accounts: :snaptrade_accounts, linked_accounts: :linked_accounts }, { key: "ibkr", type: "IbkrItem", association: :ibkr_items, accounts: :ibkr_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/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index bd9d4b3af..38ba4d888 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? && @ibkr_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? && @ibkr_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 %> @@ -62,8 +66,8 @@ <% end %> <% if @indexa_capital_items.any? %> - <%= render @indexa_capital_items.sort_by(&:created_at) %> -<% end %> + <%= render @indexa_capital_items.sort_by(&:created_at) %> + <% end %> <% if @manual_accounts.any? %>
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..9653265a6 --- /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..fdc3e25f9 --- /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..734db5a1f --- /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..88206fb46 --- /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") %> +

+
    + <% @displayable_account_type_options.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/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/_drawer_header.html.erb b/app/views/settings/providers/_drawer_header.html.erb index df439dafd..20c3eb611 100644 --- a/app/views/settings/providers/_drawer_header.html.erb +++ b/app/views/settings/providers/_drawer_header.html.erb @@ -15,8 +15,8 @@ variant: "icon", class: "ml-auto hidden lg:flex", icon: "x", - title: t("common.close"), - aria_label: t("common.close"), + title: t("defaults.common.close"), + aria_label: t("defaults.common.close"), data: { action: "DS--dialog#close" } ) %>
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 @@ primary_key = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"] 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/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/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/views/brex_items/en.yml b/config/locales/views/brex_items/en.yml new file mode 100644 index 000000000..d090cb6dd --- /dev/null +++ b/config/locales/views/brex_items/en.yml @@ -0,0 +1,277 @@ +--- +en: + brex_items: + default_connection_name: Brex Connection + account_metadata: + provider: Brex + separator: " • " + kinds: + cash: Cash + card: Card + statuses: + ACTIVE: Active + active: Active + CLOSED: Closed + closed: Closed + frozen: Frozen + FROZEN: Frozen + 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: Database encryption is not configured + message: Configure Active Record encryption keys before adding Brex tokens in production. Without encryption keys, Sure stores Brex provider credentials and snapshots in plaintext like other provider records. + 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 not found. Please configure it in Provider 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 not found. Please configure it in Provider 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 not found. Please configure it in Provider 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/settings/en.yml b/config/locales/views/settings/en.yml index 13887befc..4ac65a9f3 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -233,6 +233,7 @@ en: 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. kraken: Sync Kraken balances and spot trade fills using a read-only API key. diff --git a/config/locales/views/valuations/nb.yml b/config/locales/views/valuations/nb.yml index f2d311307..9564af840 100644 --- a/config/locales/views/valuations/nb.yml +++ b/config/locales/views/valuations/nb.yml @@ -27,5 +27,5 @@ nb: note_label: Notater note_placeholder: Legg til eventuelle tilleggsdetaljer om denne oppføringen overview: Oversikt - settings: Innstillinger - opening_balance: Startsaldo \ No newline at end of file + settings: Innstillinger + opening_balance: Startsaldo diff --git a/config/routes.rb b/config/routes.rb index ba00f86f4..ac61dcecb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,6 +33,22 @@ Rails.application.routes.draw do 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 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/schema.rb b/db/schema.rb index f013a0ed2..8b3cef737 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -214,6 +214,49 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_12_211200) do 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 @@ -1766,6 +1809,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_12_211200) do add_foreign_key "balances", "accounts", on_delete: :cascade add_foreign_key "binance_accounts", "binance_items" add_foreign_key "binance_items", "families" + add_foreign_key "brex_accounts", "brex_items" + add_foreign_key "brex_items", "families" add_foreign_key "budget_categories", "budgets" add_foreign_key "budget_categories", "categories" add_foreign_key "budgets", "families" 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/controllers/api/v1/provider_connections_controller_test.rb b/test/controllers/api/v1/provider_connections_controller_test.rb index ebd637380..957b5aa31 100644 --- a/test/controllers/api/v1/provider_connections_controller_test.rb +++ b/test/controllers/api/v1/provider_connections_controller_test.rb @@ -158,6 +158,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/api/v1/usage_controller_test.rb b/test/controllers/api/v1/usage_controller_test.rb index 272358261..02f4ee8c2 100644 --- a/test/controllers/api/v1/usage_controller_test.rb +++ b/test/controllers/api/v1/usage_controller_test.rb @@ -111,26 +111,28 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest end test "should work correctly when approaching rate limit" do - # Make 98 requests to get close to the limit - 98.times do + travel_to Time.zone.local(2026, 1, 1, 12, 15, 0) do + # Make 98 requests to get close to the limit + 98.times do + get "/api/v1/test", headers: { "X-Api-Key" => @api_key.display_key } + assert_response :success + end + + # Check usage - this should be request 99 + get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } + assert_response :success + + response_body = JSON.parse(response.body) + assert_equal 99, response_body["rate_limit"]["current_count"] + assert_equal 1, response_body["rate_limit"]["remaining"] + + # One more request should hit the limit get "/api/v1/test", headers: { "X-Api-Key" => @api_key.display_key } assert_response :success + + # Now we should be rate limited + get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } + assert_response :too_many_requests end - - # Check usage - this should be request 99 - get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - - response_body = JSON.parse(response.body) - assert_equal 99, response_body["rate_limit"]["current_count"] - assert_equal 1, response_body["rate_limit"]["remaining"] - - # One more request should hit the limit - get "/api/v1/test", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - - # Now we should be rate limited - get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :too_many_requests end end diff --git a/test/controllers/brex_items_controller_test.rb b/test/controllers/brex_items_controller_test.rb new file mode 100644 index 000000000..b443f2023 --- /dev/null +++ b/test/controllers/brex_items_controller_test.rb @@ -0,0 +1,488 @@ +# 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 "create uses localized default name when submitted name is blank" do + assert_difference "BrexItem.count", 1 do + post brex_items_url, params: { + brex_item: { + name: " ", + token: "default_name_token", + base_url: "https://api.brex.com" + } + } + end + + assert_redirected_to accounts_path + assert_equal I18n.t("brex_items.default_connection_name"), @family.brex_items.order(:created_at).last.name + 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 + + test "complete account setup treats scalar setup params as empty" do + assert_no_difference "AccountProvider.count" do + post complete_account_setup_brex_item_url(@second_item), params: { + account_types: "not-a-hash", + account_subtypes: "also-not-a-hash" + } + end + + assert_redirected_to accounts_path + assert_equal I18n.t("brex_items.complete_account_setup.no_accounts"), flash[:alert] + 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 f000bed7b..0f9bce42e 100644 --- a/test/controllers/settings/providers_controller_test.rb +++ b/test/controllers/settings/providers_controller_test.rb @@ -32,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 @@ -355,6 +376,21 @@ class Settings::ProvidersControllerTest < ActionDispatch::IntegrationTest 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 "GET show includes Interactive Brokers in bank sync providers" do get settings_providers_url 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/helpers/brex_items_helper_test.rb b/test/helpers/brex_items_helper_test.rb new file mode 100644 index 000000000..124881220 --- /dev/null +++ b/test/helpers/brex_items_helper_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "test_helper" + +class BrexItemsHelperTest < ActionView::TestCase + test "metadata uses translations with titleized fallback" do + display = BrexItemsHelper::BrexAccountDisplay.new( + id: "cash_1", + name: "Operating Cash", + kind: "cash", + currency: "USD", + status: "ACTIVE", + blank_name: false + ) + + assert_equal "Brex • USD • Cash • Active", brex_account_metadata(display) + + fallback_display = BrexItemsHelper::BrexAccountDisplay.new( + id: "unknown_1", + name: "Unknown", + kind: "custom_kind", + currency: "USD", + status: "custom_status", + blank_name: false + ) + + assert_equal "Brex • USD • Custom Kind • Custom Status", brex_account_metadata(fallback_display) + end +end 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..6d0d60769 --- /dev/null +++ b/test/models/brex_account_test.rb @@ -0,0 +1,210 @@ +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 + skip "Encryption not configured" unless BrexAccount.encryption_ready? + + 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: "card ending 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 "transaction payload sanitizer limits card metadata last four to digits" do + sanitized = BrexAccount.sanitize_payload(card_metadata: { card_last_four: "card id abc9876" }) + + assert_equal "9876", sanitized["card_metadata"]["last_four"] + refute_includes sanitized.to_s, "abc9876" + 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..60a2207dd --- /dev/null +++ b/test/models/brex_item/account_flow_test.rb @@ -0,0 +1,394 @@ +# 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 "account cache keys isolate multiple credentialed connections with shared upstream ids" do + second_item = BrexItem.create!( + family: @family, + name: "Second Brex", + token: "second_brex_token", + base_url: "https://api.brex.com" + ) + first_cache_key = BrexItem::AccountFlow.cache_key(@family, @brex_item) + second_cache_key = BrexItem::AccountFlow.cache_key(@family, second_item) + + refute_equal first_cache_key, second_cache_key + + Rails.cache.expects(:read).with(first_cache_key).never + Rails.cache.expects(:read).with(second_cache_key).returns( + [ { id: BrexAccount.card_account_id, name: "Second Brex Card", account_kind: "card" } ] + ) + Rails.cache.expects(:write).never + + result = BrexItem::AccountFlow.new(family: @family, brex_item: second_item).select_accounts_result(accountable_type: "CreditCard") + + assert result.success? + assert_equal [ "Second Brex Card" ], result.available_accounts.map { |account| account.with_indifferent_access[:name] } + end + + test "preload payload reports invalid explicit connection as selection error" do + payload = BrexItem::AccountFlow.new( + family: @family, + brex_item_id: " #{SecureRandom.uuid} " + ).preload_payload + + assert_equal false, payload[:success] + assert_equal "select_connection", payload[:error] + assert_nil payload[:has_accounts] + end + + test "import accounts reports missing selected item as no api token" do + flow = BrexItem::AccountFlow.new(family: @family, brex_item_id: SecureRandom.uuid) + + assert_raises BrexItem::AccountFlow::NoApiTokenError do + flow.import_accounts_from_api_if_needed + end + 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 result is unsuccessful when any account creation fails" do + first_brex_account = @brex_item.brex_accounts.create!( + account_id: "setup_result_partial_1", + account_kind: "cash", + name: "Setup Result Partial One", + currency: "USD", + current_balance: 100 + ) + second_brex_account = @brex_item.brex_accounts.create!( + account_id: "setup_result_partial_2", + account_kind: "cash", + name: "Setup Result 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_result( + account_types: { + first_brex_account.id => "Depository", + second_brex_account.id => "Depository" + }, + account_subtypes: {} + ) + + refute result.success? + assert_match(/failed/i, result.message) + assert first_brex_account.reload.account_provider.present? + assert_nil second_brex_account.reload.account_provider + 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..ed64f5e81 --- /dev/null +++ b/test/models/brex_item/importer_test.rb @@ -0,0 +1,331 @@ +# 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 "imports aggregate card transactions only into the selected connection" do + first_card_account = @family.accounts.create!( + name: "First Brex Card", + balance: 0, + currency: "USD", + accountable: CreditCard.new + ) + first_brex_card_account = @brex_item.brex_accounts.create!( + account_id: BrexAccount.card_account_id, + account_kind: "card", + name: "First Brex Card", + currency: "USD", + current_balance: 0 + ) + AccountProvider.create!(account: first_card_account, provider: first_brex_card_account) + + second_item = BrexItem.create!( + family: @family, + name: "Second Brex", + token: "second_brex_token", + base_url: "https://api.brex.com" + ) + second_card_account = @family.accounts.create!( + name: "Second Brex Card", + balance: 0, + currency: "USD", + accountable: CreditCard.new + ) + second_brex_card_account = second_item.brex_accounts.create!( + account_id: BrexAccount.card_account_id, + account_kind: "card", + name: "Second Brex Card", + currency: "USD", + current_balance: 0, + raw_transactions_payload: [ + { + id: "second_connection_card_tx", + amount: { amount: 42_00, currency: "USD" }, + description: "Existing second connection card transaction", + posted_at_date: "2026-02-01" + } + ] + ) + AccountProvider.create!(account: second_card_account, provider: second_brex_card_account) + + 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: anything).returns(transactions: []) + provider.expects(:get_primary_card_transactions).with(start_date: anything).returns( + transactions: [ + { + id: "first_connection_card_tx", + amount: { amount: 21_00, currency: "USD" }, + description: "First connection card transaction", + posted_at_date: "2026-02-02", + card_id: "card_account_1" + } + ] + ) + + result = BrexItem::Importer.new(@brex_item, brex_provider: provider, sync_start_date: Date.new(2026, 2, 1)).import + + assert result[:success] + assert_equal [ "first_connection_card_tx" ], first_brex_card_account.reload.raw_transactions_payload.map { |tx| tx["id"] } + assert_equal [ "second_connection_card_tx" ], second_brex_card_account.reload.raw_transactions_payload.map { |tx| tx["id"] } + 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 + + test "refreshes already discovered unlinked accounts during import" do + unlinked_account = @brex_item.brex_accounts.create!( + account_id: "cash_unlinked_1", + account_kind: "cash", + name: "Old Unlinked Cash", + currency: "USD", + current_balance: 1 + ) + + provider = mock("brex_provider") + provider.expects(:get_accounts).returns( + accounts: [ + cash_account_payload, + cash_account_payload.merge( + id: "cash_unlinked_1", + name: "Updated Unlinked Cash", + current_balance: { amount: 987_65, currency: "USD" } + ) + ] + ) + 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_equal 2, result[:accounts_updated] + assert_equal "Updated Unlinked Cash", unlinked_account.reload.name + assert_equal BigDecimal("987.65"), unlinked_account.current_balance + 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..9d454b2fd --- /dev/null +++ b/test/models/brex_item_test.rb @@ -0,0 +1,198 @@ +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 + skip "Encryption not configured" unless BrexItem.encryption_ready? + + assert_includes BrexItem.encrypted_attributes.map(&:to_s), "token" + assert_includes BrexItem.encrypted_attributes.map(&:to_s), "raw_payload" + end + + test "resolve for returns explicit credentialed item scoped to family" do + resolved = BrexItem.resolve_for(family: @brex_item.family, brex_item_id: " #{@brex_item.id} ") + + assert_equal @brex_item, resolved + end + + test "resolve for refuses explicit items without usable credentials" do + item = BrexItem.create!( + family: @brex_item.family, + name: "Blank Resolve Brex", + token: "temporary_token", + base_url: "https://api.brex.com" + ) + item.update_column(:token, " ") + + assert_nil BrexItem.resolve_for(family: @brex_item.family, brex_item_id: item.id) + end + + test "resolve for does not select one item when multiple credentialed items exist" do + BrexItem.create!( + family: @brex_item.family, + name: "Second Resolve Brex", + token: "second_resolve_token", + base_url: "https://api.brex.com" + ) + + assert_nil BrexItem.resolve_for(family: @brex_item.family) + 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 48ed9ffb2..be9624109 100644 --- a/test/models/family/syncer_test.rb +++ b/test/models/family/syncer_test.rb @@ -10,6 +10,7 @@ class Family::SyncerTest < ActiveSupport::TestCase manual_accounts_count = @family.accounts.manual.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) @@ -24,6 +25,11 @@ class Family::SyncerTest < ActiveSupport::TestCase .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) @@ -67,6 +73,7 @@ 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) diff --git a/test/models/provider/brex_adapter_test.rb b/test/models/provider/brex_adapter_test.rb new file mode 100644 index 000000000..10fd26aad --- /dev/null +++ b/test/models/provider/brex_adapter_test.rb @@ -0,0 +1,224 @@ +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 + + test "logs institution urls without hosts" do + brex_account = brex_items(:one).brex_accounts.create!( + account_id: "metadata_bad_url_cash", + account_kind: "cash", + name: "Metadata Bad URL Cash", + currency: "USD", + institution_metadata: { + "url" => "not-a-url" + } + ) + + Rails.logger.expects(:warn).with(regexp_matches(/institution URL has no host/)) + + assert_nil Provider::BrexAdapter.new(brex_account).institution_domain + 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