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") %>
+
+ - <%= t(".steps.open_settings_html") %>
+ - <%= t(".steps.find_section_html") %>
+ - <%= t(".steps.enter_token") %>
+ - <%= t(".steps.return_to_link") %>
+
+
+
+
+ <%= 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 @@
+
+ <% if subtype_config[:options].present? %>
+ <%= label_tag "account_subtypes[#{brex_account.id}]", subtype_config[:label],
+ class: "block text-sm font-medium text-primary mb-2" %>
+ <% selected_value = "" %>
+ <% if account_type == "Depository" %>
+ <% selected_value = default_brex_depository_subtype(brex_account.name) %>
+ <% end %>
+ <% prompt_key = account_type == "Depository" ? "subtype" : "type" %>
+ <%= select_tag "account_subtypes[#{brex_account.id}]",
+ options_for_select([[t("brex_items.subtype_select.placeholder.#{prompt_key}"), ""]] + subtype_config[:options], selected_value),
+ { 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" } %>
+ <% else %>
+
<%= subtype_config[:message] %>
+ <% end %>
+
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") %>
+
+ - <%= t("brex_items.provider_panel.instructions.sign_in_html", link: link_to("Brex", "https://brex.com", target: "_blank", rel: "noopener noreferrer", class: "link")) %>
+ - <%= t("brex_items.provider_panel.instructions.open_tokens") %>
+ - <%= t("brex_items.provider_panel.instructions.create_token") %>
+ - <%= t("brex_items.provider_panel.instructions.copy_token_html") %>
+
+
+
+ <%= 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? %>
+
+ <% 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