mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
Merge branch 'main' into feat/savings-goals
Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
This commit is contained in:
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
132
app/controllers/brex_items/account_flows_controller.rb
Normal file
132
app/controllers/brex_items/account_flows_controller.rb
Normal 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
|
||||
109
app/controllers/brex_items/account_setups_controller.rb
Normal file
109
app/controllers/brex_items/account_setups_controller.rb
Normal 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
|
||||
98
app/controllers/brex_items_controller.rb
Normal file
98
app/controllers/brex_items_controller.rb
Normal 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
|
||||
236
app/controllers/ibkr_items_controller.rb
Normal file
236
app/controllers/ibkr_items_controller.rb
Normal 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
|
||||
241
app/controllers/kraken_items_controller.rb
Normal file
241
app/controllers/kraken_items_controller.rb
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user