Merge branch 'main' into feat/savings-goals

Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
This commit is contained in:
Guillem Arias Fauste
2026-05-13 18:22:55 +02:00
committed by GitHub
267 changed files with 19408 additions and 455 deletions

View File

@@ -18,8 +18,10 @@ 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))
@indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts))
@sophtron_items = visible_provider_items(family.sophtron_items.ordered.includes(:syncs, :sophtron_accounts))
@@ -47,13 +49,14 @@ class AccountsController < ApplicationController
@chart_view = params[:chart_view] || "balance"
@tab = params[:tab]
@q = params.fetch(:q, {}).permit(:search, status: [])
entries = @account.entries.where(excluded: false).search(@q).reverse_chronological
entries = @account.entries.where(excluded: false).search(@q).reverse_chronological.includes(:entryable)
@pagy, @entries = pagy(
entries,
limit: safe_per_page,
params: request.query_parameters.except("tab").merge("tab" => "activity")
)
Transaction::ActivitySecurityPreloader.new(@entries).preload
@activity_feed_data = Account::ActivityFeedData.new(@account, @entries)
end
@@ -315,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 = {}

View File

@@ -3,11 +3,14 @@
class Api::V1::BaseController < ApplicationController
include Doorkeeper::Rails::Helpers
UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
private_constant :UUID_PATTERN
InvalidFilterError = Class.new(StandardError)
class << self
def valid_uuid?(value)
UuidFormat.valid?(value)
end
end
# Skip regular session-based authentication for API
skip_authentication
@@ -220,7 +223,7 @@ class Api::V1::BaseController < ApplicationController
end
def valid_uuid?(value)
value.to_s.match?(UUID_PATTERN)
self.class.valid_uuid?(value)
end
def safe_page_param

View File

@@ -4,7 +4,7 @@ class Api::V1::ImportsController < Api::V1::BaseController
include Pagy::Backend
# Ensure proper scope authorization
before_action :ensure_read_scope, only: [ :index, :show, :rows ]
before_action :ensure_read_scope, only: [ :index, :show, :rows, :preflight ]
before_action :ensure_write_scope, only: [ :create ]
before_action :set_import_with_rows, only: [ :show ]
before_action :set_import, only: [ :rows ]
@@ -77,10 +77,10 @@ class Api::V1::ImportsController < Api::V1::BaseController
if params[:file].present?
file = params[:file]
if file.size > Import::MAX_CSV_SIZE
if file.size > Import.max_csv_size
return render json: {
error: "file_too_large",
message: "File is too large. Maximum size is #{Import::MAX_CSV_SIZE / 1.megabyte}MB."
message: "File is too large. Maximum size is #{Import.max_csv_size / 1.megabyte}MB."
}, status: :unprocessable_entity
end
@@ -93,10 +93,10 @@ class Api::V1::ImportsController < Api::V1::BaseController
@import.raw_file_str = file.read
elsif params[:raw_file_content].present?
if params[:raw_file_content].bytesize > Import::MAX_CSV_SIZE
if params[:raw_file_content].bytesize > Import.max_csv_size
return render json: {
error: "content_too_large",
message: "Content is too large. Maximum size is #{Import::MAX_CSV_SIZE / 1.megabyte}MB."
message: "Content is too large. Maximum size is #{Import.max_csv_size / 1.megabyte}MB."
}, status: :unprocessable_entity
end
@@ -136,6 +136,30 @@ class Api::V1::ImportsController < Api::V1::BaseController
render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error
end
def preflight
preflight_result = Import::Preflight.new(family: current_resource_owner.family, params: preflight_params).call
render json: preflight_result.payload, status: preflight_result.status
rescue ActiveRecord::RecordNotFound
render json: {
error: "record_not_found",
message: "The requested resource was not found"
}, status: :not_found
rescue CSV::MalformedCSVError => e
render json: {
error: "invalid_csv",
message: "CSV content could not be parsed",
errors: [ e.message ]
}, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "ImportsController#preflight error: #{e.message}"
e.backtrace&.each { |line| Rails.logger.error line }
render json: {
error: "internal_server_error",
message: "Error: #{e.message}"
}, status: :internal_server_error
end
private
def set_import
@@ -186,10 +210,15 @@ class Api::V1::ImportsController < Api::V1::BaseController
:signage_convention,
:col_sep,
:amount_type_strategy,
:amount_type_inflow_value
:amount_type_inflow_value,
:rows_to_skip
)
end
def preflight_params
params.permit(*Import::Preflight::PARAM_KEYS)
end
def create_sure_import(family)
content, filename, content_type = sure_import_upload_attributes
return unless content
@@ -282,10 +311,10 @@ class Api::V1::ImportsController < Api::V1::BaseController
end
def sure_import_file_upload_attributes(file)
if file.size > SureImport::MAX_NDJSON_SIZE
if file.size > SureImport.max_ndjson_size
render json: {
error: "file_too_large",
message: "File is too large. Maximum size is #{SureImport::MAX_NDJSON_SIZE / 1.megabyte}MB."
message: "File is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB."
}, status: :unprocessable_entity
return
end
@@ -308,10 +337,10 @@ class Api::V1::ImportsController < Api::V1::BaseController
end
def sure_import_raw_content_attributes(content)
if content.bytesize > SureImport::MAX_NDJSON_SIZE
if content.bytesize > SureImport.max_ndjson_size
render json: {
error: "content_too_large",
message: "Content is too large. Maximum size is #{SureImport::MAX_NDJSON_SIZE / 1.megabyte}MB."
message: "Content is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB."
}, status: :unprocessable_entity
return
end

View File

@@ -10,8 +10,11 @@ class Api::V1::TransactionsController < Api::V1::BaseController
def index
family = current_resource_owner.family
accessible_account_ids = family.accounts.accessible_by(current_resource_owner).select(:id)
transactions_query = family.transactions.visible
accessible_account_ids = family.accounts
.accessible_by(current_resource_owner)
.where.not(status: "pending_deletion")
.select(:id)
transactions_query = family.transactions
.joins(:entry).where(entries: { account_id: accessible_account_ids })
# Apply filters
@@ -69,7 +72,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController
family = current_resource_owner.family
# Validate account_id is present
unless transaction_params[:account_id].present?
unless account_id_param.present?
render json: {
error: "validation_failed",
message: "Account ID is required",
@@ -78,7 +81,21 @@ class Api::V1::TransactionsController < Api::V1::BaseController
return
end
account = family.accounts.writable_by(current_resource_owner).find(transaction_params[:account_id])
if idempotency_source_param.present? && idempotency_external_id.blank?
render json: {
error: "validation_failed",
message: "Source requires external_id",
errors: [ "Source requires external_id" ]
}, status: :unprocessable_entity
return
end
account = family.accounts.writable_by(current_resource_owner).find(account_id_param)
if idempotency_key_requested? && (existing_entry = existing_idempotent_entry(account))
return render_existing_idempotent_entry(existing_entry)
end
@entry = account.entries.new(entry_params_for_create)
if @entry.save
@@ -96,6 +113,12 @@ class Api::V1::TransactionsController < Api::V1::BaseController
}, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotUnique
if idempotency_key_requested? && account && (existing_entry = existing_idempotent_entry(account))
render_existing_idempotent_entry(existing_entry)
else
raise
end
rescue => e
Rails.logger.error "TransactionsController#create error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
@@ -178,6 +201,8 @@ end
private
def set_transaction
raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id])
family = current_resource_owner.family
@transaction = family.transactions
.joins(entry: :account)
@@ -282,11 +307,15 @@ end
def transaction_params
params.require(:transaction).permit(
:account_id, :date, :amount, :name, :description, :notes, :currency,
:date, :amount, :name, :description, :notes, :currency,
:category_id, :merchant_id, :nature, tag_ids: []
)
end
def account_id_param
params.dig(:transaction, :account_id).presence
end
def entry_params_for_create
entry_params = {
name: transaction_params[:name] || transaction_params[:description],
@@ -301,6 +330,10 @@ end
tag_ids: transaction_params[:tag_ids] || []
}
}
if idempotency_key_requested?
entry_params[:external_id] = idempotency_external_id
entry_params[:source] = idempotency_source
end
entry_params.compact
end
@@ -339,6 +372,49 @@ end
params.dig(:transaction, :nature).present?
end
def idempotency_key_requested?
idempotency_external_id.present?
end
def idempotency_external_id
idempotency_param_value(:external_id)
end
def idempotency_source
idempotency_source_param.presence || "api"
end
def idempotency_source_param
idempotency_param_value(:source)
end
def idempotency_param_value(key)
value = params.dig(:transaction, key)
value.to_s.presence if value.is_a?(String) || value.is_a?(Numeric)
end
def existing_idempotent_entry(account)
account.entries.find_by(
external_id: idempotency_external_id,
source: idempotency_source
)
end
def render_existing_idempotent_entry(entry)
unless entry.entryable.is_a?(Transaction)
render json: {
error: "validation_failed",
message: "External ID already exists for a non-transaction entry",
errors: [ "External ID already exists for a non-transaction entry" ]
}, status: :unprocessable_entity
return
end
@entry = entry
@transaction = entry.transaction
render :show, status: :ok
end
def calculate_signed_amount
amount = transaction_params[:amount].to_f
nature = transaction_params[:nature]

View File

@@ -269,10 +269,6 @@ class Api::V1::ValuationsController < Api::V1::BaseController
raise InvalidFilterError, "#{key} must be an ISO 8601 date"
end
def valid_uuid?(value)
value.to_s.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
end
def safe_page_param
page = params[:page].to_i
page > 0 ? page : 1

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,236 @@
class IbkrItemsController < ApplicationController
before_action :set_ibkr_item, only: [ :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
before_action :require_admin!, only: [ :create, :select_accounts, :select_existing_account, :link_existing_account, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
def create
@ibkr_item = Current.family.ibkr_items.build(ibkr_item_params)
@ibkr_item.name ||= t("ibkr_items.defaults.name")
if @ibkr_item.save
@ibkr_item.sync_later
if turbo_frame_request?
flash.now[:notice] = t(".success")
render turbo_stream: [
turbo_stream.replace(
"ibkr-providers-panel",
partial: "settings/providers/ibkr_panel"
),
*flash_notification_stream_items
]
else
redirect_to accounts_path, notice: t(".success"), status: :see_other
end
else
@error_message = @ibkr_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"ibkr-providers-panel",
partial: "settings/providers/ibkr_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :see_other
end
end
end
def update
attrs = ibkr_item_params.to_h
attrs["query_id"] = @ibkr_item.query_id if attrs["query_id"].blank?
attrs["token"] = @ibkr_item.token if attrs["token"].blank?
if @ibkr_item.update(attrs.merge(status: :good))
@ibkr_item.sync_later unless @ibkr_item.syncing?
if turbo_frame_request?
flash.now[:notice] = t(".success")
render turbo_stream: [
turbo_stream.replace(
"ibkr-providers-panel",
partial: "settings/providers/ibkr_panel"
),
*flash_notification_stream_items
]
else
redirect_to accounts_path, notice: t(".success"), status: :see_other
end
else
@error_message = @ibkr_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"ibkr-providers-panel",
partial: "settings/providers/ibkr_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :see_other
end
end
end
def destroy
begin
@ibkr_item.unlink_all!(dry_run: false)
rescue => e
Rails.logger.warn("IBKR unlink during destroy failed: #{e.class} - #{e.message}")
end
@ibkr_item.destroy_later
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
def sync
@ibkr_item.sync_later unless @ibkr_item.syncing?
respond_to do |format|
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end
def select_accounts
ibkr_item = current_ibkr_item
unless ibkr_item
redirect_to settings_providers_path, alert: t(".not_configured")
return
end
redirect_to setup_accounts_ibkr_item_path(ibkr_item)
end
def select_existing_account
@account = Current.family.accounts.find(params[:account_id])
@available_ibkr_accounts = Current.family.ibkr_items
.includes(ibkr_accounts: { account_provider: :account })
.flat_map(&:ibkr_accounts)
.select { |ibkr_account| ibkr_account.account_provider.nil? }
.sort_by { |ibkr_account| ibkr_account.updated_at || ibkr_account.created_at }
.reverse
render :select_existing_account, layout: false
end
def link_existing_account
account = Current.family.accounts.find_by(id: params[:account_id])
ibkr_account = Current.family.ibkr_items
.joins(:ibkr_accounts)
.where(ibkr_accounts: { id: params[:ibkr_account_id] })
.first
&.ibkr_accounts
&.find_by(id: params[:ibkr_account_id])
if account.blank? || ibkr_account.blank?
redirect_to settings_providers_path, alert: t(".not_found")
return
end
if account.accountable_type != "Investment" || account.account_providers.any? || account.plaid_account_id.present? || account.simplefin_account_id.present?
redirect_to account_path(account), alert: t(".only_manual_investment")
return
end
provider = nil
ibkr_account.with_lock do
if ibkr_account.current_account.present?
redirect_to account_path(account), alert: t(".already_linked")
return
end
provider = ibkr_account.ensure_account_provider!(account)
end
raise "Failed to create AccountProvider link" unless provider
begin
IbkrAccount::Processor.new(ibkr_account.reload).process
rescue => e
Rails.logger.error("Failed to process linked IBKR account #{ibkr_account.id}: #{e.class} - #{e.message}")
end
ibkr_account.ibkr_item.sync_later unless ibkr_account.ibkr_item.syncing?
redirect_to account_path(account), notice: t(".success"), status: :see_other
rescue => e
Rails.logger.error("Failed to link existing IBKR account: #{e.class} - #{e.message}")
redirect_to settings_providers_path, alert: t(".failed"), status: :see_other
end
def setup_accounts
@ibkr_accounts = @ibkr_item.ibkr_accounts.includes(account_provider: :account)
@linked_accounts = @ibkr_accounts.select { |ibkr_account| ibkr_account.current_account.present? }
@unlinked_accounts = @ibkr_accounts.reject { |ibkr_account| ibkr_account.current_account.present? }
no_accounts = @linked_accounts.blank? && @unlinked_accounts.blank?
latest_sync = @ibkr_item.syncs.ordered.first
should_sync = latest_sync.nil? || !latest_sync.completed?
if no_accounts && !@ibkr_item.syncing? && should_sync
@ibkr_item.sync_later
end
@linkable_accounts = Current.family.accounts
.visible
.where(accountable_type: "Investment")
.left_joins(:account_providers)
.where(account_providers: { id: nil })
.order(:name)
@syncing = @ibkr_item.syncing?
@waiting_for_sync = no_accounts && @syncing
@no_accounts_found = no_accounts && !@syncing && @ibkr_item.last_synced_at.present?
end
def complete_account_setup
selected_accounts = Array(params[:account_ids]).reject(&:blank?)
created_accounts = []
selected_accounts.each do |ibkr_account_id|
ibkr_account = @ibkr_item.ibkr_accounts.find_by(id: ibkr_account_id)
next unless ibkr_account
ibkr_account.with_lock do
next if ibkr_account.current_account.present?
account = Account.create_from_ibkr_account(ibkr_account)
ibkr_account.ensure_account_provider!(account)
created_accounts << account
end
begin
IbkrAccount::Processor.new(ibkr_account.reload).process
rescue => e
Rails.logger.error("Failed to process IBKR account #{ibkr_account.id} after setup: #{e.class} - #{e.message}")
end
end
@ibkr_item.update!(pending_account_setup: @ibkr_item.unlinked_accounts_count.positive?)
@ibkr_item.sync_later if created_accounts.any?
if created_accounts.any?
redirect_to accounts_path, notice: t(".success", count: created_accounts.count), status: :see_other
elsif selected_accounts.empty?
redirect_to setup_accounts_ibkr_item_path(@ibkr_item), alert: t(".none_selected"), status: :see_other
else
redirect_to setup_accounts_ibkr_item_path(@ibkr_item), alert: t(".none_created"), status: :see_other
end
end
private
def set_ibkr_item
@ibkr_item = Current.family.ibkr_items.find(params[:id])
end
def current_ibkr_item
active_items = Current.family.ibkr_items.active
active_items.syncable.ordered.first || active_items.ordered.first
end
def ibkr_item_params
params.require(:ibkr_item).permit(:name, :query_id, :token)
end
end

View File

@@ -0,0 +1,241 @@
# frozen_string_literal: true
class KrakenItemsController < ApplicationController
before_action :set_kraken_item, only: %i[update destroy sync setup_accounts complete_account_setup]
before_action :require_admin!, only: %i[create select_accounts link_accounts select_existing_account link_existing_account update destroy sync setup_accounts complete_account_setup]
def create
@kraken_item = Current.family.kraken_items.build(kraken_item_params)
@kraken_item.name ||= t(".default_name")
if @kraken_item.save
@kraken_item.set_kraken_institution_defaults!
@kraken_item.sync_later
render_panel_success(t(".success"))
else
render_panel_error(@kraken_item.errors.full_messages.join(", "))
end
end
def update
if @kraken_item.update(kraken_item_params)
render_panel_success(t(".success"))
else
render_panel_error(@kraken_item.errors.full_messages.join(", "))
end
end
def destroy
@kraken_item.unlink_all!(dry_run: false)
@kraken_item.destroy_later
redirect_to settings_providers_path, notice: t(".success")
end
def sync
@kraken_item.sync_later unless @kraken_item.syncing?
respond_to do |format|
format.html { redirect_back_or_to settings_providers_path }
format.json { head :ok }
end
end
def select_accounts
account_flow = kraken_item_account_flow_context
kraken_item = account_flow[:kraken_item]
unless kraken_item
redirect_to settings_providers_path, alert: kraken_item_selection_message(account_flow[:credentialed_items])
return
end
redirect_to setup_accounts_kraken_item_path(kraken_item, return_to: safe_return_to_path), status: :see_other
end
def link_accounts
kraken_item = kraken_item_account_flow_context[:kraken_item]
unless kraken_item
redirect_to settings_providers_path, alert: t(".select_connection")
return
end
redirect_to setup_accounts_kraken_item_path(kraken_item), status: :see_other
end
def select_existing_account
@account = Current.family.accounts.find(params[:account_id])
account_flow = kraken_item_account_flow_context
@kraken_item = account_flow[:kraken_item]
unless manual_crypto_exchange_account?(@account)
redirect_to accounts_path, alert: t("kraken_items.link_existing_account.errors.only_manual")
return
end
unless @kraken_item
redirect_to settings_providers_path, alert: kraken_item_selection_message(account_flow[:credentialed_items])
return
end
@available_kraken_accounts = @kraken_item.kraken_accounts
.left_joins(:account_provider)
.where(account_providers: { id: nil })
.order(:name)
render :select_existing_account, layout: false
end
def link_existing_account
@account = Current.family.accounts.find(params[:account_id])
kraken_item = kraken_item_account_flow_context[:kraken_item]
unless manual_crypto_exchange_account?(@account)
return redirect_or_flash_error(t(".errors.only_manual"), account_path(@account))
end
unless kraken_item
redirect_to settings_providers_path, alert: t(".select_connection")
return
end
kraken_account = kraken_item.kraken_accounts.find_by(id: params[:kraken_account_id])
unless kraken_account
return redirect_or_flash_error(t(".errors.invalid_kraken_account"), account_path(@account))
end
if kraken_account.account_provider.present?
return redirect_or_flash_error(t(".errors.kraken_account_already_linked"), account_path(@account))
end
AccountProvider.create!(account: @account, provider: kraken_account)
kraken_item.sync_later
redirect_to accounts_path, notice: t(".success")
end
def setup_accounts
@kraken_accounts = unlinked_accounts_for(@kraken_item)
end
def complete_account_setup
selected_accounts = Array(params[:selected_accounts]).reject(&:blank?)
created_accounts = []
selected_accounts.each do |kraken_account_id|
kraken_account = @kraken_item.kraken_accounts.find_by(id: kraken_account_id)
next unless kraken_account
kraken_account.with_lock do
next if kraken_account.account_provider.present?
account = Account.create_from_kraken_account(kraken_account)
provider_link = kraken_account.ensure_account_provider!(account)
provider_link ? created_accounts << account : account.destroy!
end
KrakenAccount::Processor.new(kraken_account.reload).process
rescue StandardError => e
Rails.logger.error("Failed to setup account for KrakenAccount #{kraken_account_id}: #{e.message}")
end
@kraken_item.update!(pending_account_setup: unlinked_accounts_for(@kraken_item).exists?)
@kraken_item.sync_later if created_accounts.any?
notice = if created_accounts.any?
t(".success", count: created_accounts.count)
elsif selected_accounts.empty?
t(".none_selected")
else
t(".no_accounts")
end
redirect_to accounts_path, notice: notice, status: :see_other
end
private
def set_kraken_item
@kraken_item = Current.family.kraken_items.find(params[:id])
end
def kraken_item_params
permitted = params.require(:kraken_item).permit(:name, :sync_start_date, :api_key, :api_secret)
if @kraken_item&.persisted?
permitted.delete(:api_key) if permitted[:api_key].blank?
permitted.delete(:api_secret) if permitted[:api_secret].blank?
end
permitted
end
def render_panel_success(message)
if turbo_frame_request?
flash.now[:notice] = message
@kraken_items = Current.family.kraken_items.active.ordered
stream = turbo_stream.update("kraken-providers-panel", partial: "settings/providers/kraken_panel", locals: { kraken_items: @kraken_items })
render turbo_stream: [ stream, *flash_notification_stream_items ]
else
redirect_to settings_providers_path, notice: message, status: :see_other
end
end
def render_panel_error(message)
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"kraken-providers-panel",
partial: "settings/providers/kraken_panel",
locals: { error_message: message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: message, status: :see_other
end
end
def kraken_item_account_flow_context
credentialed_items = Current.family.kraken_items.active.credentials_configured.ordered.select(&:credentials_configured?)
item = if params[:kraken_item_id].present?
credentialed_items.find { |candidate| candidate.id.to_s == params[:kraken_item_id].to_s }
elsif credentialed_items.one?
credentialed_items.first
end
{ kraken_item: item, credentialed_items: credentialed_items }
end
def unlinked_accounts_for(kraken_item)
kraken_item.kraken_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).order(:name)
end
def kraken_item_selection_message(credentialed_items)
if credentialed_items.count > 1 && params[:kraken_item_id].blank?
t("kraken_items.select_accounts.select_connection")
else
t("kraken_items.select_accounts.no_credentials_configured")
end
end
def manual_crypto_exchange_account?(account)
account.manual_crypto_exchange?
end
def redirect_or_flash_error(message, fallback_path)
if turbo_frame_request?
flash.now[:alert] = message
render turbo_stream: Array(flash_notification_stream_items)
else
redirect_to fallback_path, alert: message
end
end
def safe_return_to_path
return nil if params[:return_to].blank?
value = params[:return_to].to_s
uri = URI.parse(value)
return nil if uri.scheme.present?
return nil if uri.host.present?
return nil unless value.start_with?("/")
value
rescue URI::InvalidURIError
nil
end
end

View File

@@ -187,9 +187,12 @@ 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" },
{ key: "snaptrade", title: "SnapTrade", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" },
{ key: "ibkr", title: "Interactive Brokers", turbo_id: "ibkr", partial: "ibkr_panel" },
{ key: "indexa_capital", title: "Indexa Capital", turbo_id: "indexa_capital", partial: "indexa_capital_panel" },
{ key: "sophtron", title: "Sophtron", turbo_id: "sophtron", partial: "sophtron_panel" }
].freeze
@@ -203,9 +206,12 @@ class Settings::ProvidersController < ApplicationController
"enable_banking" => "EnableBankingItem",
"coinstats" => "CoinstatsItem",
"mercury" => "MercuryItem",
"brex" => "BrexItem",
"coinbase" => "CoinbaseItem",
"binance" => "BinanceItem",
"kraken" => "KrakenItem",
"snaptrade" => "SnaptradeItem",
"ibkr" => "IbkrItem",
"indexa_capital" => "IndexaCapitalItem",
"sophtron" => "SophtronItem"
}.freeze
@@ -222,12 +228,18 @@ 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"
@binance_items = Current.family.binance_items.active.ordered
when "kraken"
@kraken_items = Current.family.kraken_items.active.ordered
when "snaptrade"
@snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered
when "ibkr"
@ibkr_items = Current.family.ibkr_items.ordered
when "indexa_capital"
@indexa_capital_items = Current.family.indexa_capital_items.ordered
when "sophtron"
@@ -251,10 +263,13 @@ 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)
@indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id)
@binance_items = Current.family.binance_items.active.ordered
@kraken_items = Current.family.kraken_items.active.ordered
@provider_sync_health = compute_provider_sync_health(family_panel_items)
@@ -277,9 +292,12 @@ 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,
"snaptrade" => @snaptrade_items,
"ibkr" => @ibkr_items,
"indexa_capital" => @indexa_capital_items,
"sophtron" => @sophtron_items
}

View File

@@ -27,6 +27,7 @@ class TransactionsController < ApplicationController
)
@pagy, @transactions = pagy(base_scope, limit: safe_per_page)
Transaction::ActivitySecurityPreloader.new(@transactions).preload
# Preload split parent data
entry_ids = @transactions.map { |t| t.entry.id }

View File

@@ -1,7 +1,7 @@
class TransfersController < ApplicationController
include StreamExtensions
before_action :set_transfer, only: %i[show destroy update]
before_action :set_transfer, only: %i[show destroy update mark_as_recurring]
before_action :set_accounts, only: %i[new create]
def new
@@ -11,6 +11,17 @@ class TransfersController < ApplicationController
def show
@categories = Current.family.categories.alphabetically
# Whether the current user can hit `mark_as_recurring`: feature flag on,
# AND they have write access to BOTH transfer endpoints. Gating the
# view button on this avoids showing a CTA that the controller would
# reject via `require_account_permission!` for read-only sharers.
endpoint_ids = [ @transfer.from_account&.id, @transfer.to_account&.id ].compact
writable_endpoint_count = Account.writable_by(Current.user).where(id: endpoint_ids).distinct.count
@can_mark_as_recurring_transfer =
!Current.family.recurring_transactions_disabled? &&
endpoint_ids.size == 2 &&
writable_endpoint_count == 2
end
def create
@@ -75,6 +86,65 @@ class TransfersController < ApplicationController
redirect_back_or_to transactions_url, notice: t(".success")
end
def mark_as_recurring
if Current.family.recurring_transactions_disabled?
flash[:alert] = t("recurring_transactions.transfer_feature_disabled")
redirect_back_or_to transactions_path
return
end
source_account = @transfer.from_account
destination_account = @transfer.to_account
if source_account.nil? || destination_account.nil?
flash[:alert] = t("recurring_transactions.unexpected_error")
redirect_back_or_to transactions_path
return
end
return unless require_account_permission!(source_account)
return unless require_account_permission!(destination_account)
existing = Current.family.recurring_transactions.find_by(
account_id: source_account.id,
destination_account_id: destination_account.id,
amount: @transfer.outflow_transaction.entry.amount,
currency: @transfer.outflow_transaction.entry.currency
)
if existing
flash[:alert] = t("recurring_transactions.transfer_already_exists")
respond_to do |format|
format.html { redirect_back_or_to transactions_path }
end
return
end
begin
RecurringTransaction.create_from_transfer(@transfer)
flash[:notice] = t("recurring_transactions.transfer_marked_as_recurring")
respond_to do |format|
format.html { redirect_back_or_to transactions_path }
end
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
# RecordNotUnique covers the race window between `find_by` and `create!`
# (the partial unique index protects us at the DB level).
flash[:alert] = t("recurring_transactions.transfer_creation_failed")
respond_to do |format|
format.html { redirect_back_or_to transactions_path }
end
rescue StandardError => e
Rails.logger.error(
"transfers#mark_as_recurring failed: #{e.class} #{e.message} " \
"(transfer=#{@transfer&.id} family=#{Current.family&.id} user=#{Current.user&.id})"
)
flash[:alert] = t("recurring_transactions.unexpected_error")
respond_to do |format|
format.html { redirect_back_or_to transactions_path }
end
end
end
private
def set_transfer
# Finds the transfer and ensures the user has access to it