Skip to content
Open
Show file tree
Hide file tree
Changes from 15 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/assets/tailwind/sure-design-system/_generated.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions app/components/DS/dialog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ def close_button
variant: "icon",
class: classes,
icon: "x",
title: I18n.t("common.close"),
aria_label: I18n.t("common.close"),
title: I18n.t("defaults.common.close"),
aria_label: I18n.t("defaults.common.close"),
data: { action: "DS--dialog#close" }
)
end
Expand Down
22 changes: 22 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(:accounts, :syncs, brex_accounts: :account_provider))
@coinbase_items = visible_provider_items(family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs))
@snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts))
@indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts))
Expand Down Expand Up @@ -314,6 +315,27 @@ def build_sync_stats_maps
@mercury_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end

# Brex sync stats
@brex_sync_stats_map = {}
@brex_account_counts_map = {}
@brex_institutions_count_map = {}
@brex_items.each do |item|
latest_sync = item.syncs.ordered.first
@brex_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
brex_accounts = item.brex_accounts.to_a
linked_count = brex_accounts.count { |brex_account| brex_account.account_provider.present? }
total_count = brex_accounts.count
@brex_account_counts_map[item.id] = {
linked: linked_count,
unlinked: total_count - linked_count,
total: total_count
}
@brex_institutions_count_map[item.id] = brex_accounts
.filter_map(&:institution_metadata)
.uniq { |institution| institution["name"] || institution["institution_name"] }
.count
end

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

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
100 changes: 100 additions & 0 deletions app/controllers/brex_items/account_setups_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
class BrexItems::AccountSetupsController < ApplicationController
before_action :require_admin!
before_action :set_brex_item

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

render "brex_items/setup_accounts"
end

def complete_account_setup
result = brex_account_flow.complete_setup_result(
account_types: sanitized_account_types,
account_subtypes: sanitized_account_subtypes
)

unless result.success?
redirect_to accounts_path, alert: result.message, status: :see_other
return
end

flash[:notice] = result.message

if turbo_frame_request?
render_accounts_update_after_setup
else
redirect_to accounts_path, status: :see_other
end
end

private

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

def brex_account_flow
@brex_account_flow ||= BrexItem::AccountFlow.new(family: Current.family, brex_item: @brex_item)
end

def render_accounts_update_after_setup
@manual_accounts = Account.uncached { Current.family.accounts.visible_manual.order(:name).to_a }
@brex_items = Current.family.brex_items.ordered

manual_accounts_stream = if @manual_accounts.any?
turbo_stream.update(
"manual-accounts",
partial: "accounts/index/manual_accounts",
locals: { accounts: @manual_accounts }
)
else
turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts"))
end

render turbo_stream: [
manual_accounts_stream,
turbo_stream.replace(
ActionView::RecordIdentifier.dom_id(@brex_item),
partial: "brex_items/brex_item",
locals: { brex_item: @brex_item }
)
] + Array(flash_notification_stream_items)
end

def sanitized_account_types
allowed_account_ids = @brex_item.brex_accounts.pluck(:id).map(&:to_s)
supported_types = Provider::BrexAdapter.supported_account_types

setup_param_hash(:account_types).each_with_object({}) do |(account_id, selected_type), sanitized|
next unless allowed_account_ids.include?(account_id.to_s)

normalized_type = selected_type.to_s
sanitized[account_id.to_s] = supported_types.include?(normalized_type) ? normalized_type : "skip"
end
end

def sanitized_account_subtypes
allowed_account_ids = @brex_item.brex_accounts.pluck(:id).map(&:to_s)
allowed_subtypes = (Depository::SUBTYPES.keys + CreditCard::SUBTYPES.keys).map(&:to_s)

setup_param_hash(:account_subtypes).each_with_object({}) do |(account_id, selected_subtype), sanitized|
next unless allowed_account_ids.include?(account_id.to_s)
next if selected_subtype.blank?
next unless allowed_subtypes.include?(selected_subtype.to_s)

sanitized[account_id.to_s] = selected_subtype.to_s
end
end

def setup_param_hash(key)
raw_params = params.fetch(key, {})
return {} if raw_params.blank?

raw_params.respond_to?(:to_unsafe_h) ? raw_params.to_unsafe_h : raw_params.to_h
end
end
Loading
Loading