Files
sure/app/controllers/snaptrade_items_controller.rb

366 lines
14 KiB
Ruby

class SnaptradeItemsController < ApplicationController
before_action :set_snaptrade_item, only: [ :show, :edit, :update, :destroy, :sync, :connect, :setup_accounts, :complete_account_setup ]
def index
@snaptrade_items = Current.family.snaptrade_items.ordered
end
def show
end
def new
@snaptrade_item = Current.family.snaptrade_items.build
end
def edit
end
def create
@snaptrade_item = Current.family.snaptrade_items.build(snaptrade_item_params)
@snaptrade_item.name ||= "SnapTrade Connection"
if @snaptrade_item.save
# Register user with SnapTrade after saving credentials
begin
@snaptrade_item.ensure_user_registered!
rescue => e
Rails.logger.error "SnapTrade user registration failed: #{e.message}"
# Don't fail the whole operation - user can retry connection later
end
if turbo_frame_request?
flash.now[:notice] = t(".success", default: "Successfully configured SnapTrade.")
@snaptrade_items = Current.family.snaptrade_items.ordered
render turbo_stream: [
turbo_stream.replace(
"snaptrade-providers-panel",
partial: "settings/providers/snaptrade_panel",
locals: { snaptrade_items: @snaptrade_items }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
else
@error_message = @snaptrade_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"snaptrade-providers-panel",
partial: "settings/providers/snaptrade_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity
end
end
end
def update
if @snaptrade_item.update(snaptrade_item_params)
if turbo_frame_request?
flash.now[:notice] = t(".success", default: "Successfully updated SnapTrade configuration.")
@snaptrade_items = Current.family.snaptrade_items.ordered
render turbo_stream: [
turbo_stream.replace(
"snaptrade-providers-panel",
partial: "settings/providers/snaptrade_panel",
locals: { snaptrade_items: @snaptrade_items }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
else
@error_message = @snaptrade_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"snaptrade-providers-panel",
partial: "settings/providers/snaptrade_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity
end
end
end
def destroy
@snaptrade_item.destroy_later
redirect_to settings_providers_path, notice: t(".success", default: "Scheduled SnapTrade connection for deletion.")
end
def sync
unless @snaptrade_item.syncing?
@snaptrade_item.sync_later
end
respond_to do |format|
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end
# Redirect user to SnapTrade connection portal
def connect
# Ensure user is registered first
unless @snaptrade_item.user_registered?
begin
@snaptrade_item.ensure_user_registered!
rescue => e
Rails.logger.error "SnapTrade registration error: #{e.class} - #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
redirect_to settings_providers_path, alert: t(".registration_failed", message: e.message)
return
end
end
# Get the connection portal URL - include item ID in callback for proper routing
redirect_url = callback_snaptrade_items_url(item_id: @snaptrade_item.id)
begin
portal_url = @snaptrade_item.connection_portal_url(redirect_url: redirect_url)
redirect_to portal_url, allow_other_host: true
rescue => e
Rails.logger.error "SnapTrade connection portal error: #{e.class} - #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
redirect_to settings_providers_path, alert: t(".portal_error", message: e.message)
end
end
# Handle callback from SnapTrade after user connects brokerage
def callback
# SnapTrade redirects back after user connects their brokerage
# The connection is already established - we just need to sync to get the accounts
unless params[:item_id].present?
redirect_to settings_providers_path, alert: t(".no_item")
return
end
snaptrade_item = Current.family.snaptrade_items.find_by(id: params[:item_id])
if snaptrade_item
# Trigger a sync to fetch the newly connected accounts
snaptrade_item.sync_later unless snaptrade_item.syncing?
# Redirect to accounts page - user can click "accounts need setup" badge
# when sync completes. This avoids the auto-refresh loop issues.
redirect_to accounts_path, notice: t(".success")
else
redirect_to settings_providers_path, alert: t(".no_item")
end
end
# Show available accounts for linking
def setup_accounts
@snaptrade_accounts = @snaptrade_item.snaptrade_accounts.includes(account_provider: :account)
@linked_accounts = @snaptrade_accounts.select { |sa| sa.current_account.present? }
@unlinked_accounts = @snaptrade_accounts.reject { |sa| sa.current_account.present? }
no_accounts = @unlinked_accounts.blank? && @linked_accounts.blank?
# If no accounts and not syncing, trigger a sync
if no_accounts && !@snaptrade_item.syncing?
@snaptrade_item.sync_later
end
# Determine view state
@syncing = @snaptrade_item.syncing?
@waiting_for_sync = no_accounts && @syncing
@no_accounts_found = no_accounts && !@syncing && @snaptrade_item.last_synced_at.present?
end
# Link selected accounts to Sure
def complete_account_setup
Rails.logger.info "SnapTrade complete_account_setup - params: #{params.to_unsafe_h.inspect}"
account_ids = params[:account_ids] || []
sync_start_dates = params[:sync_start_dates] || {}
Rails.logger.info "SnapTrade complete_account_setup - account_ids: #{account_ids.inspect}, sync_start_dates: #{sync_start_dates.inspect}"
linked_count = 0
errors = []
account_ids.each do |snaptrade_account_id|
snaptrade_account = @snaptrade_item.snaptrade_accounts.find_by(id: snaptrade_account_id)
unless snaptrade_account
Rails.logger.warn "SnapTrade complete_account_setup - snaptrade_account not found for id: #{snaptrade_account_id}"
next
end
if snaptrade_account.current_account.present?
Rails.logger.info "SnapTrade complete_account_setup - snaptrade_account #{snaptrade_account_id} already linked to account #{snaptrade_account.current_account.id}"
next
end
begin
# Save sync_start_date if provided
if sync_start_dates[snaptrade_account_id].present?
snaptrade_account.update!(sync_start_date: sync_start_dates[snaptrade_account_id])
end
Rails.logger.info "SnapTrade complete_account_setup - linking snaptrade_account #{snaptrade_account_id}"
link_snaptrade_account(snaptrade_account)
linked_count += 1
Rails.logger.info "SnapTrade complete_account_setup - successfully linked snaptrade_account #{snaptrade_account_id}"
rescue => e
Rails.logger.error "Failed to link SnapTrade account #{snaptrade_account_id}: #{e.class} - #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
errors << e.message
end
end
Rails.logger.info "SnapTrade complete_account_setup - completed. linked_count: #{linked_count}, errors: #{errors.inspect}"
if linked_count > 0
# Trigger sync to process the newly linked accounts
# Always queue the sync - if one is running, this will run after it finishes
@snaptrade_item.sync_later
if errors.any?
# Partial success - some linked, some failed
redirect_to accounts_path, notice: t(".partial_success", linked: linked_count, failed: errors.size,
default: "Linked #{linked_count} account(s). #{errors.size} failed to link.")
else
redirect_to accounts_path, notice: t(".success", count: linked_count, default: "Successfully linked #{linked_count} account(s).")
end
else
if errors.any?
# All failed
redirect_to setup_accounts_snaptrade_item_path(@snaptrade_item),
alert: t(".link_failed", default: "Failed to link accounts: %{errors}", errors: errors.first)
else
redirect_to setup_accounts_snaptrade_item_path(@snaptrade_item),
alert: t(".no_accounts", default: "No accounts were selected for linking.")
end
end
end
# Collection actions for account linking flow
def preload_accounts
snaptrade_item = Current.family.snaptrade_items.first
if snaptrade_item
snaptrade_item.sync_later unless snaptrade_item.syncing?
redirect_to setup_accounts_snaptrade_item_path(snaptrade_item)
else
redirect_to settings_providers_path, alert: t(".not_configured", default: "SnapTrade is not configured.")
end
end
def select_accounts
@accountable_type = params[:accountable_type]
@return_to = params[:return_to]
snaptrade_item = Current.family.snaptrade_items.first
if snaptrade_item
redirect_to setup_accounts_snaptrade_item_path(snaptrade_item, accountable_type: @accountable_type, return_to: @return_to)
else
redirect_to settings_providers_path, alert: t(".not_configured", default: "SnapTrade is not configured.")
end
end
def link_accounts
redirect_to settings_providers_path, alert: "Use the account setup flow instead"
end
def select_existing_account
@account_id = params[:account_id]
@account = Current.family.accounts.find_by(id: @account_id)
snaptrade_item = Current.family.snaptrade_items.first
if snaptrade_item && @account
@snaptrade_accounts = snaptrade_item.snaptrade_accounts
.left_joins(:account_provider)
.where(account_providers: { id: nil })
render :select_existing_account
else
redirect_to settings_providers_path, alert: t(".not_found", default: "Account or SnapTrade configuration not found.")
end
end
def link_existing_account
account_id = params[:account_id]
snaptrade_account_id = params[:snaptrade_account_id]
account = Current.family.accounts.find_by(id: account_id)
snaptrade_item = Current.family.snaptrade_items.first
snaptrade_account = snaptrade_item&.snaptrade_accounts&.find_by(id: snaptrade_account_id)
if account && snaptrade_account
begin
# Create AccountProvider linking - pass the account directly
provider = snaptrade_account.ensure_account_provider!(account)
unless provider
raise "Failed to create AccountProvider link"
end
# Trigger sync to process the linked account
snaptrade_item.sync_later unless snaptrade_item.syncing?
redirect_to account_path(account), notice: t(".success", default: "Successfully linked to SnapTrade account.")
rescue => e
Rails.logger.error "Failed to link existing account: #{e.message}"
redirect_to settings_providers_path, alert: t(".failed", default: "Failed to link account: #{e.message}")
end
else
redirect_to settings_providers_path, alert: t(".not_found", default: "Account not found.")
end
end
private
def set_snaptrade_item
@snaptrade_item = Current.family.snaptrade_items.find(params[:id])
end
def snaptrade_item_params
params.require(:snaptrade_item).permit(
:name,
:sync_start_date,
:client_id,
:consumer_key
)
end
def link_snaptrade_account(snaptrade_account)
# Determine account type based on SnapTrade account type
accountable_type = infer_accountable_type(snaptrade_account.account_type)
# Create the Sure account
account = Current.family.accounts.create!(
name: snaptrade_account.name,
balance: snaptrade_account.current_balance || 0,
cash_balance: snaptrade_account.cash_balance || 0,
currency: snaptrade_account.currency || Current.family.currency,
accountable: accountable_type.constantize.new
)
# Link via AccountProvider - pass the account directly
provider = snaptrade_account.ensure_account_provider!(account)
unless provider
Rails.logger.error "SnapTrade: Failed to create AccountProvider for snaptrade_account #{snaptrade_account.id}"
raise "Failed to link account"
end
account
end
def infer_accountable_type(snaptrade_type)
# SnapTrade account types: https://docs.snaptrade.com/reference/get_accounts
case snaptrade_type&.downcase
when "tfsa", "rrsp", "rrif", "resp", "rdsp", "lira", "lrsp", "lif", "rlsp", "prif",
"401k", "403b", "457b", "ira", "roth_ira", "roth_401k", "sep_ira", "simple_ira",
"pension", "retirement", "registered"
"Investment" # Tax-advantaged accounts
when "margin", "cash", "non-registered", "individual", "joint"
"Investment" # Standard brokerage accounts
when "crypto"
"Crypto"
else
"Investment" # Default to Investment for brokerage accounts
end
end
end