Skip to content
Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
31 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
acb8279
Update README to reflect Maybe Finance repo status
jjmata May 9, 2026
c92b984
[codex] Add Sophtron manual sync fixes (#1714)
jjmata May 9, 2026
712d6ba
docs(agents): add Design System Hygiene checklist for UI PRs (#1732)
gariasf May 10, 2026
57d71cd
refactor(design-system): extend DS::Alert and migrate 9 inline alert …
gariasf May 10, 2026
7f05693
Defensive coding on @variant
jjmata May 10, 2026
f62aed6
i18n(es): fill high-value locale gaps (#1733)
sure-admin May 10, 2026
f6f9feb
Bank Sync cleanup (#1710)
jjmata May 10, 2026
36960fe
Bump version to next iteration after v0.7.1-alpha.5 release
github-actions[bot] May 10, 2026
1dfba4c
fix(sync): reconcile Brex with bank sync settings
JSONbored May 11, 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
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
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
Loading
Loading