Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
db29988
feat(sync): add Brex provider schema
JSONbored May 5, 2026
63ad268
feat(sync): add Brex provider core
JSONbored May 5, 2026
94a2cd5
feat(sync): add Brex import pipeline
JSONbored May 5, 2026
cf02b57
feat(sync): add Brex connection flows
JSONbored May 5, 2026
57b3da8
test(sync): cover Brex provider workflows
JSONbored May 5, 2026
e672624
fix(sync): align Brex API edge cases
JSONbored May 5, 2026
493b2f3
fix(sync): harden Brex provider integration
JSONbored May 5, 2026
74bf0dd
test(sync): avoid Brex secret-shaped fixtures
JSONbored May 5, 2026
785550c
refactor(sync): extract Brex account flows
JSONbored May 5, 2026
c7ebd6d
fix(sync): address Brex provider review feedback
JSONbored May 6, 2026
ce3bf2d
fix(sync): address Brex review follow-ups
JSONbored May 6, 2026
40f924a
refactor(sync): split Brex account flow controllers
JSONbored May 6, 2026
7b8452e
fix(sync): address Brex CodeRabbit review
JSONbored May 6, 2026
cdbb795
fix(sync): address Brex follow-up review
JSONbored May 6, 2026
5390bea
fix(sync): address Brex review follow-ups
JSONbored May 6, 2026
4ab3fc9
fix(sync): address Brex sync review findings
JSONbored May 6, 2026
0e68db1
fix(sync): polish Brex review copy and errors
JSONbored May 6, 2026
d30f4a2
Merge branch 'main' into codex/feat-brex-provider
JSONbored May 6, 2026
042e3f6
Merge branch 'main' into codex/feat-brex-provider
JSONbored May 7, 2026
6174a9d
fix(sync): register Brex provider health
JSONbored May 7, 2026
8e1d3c6
Merge branch 'main' into codex/feat-brex-provider
JSONbored May 7, 2026
5eb2813
Merge branch 'main' into codex/feat-brex-provider
jjmata May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,3 @@ scripts/
.claude_settings.json
.security-key
logs/security/

# Added by codex
.codex
8 changes: 8 additions & 0 deletions app/controllers/accounts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def index
@enable_banking_items = visible_provider_items(family.enable_banking_items.ordered.includes(:syncs))
@coinstats_items = visible_provider_items(family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs))
@mercury_items = visible_provider_items(family.mercury_items.ordered.includes(:syncs, :mercury_accounts))
@brex_items = visible_provider_items(family.brex_items.ordered.includes(:syncs, :brex_accounts))
@coinbase_items = visible_provider_items(family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs))
@snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts))
@indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts))
Expand Down Expand Up @@ -314,6 +315,13 @@ def build_sync_stats_maps
@mercury_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end

# Brex sync stats
@brex_sync_stats_map = {}
@brex_items.each do |item|
latest_sync = item.syncs.ordered.first
@brex_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end

# Coinbase sync stats
@coinbase_sync_stats_map = {}
@coinbase_unlinked_count_map = {}
Expand Down
112 changes: 112 additions & 0 deletions app/controllers/brex_items/account_flows_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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
Comment thread
JSONbored marked this conversation as resolved.

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(params[:account_id])
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(params[:account_id])
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
uri = URI.parse(return_to)

return nil if uri.scheme.present? || uri.host.present?
return nil if return_to.start_with?("//")
return nil unless return_to.start_with?("/")

return_to
rescue URI::InvalidURIError
nil
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.
end
68 changes: 68 additions & 0 deletions app/controllers/brex_items/account_setups_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
class BrexItems::AccountSetupsController < ApplicationController
before_action :set_brex_item
before_action :require_admin!

def setup_accounts
flow = brex_account_flow
@api_error = flow.import_accounts_error_message
@brex_accounts = flow.unlinked_brex_accounts
@account_type_options = flow.account_type_options
@subtype_options = flow.subtype_options

render "brex_items/setup_accounts"
end

def complete_account_setup
result = brex_account_flow.complete_setup_result(
account_types: params[:account_types] || {},
account_subtypes: params[: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
end
98 changes: 98 additions & 0 deletions app/controllers/brex_items_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
class BrexItemsController < ApplicationController
before_action :set_brex_item, only: [ :show, :edit, :update, :destroy, :sync ]
before_action :require_admin!, only: [ :new, :create, :edit, :update, :destroy, :sync ]

def index
@brex_items = Current.family.brex_items.active.ordered
render layout: "settings"
end

def show
end

def new
@brex_item = Current.family.brex_items.build
end

def create
@brex_item = Current.family.brex_items.build(brex_item_params)
@brex_item.name ||= "Brex Connection"

if @brex_item.save
@brex_item.sync_later
render_provider_panel_success(t(".success"))
else
render_provider_panel_error
end
end

def edit
end

def update
if BrexItem::AccountFlow.update_item_with_cache_expiration(@brex_item, family: Current.family, attributes: brex_item_params)
render_provider_panel_success(t(".success"))
else
render_provider_panel_error
end
end

def destroy
@brex_item.unlink_all!(dry_run: false)
@brex_item.destroy_later
redirect_to accounts_path, notice: t(".success")
end

def sync
@brex_item.sync_later unless @brex_item.syncing?

respond_to do |format|
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end

private

def render_provider_panel_success(message)
return redirect_to accounts_path, notice: message, status: :see_other unless turbo_frame_request?

flash.now[:notice] = message
@brex_items = Current.family.brex_items.active.ordered.includes(:syncs, :brex_accounts)
render_brex_provider_panel(locals: { brex_items: @brex_items }, include_flash: true)
end

def render_provider_panel_error
@error_message = @brex_item.errors.full_messages.join(", ")
return redirect_to settings_providers_path, alert: @error_message, status: :see_other unless turbo_frame_request?

render_brex_provider_panel(locals: { error_message: @error_message }, status: :unprocessable_entity)
end

def render_brex_provider_panel(locals:, status: :ok, include_flash: false)
streams = [
turbo_stream.replace(
"brex-providers-panel",
partial: "settings/providers/brex_panel",
locals: locals
)
]
streams += flash_notification_stream_items if include_flash
render turbo_stream: streams, status: status
end

def set_brex_item
@brex_item = Current.family.brex_items.find(params[:id])
end

def brex_item_params
permitted = params.require(:brex_item).permit(:name, :sync_start_date, :token, :base_url)
permitted.delete(:token) if @brex_item&.persisted? && permitted[:token].blank?
permitted[:token] = permitted[:token].to_s.strip if permitted[:token].present?
if permitted.key?(:base_url)
permitted[:base_url] = permitted[:base_url].to_s.strip
permitted[:base_url] = nil if permitted[:base_url].blank?
end
permitted
end
end
2 changes: 2 additions & 0 deletions app/controllers/settings/providers_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ def prepare_show_context
config.provider_key.to_s.casecmp("sophtron").zero? || \
config.provider_key.to_s.casecmp("coinstats").zero? || \
config.provider_key.to_s.casecmp("mercury").zero? || \
config.provider_key.to_s.casecmp("brex").zero? || \
config.provider_key.to_s.casecmp("coinbase").zero? || \
config.provider_key.to_s.casecmp("snaptrade").zero? || \
config.provider_key.to_s.casecmp("indexa_capital").zero?
Expand All @@ -142,6 +143,7 @@ def prepare_show_context
@sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id)
@coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display
@mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts)
@brex_items = Current.family.brex_items.active.ordered.includes(:syncs, :brex_accounts)
@coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display
@snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered
@indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id)
Expand Down
55 changes: 55 additions & 0 deletions app/helpers/brex_items_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

module BrexItemsHelper
BrexAccountDisplay = Struct.new(
:id,
:name,
:kind,
:currency,
:status,
:blank_name,
keyword_init: true
) do
alias_method :blank_name?, :blank_name
end

def brex_account_display(account)
data = account.with_indifferent_access
kind = BrexAccount.kind_for(data)
name = BrexAccount.name_for(data)

BrexAccountDisplay.new(
id: data[:id],
name: name,
kind: kind,
currency: BrexAccount.currency_code_from_money(data[:current_balance] || data[:available_balance] || data[:account_limit]),
status: data[:status],
blank_name: name.blank?
)
end

def brex_account_metadata(display)
parts = [
t("brex_items.account_metadata.provider"),
display.currency,
display.kind.to_s.titleize,
display.status.presence&.to_s&.titleize
].compact

parts.join(t("brex_items.account_metadata.separator"))
end

def default_brex_depository_subtype(account_name)
normalized_name = account_name.to_s.downcase

if normalized_name.match?(/\bchecking\b|\bchequing\b|\bck\b|demand\s+deposit/)
"checking"
elsif normalized_name.match?(/\bsavings\b|\bsv\b/)
"savings"
elsif normalized_name.match?(/money\s+market|\bmm\b/)
"money_market"
else
"checking"
end
end
end
Loading
Loading