diff --git a/Gemfile b/Gemfile index 46729521b..075e52a1e 100644 --- a/Gemfile +++ b/Gemfile @@ -74,6 +74,7 @@ gem "rchardet" # Character encoding detection gem "redcarpet" gem "stripe" gem "plaid" +gem "snaptrade", "~> 2.0" gem "httparty" gem "rotp", "~> 6.3" gem "rqrcode", "~> 3.0" diff --git a/Gemfile.lock b/Gemfile.lock index d949cd9dd..540bb38c4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -681,6 +681,9 @@ GEM snaky_hash (2.0.3) hashie (>= 0.1.0, < 6) version_gem (>= 1.1.8, < 3) + snaptrade (2.0.156) + faraday (>= 1.0.1, < 3.0) + faraday-multipart (~> 1.0, >= 1.0.4) sorbet-runtime (0.5.12163) stackprof (0.2.27) stimulus-rails (1.3.4) @@ -844,6 +847,7 @@ DEPENDENCIES sidekiq-unique-jobs simplecov skylight + snaptrade (~> 2.0) stackprof stimulus-rails stripe diff --git a/app/components/DS/dialog.rb b/app/components/DS/dialog.rb index 3385003c1..11fce8f02 100644 --- a/app/components/DS/dialog.rb +++ b/app/components/DS/dialog.rb @@ -33,7 +33,7 @@ class DS::Dialog < DesignSystemComponent end end - attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :opts + attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :disable_click_outside, :opts VARIANTS = %w[modal drawer].freeze WIDTHS = { @@ -43,13 +43,14 @@ class DS::Dialog < DesignSystemComponent full: "lg:max-w-full" }.freeze - def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, **opts) + def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, disable_click_outside: false, **opts) @variant = variant.to_sym @auto_open = auto_open @reload_on_close = reload_on_close @width = width.to_sym @frame = frame @disable_frame = disable_frame + @disable_click_outside = disable_click_outside @opts = opts end @@ -102,6 +103,7 @@ class DS::Dialog < DesignSystemComponent data[:controller] = [ "DS--dialog", "hotkey", data[:controller] ].compact.join(" ") data[:DS__dialog_auto_open_value] = auto_open data[:DS__dialog_reload_on_close_value] = reload_on_close + data[:DS__dialog_disable_click_outside_value] = disable_click_outside data[:action] = [ "mousedown->DS--dialog#clickOutside", data[:action] ].compact.join(" ") data[:hotkey] = "esc:DS--dialog#close" merged_opts[:data] = data diff --git a/app/components/DS/dialog_controller.js b/app/components/DS/dialog_controller.js index 8d746ad9d..5391d42ae 100644 --- a/app/components/DS/dialog_controller.js +++ b/app/components/DS/dialog_controller.js @@ -7,6 +7,7 @@ export default class extends Controller { static values = { autoOpen: { type: Boolean, default: false }, reloadOnClose: { type: Boolean, default: false }, + disableClickOutside: { type: Boolean, default: false }, }; connect() { @@ -18,6 +19,7 @@ export default class extends Controller { // If the user clicks anywhere outside of the visible content, close the dialog clickOutside(e) { + if (this.disableClickOutsideValue) return; if (!this.contentTarget.contains(e.target)) { this.close(); } diff --git a/app/components/provider_sync_summary.html.erb b/app/components/provider_sync_summary.html.erb index c266feccf..b6156edf7 100644 --- a/app/components/provider_sync_summary.html.erb +++ b/app/components/provider_sync_summary.html.erb @@ -28,15 +28,22 @@ <% end %> <%# Transactions section - shown if provider collects transaction stats %> - <% if has_transaction_stats? %> + <% if has_transaction_stats? || activities_pending? %>

<%= t("provider_sync_summary.transactions.title") %>

-
- <%= t("provider_sync_summary.transactions.seen", count: tx_seen) %> - <%= t("provider_sync_summary.transactions.imported", count: tx_imported) %> - <%= t("provider_sync_summary.transactions.updated", count: tx_updated) %> - <%= t("provider_sync_summary.transactions.skipped", count: tx_skipped) %> -
+ <% if activities_pending? && !has_transaction_stats? %> +
+ <%= helpers.icon "loader-circle", size: "sm", class: "animate-spin text-secondary" %> + <%= t("provider_sync_summary.transactions.fetching") %> +
+ <% else %> +
+ <%= t("provider_sync_summary.transactions.seen", count: tx_seen) %> + <%= t("provider_sync_summary.transactions.imported", count: tx_imported) %> + <%= t("provider_sync_summary.transactions.updated", count: tx_updated) %> + <%= t("provider_sync_summary.transactions.skipped", count: tx_skipped) %> +
+ <% end %> <%# Protected entries detail - shown when entries were skipped due to protection %> <% if has_skipped_entries? %> @@ -81,6 +88,26 @@
<% end %> + <%# Trades section - shown if provider collects trades stats (investment activities) %> + <% if has_trades_stats? || activities_pending? %> +
+

<%= t("provider_sync_summary.trades.title") %>

+ <% if activities_pending? && !has_trades_stats? %> +
+ <%= helpers.icon "loader-circle", size: "sm", class: "animate-spin text-secondary" %> + <%= t("provider_sync_summary.trades.fetching") %> +
+ <% else %> +
+ <%= t("provider_sync_summary.trades.imported", count: trades_imported) %> + <% if trades_skipped > 0 %> + <%= t("provider_sync_summary.trades.skipped", count: trades_skipped) %> + <% end %> +
+ <% end %> +
+ <% end %> + <%# Health section - always shown %>

<%= t("provider_sync_summary.health.title") %>

diff --git a/app/components/provider_sync_summary.rb b/app/components/provider_sync_summary.rb index eb3fa31d7..8ef745719 100644 --- a/app/components/provider_sync_summary.rb +++ b/app/components/provider_sync_summary.rb @@ -19,15 +19,21 @@ # ) %> # class ProviderSyncSummary < ViewComponent::Base - attr_reader :stats, :provider_item, :institutions_count + attr_reader :stats, :provider_item, :institutions_count, :activities_pending # @param stats [Hash] The sync statistics hash from sync.sync_stats # @param provider_item [Object] The provider item (must respond to last_synced_at) # @param institutions_count [Integer, nil] Optional count of connected institutions - def initialize(stats:, provider_item:, institutions_count: nil) + # @param activities_pending [Boolean] Whether activities are still being fetched in background + def initialize(stats:, provider_item:, institutions_count: nil, activities_pending: false) @stats = stats || {} @provider_item = provider_item @institutions_count = institutions_count + @activities_pending = activities_pending + end + + def activities_pending? + @activities_pending end def render? @@ -102,6 +108,19 @@ class ProviderSyncSummary < ViewComponent::Base stats.key?("holdings_processed") ? holdings_processed : holdings_found end + # Trades statistics (investment activities like buy/sell) + def trades_imported + stats["trades_imported"].to_i + end + + def trades_skipped + stats["trades_skipped"].to_i + end + + def has_trades_stats? + stats.key?("trades_imported") || stats.key?("trades_skipped") + end + # Returns the CSS color class for a data quality detail severity # @param severity [String] The severity level ("warning", "error", or other) # @return [String] The Tailwind CSS class for the color diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 16f5f2f21..c7d23cf51 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -13,6 +13,7 @@ class AccountsController < ApplicationController @coinstats_items = family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs) @mercury_items = family.mercury_items.ordered.includes(:syncs, :mercury_accounts) @coinbase_items = family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs) + @snaptrade_items = family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts) # Build sync stats maps for all providers build_sync_stats_maps @@ -112,9 +113,16 @@ class AccountsController < ApplicationController Holding.where(account_provider_id: provider_link_ids).update_all(account_provider_id: nil) end - # Capture SimplefinAccount before clearing FK (so we can destroy it) + # Capture provider accounts before clearing links (so we can destroy them) simplefin_account_to_destroy = @account.simplefin_account + # Capture SnaptradeAccounts linked via AccountProvider + # Destroying them will trigger delete_snaptrade_connection callback to free connection slots + snaptrade_accounts_to_destroy = @account.account_providers + .where(provider_type: "SnaptradeAccount") + .map { |ap| SnaptradeAccount.find_by(id: ap.provider_id) } + .compact + # Remove new system links (account_providers join table) @account.account_providers.destroy_all @@ -127,6 +135,11 @@ class AccountsController < ApplicationController # - SimplefinAccount only caches API data which is regenerated on reconnect # - If user reconnects SimpleFin later, a new SimplefinAccount will be created simplefin_account_to_destroy&.destroy! + + # Destroy SnaptradeAccount records to free up SnapTrade connection slots + # The before_destroy callback will delete the connection from SnapTrade API + # if no other accounts share the same authorization + snaptrade_accounts_to_destroy.each(&:destroy!) end redirect_to accounts_path, notice: t("accounts.unlink.success") diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 36a3a2972..3808fac70 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -126,8 +126,9 @@ class Settings::ProvidersController < ApplicationController config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \ config.provider_key.to_s.casecmp("enable_banking").zero? || \ config.provider_key.to_s.casecmp("coinstats").zero? || \ - config.provider_key.to_s.casecmp("mercury").zero? - config.provider_key.to_s.casecmp("coinbase").zero? + config.provider_key.to_s.casecmp("mercury").zero? || \ + config.provider_key.to_s.casecmp("coinbase").zero? || \ + config.provider_key.to_s.casecmp("snaptrade").zero? end # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials @@ -137,5 +138,6 @@ class Settings::ProvidersController < ApplicationController @coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display @mercury_items = Current.family.mercury_items.ordered.select(:id) @coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display + @snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered end end diff --git a/app/controllers/snaptrade_items_controller.rb b/app/controllers/snaptrade_items_controller.rb new file mode 100644 index 000000000..5341e2d83 --- /dev/null +++ b/app/controllers/snaptrade_items_controller.rb @@ -0,0 +1,352 @@ +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 + redirect_to accounts_path, notice: t(".success", count: linked_count, default: "Successfully linked #{linked_count} account(s).") + else + redirect_to setup_accounts_snaptrade_item_path(@snaptrade_item), + alert: t(".no_accounts", default: "No accounts were selected for linking.") + 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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3e7fe1b42..f6010503e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -122,6 +122,31 @@ module ApplicationHelper markdown.render(text).html_safe end + # Formats quantity with adaptive precision based on the value size. + # Shows more decimal places for small quantities (common with crypto). + # + # @param qty [Numeric] The quantity to format + # @param max_precision [Integer] Maximum precision for very small numbers + # @return [String] Formatted quantity with appropriate precision + def format_quantity(qty) + return "0" if qty.nil? || qty.zero? + + abs_qty = qty.abs + + precision = if abs_qty >= 1 + 1 # "10.5" + elsif abs_qty >= 0.01 + 2 # "0.52" + elsif abs_qty >= 0.0001 + 4 # "0.0005" + else + 8 # "0.00000052" + end + + # Use strip_insignificant_zeros to avoid trailing zeros like "0.50000000" + number_with_precision(qty, precision: precision, strip_insignificant_zeros: true) + end + private def calculate_total(item, money_method, negate) # Filter out transfer-type transactions from entries diff --git a/app/jobs/snaptrade_activities_fetch_job.rb b/app/jobs/snaptrade_activities_fetch_job.rb new file mode 100644 index 000000000..810459de7 --- /dev/null +++ b/app/jobs/snaptrade_activities_fetch_job.rb @@ -0,0 +1,216 @@ +# Job for fetching SnapTrade activities with retry logic +# +# On fresh brokerage connections, SnapTrade may need 30-60+ seconds to sync +# data from the brokerage. This job handles that delay by rescheduling itself +# instead of blocking the worker with sleep(). +# +# Usage: +# SnaptradeActivitiesFetchJob.perform_later(snaptrade_account, start_date: 5.years.ago.to_date) +# +class SnaptradeActivitiesFetchJob < ApplicationJob + queue_as :default + + # Prevent concurrent jobs for the same account - only one fetch at a time + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [ args.first.id ] }, + on_conflict: :log + + # Configuration for retry behavior + RETRY_DELAY = 10.seconds + MAX_RETRIES = 6 + + def perform(snaptrade_account, start_date:, end_date: nil, retry_count: 0) + end_date ||= Date.current + + Rails.logger.info( + "SnaptradeActivitiesFetchJob - Fetching activities for account #{snaptrade_account.id}, " \ + "retry #{retry_count}/#{MAX_RETRIES}, range: #{start_date} to #{end_date}" + ) + + # Get provider and credentials + snaptrade_item = snaptrade_account.snaptrade_item + provider = snaptrade_item.snaptrade_provider + credentials = snaptrade_item.snaptrade_credentials + + unless provider && credentials + Rails.logger.error("SnaptradeActivitiesFetchJob - No provider/credentials for account #{snaptrade_account.id}") + snaptrade_account.update!(activities_fetch_pending: false) + snaptrade_account.snaptrade_item.broadcast_sync_complete + return + end + + # Fetch activities from API + activities_data = fetch_activities(snaptrade_account, provider, credentials, start_date, end_date) + + if activities_data.any? + Rails.logger.info( + "SnaptradeActivitiesFetchJob - Got #{activities_data.size} activities for account #{snaptrade_account.id}" + ) + + # Merge with existing and save + existing = snaptrade_account.raw_activities_payload || [] + merged = merge_activities(existing, activities_data) + snaptrade_account.upsert_activities_snapshot!(merged) + + # Process the activities into trades/transactions + process_activities(snaptrade_account) + elsif retry_count < MAX_RETRIES + # No activities yet, reschedule with delay + Rails.logger.info( + "SnaptradeActivitiesFetchJob - No activities yet for account #{snaptrade_account.id}, " \ + "rescheduling (#{retry_count + 1}/#{MAX_RETRIES})" + ) + + self.class.set(wait: RETRY_DELAY).perform_later( + snaptrade_account, + start_date: start_date, + end_date: end_date, + retry_count: retry_count + 1 + ) + else + Rails.logger.warn( + "SnaptradeActivitiesFetchJob - Max retries reached for account #{snaptrade_account.id}, " \ + "no activities fetched. This may be normal for new/empty accounts." + ) + + # Clear the pending flag even if no activities were found + # Otherwise the account stays stuck in "syncing" state forever + snaptrade_account.update!(activities_fetch_pending: false) + snaptrade_account.snaptrade_item.broadcast_sync_complete + end + rescue Provider::Snaptrade::AuthenticationError => e + Rails.logger.error("SnaptradeActivitiesFetchJob - Auth error for account #{snaptrade_account.id}: #{e.message}") + snaptrade_account.update!(activities_fetch_pending: false) + snaptrade_account.snaptrade_item.update!(status: :requires_update) + snaptrade_account.snaptrade_item.broadcast_sync_complete + rescue => e + Rails.logger.error("SnaptradeActivitiesFetchJob - Error for account #{snaptrade_account.id}: #{e.message}") + Rails.logger.error(e.backtrace.first(5).join("\n")) if e.backtrace + # Clear pending flag on error to avoid stuck syncing state + snaptrade_account.update!(activities_fetch_pending: false) rescue nil + snaptrade_account.snaptrade_item.broadcast_sync_complete rescue nil + end + + private + + def fetch_activities(snaptrade_account, provider, credentials, start_date, end_date) + response = provider.get_account_activities( + user_id: credentials[:user_id], + user_secret: credentials[:user_secret], + account_id: snaptrade_account.snaptrade_account_id, + start_date: start_date, + end_date: end_date + ) + + # Handle paginated response + activities = if response.respond_to?(:data) + response.data || [] + elsif response.is_a?(Array) + response + else + [] + end + + # Convert SDK objects to hashes + activities.map { |a| sdk_object_to_hash(a) } + end + + def sdk_object_to_hash(obj) + return obj if obj.is_a?(Hash) + + if obj.respond_to?(:to_json) + JSON.parse(obj.to_json) + elsif obj.respond_to?(:to_h) + obj.to_h + else + obj + end + rescue JSON::ParserError, TypeError + obj.respond_to?(:to_h) ? obj.to_h : {} + end + + # Merge activities, deduplicating by ID + # Fallback key includes symbol to distinguish activities with same date/type/amount + def merge_activities(existing, new_activities) + by_id = {} + + existing.each do |activity| + a = activity.with_indifferent_access + key = a[:id] || activity_fallback_key(a) + by_id[key] = activity + end + + new_activities.each do |activity| + a = activity.with_indifferent_access + key = a[:id] || activity_fallback_key(a) + by_id[key] = activity # Newer data wins + end + + by_id.values + end + + def activity_fallback_key(activity) + symbol = activity.dig(:symbol, :symbol) || activity.dig("symbol", "symbol") + [ activity[:settlement_date], activity[:type], activity[:amount], symbol ] + end + + def process_activities(snaptrade_account) + account = snaptrade_account.current_account + unless account.present? + snaptrade_account.update!(activities_fetch_pending: false) + snaptrade_account.snaptrade_item.broadcast_sync_complete + return + end + + processor = SnaptradeAccount::ActivitiesProcessor.new(snaptrade_account) + result = processor.process + + # Clear the pending flag since activities have been processed + snaptrade_account.update!(activities_fetch_pending: false) + + # Update the sync stats with the activity counts + # This ensures the sync summary shows accurate numbers even when + # activities are fetched asynchronously after the main sync + update_sync_stats(snaptrade_account, result) + + # Trigger UI refresh so new entries appear in the activity feed + # This is critical for fresh account connections where activities are fetched + # asynchronously after the main sync completes + account.broadcast_sync_complete + + # Also broadcast for the snaptrade_item to update its status (spinner → done) + snaptrade_account.snaptrade_item.broadcast_sync_complete + + Rails.logger.info( + "SnaptradeActivitiesFetchJob - Processed and broadcast activities for account #{snaptrade_account.id}" + ) + rescue => e + Rails.logger.error( + "SnaptradeActivitiesFetchJob - Failed to process activities for account #{snaptrade_account.id}: #{e.message}" + ) + end + + def update_sync_stats(snaptrade_account, result) + return unless result.is_a?(Hash) + + # Find the most recent sync for this SnapTrade item + sync = snaptrade_account.snaptrade_item.syncs.ordered.first + return unless sync&.respond_to?(:sync_stats) + + # Update the stats with the activity counts + current_stats = sync.sync_stats || {} + updated_stats = current_stats.merge( + "trades_imported" => (current_stats["trades_imported"] || 0) + (result[:trades] || 0), + "tx_seen" => (current_stats["tx_seen"] || 0) + (result[:transactions] || 0), + "tx_imported" => (current_stats["tx_imported"] || 0) + (result[:transactions] || 0) + ) + + sync.update_columns(sync_stats: updated_stats) + + Rails.logger.info( + "SnaptradeActivitiesFetchJob - Updated sync stats: trades=#{result[:trades]}, transactions=#{result[:transactions]}" + ) + rescue => e + Rails.logger.error("SnaptradeActivitiesFetchJob - Failed to update sync stats: #{e.message}") + end +end diff --git a/app/jobs/snaptrade_connection_cleanup_job.rb b/app/jobs/snaptrade_connection_cleanup_job.rb new file mode 100644 index 000000000..2320cb9d0 --- /dev/null +++ b/app/jobs/snaptrade_connection_cleanup_job.rb @@ -0,0 +1,71 @@ +# Job for cleaning up SnapTrade brokerage connections asynchronously +# +# This job is enqueued after a SnaptradeAccount is destroyed to delete +# the connection from SnapTrade's API if no other accounts share it. +# Running this asynchronously avoids blocking the destroy transaction +# with an external API call. +# +class SnaptradeConnectionCleanupJob < ApplicationJob + queue_as :default + + def perform(snaptrade_item_id:, authorization_id:, account_id:) + Rails.logger.info( + "SnaptradeConnectionCleanupJob - Cleaning up connection #{authorization_id} " \ + "for former account #{account_id}" + ) + + snaptrade_item = SnaptradeItem.find_by(id: snaptrade_item_id) + unless snaptrade_item + Rails.logger.info( + "SnaptradeConnectionCleanupJob - SnaptradeItem #{snaptrade_item_id} not found, " \ + "may have been deleted" + ) + return + end + + # Check if other accounts still use this authorization + if snaptrade_item.snaptrade_accounts.where(snaptrade_authorization_id: authorization_id).exists? + Rails.logger.info( + "SnaptradeConnectionCleanupJob - Skipping deletion, other accounts share " \ + "authorization #{authorization_id}" + ) + return + end + + provider = snaptrade_item.snaptrade_provider + credentials = snaptrade_item.snaptrade_credentials + + unless provider && credentials + Rails.logger.warn( + "SnaptradeConnectionCleanupJob - No provider/credentials for item #{snaptrade_item_id}" + ) + return + end + + Rails.logger.info( + "SnaptradeConnectionCleanupJob - Deleting SnapTrade connection #{authorization_id}" + ) + + provider.delete_connection( + user_id: credentials[:user_id], + user_secret: credentials[:user_secret], + authorization_id: authorization_id + ) + + Rails.logger.info( + "SnaptradeConnectionCleanupJob - Successfully deleted connection #{authorization_id}" + ) + rescue Provider::Snaptrade::ApiError => e + # Connection may already be gone or credentials invalid - log but don't retry + Rails.logger.warn( + "SnaptradeConnectionCleanupJob - Failed to delete connection #{authorization_id}: " \ + "#{e.class} - #{e.message}" + ) + rescue => e + Rails.logger.error( + "SnaptradeConnectionCleanupJob - Unexpected error deleting connection #{authorization_id}: " \ + "#{e.class} - #{e.message}" + ) + Rails.logger.error(e.backtrace.first(5).join("\n")) if e.backtrace + end +end diff --git a/app/models/concerns/sync_stats/collector.rb b/app/models/concerns/sync_stats/collector.rb index cff68c270..abcf43bc4 100644 --- a/app/models/concerns/sync_stats/collector.rb +++ b/app/models/concerns/sync_stats/collector.rb @@ -105,6 +105,32 @@ module SyncStats holdings_stats end + # Collects trades statistics (investment activities like buy/sell). + # + # @param sync [Sync] The sync record to update + # @param account_ids [Array] The account IDs to count trades for + # @param source [String] The trade source (e.g., "snaptrade", "plaid") + # @param window_start [Time, nil] Start of the sync window (defaults to sync.created_at or 30 minutes ago) + # @param window_end [Time, nil] End of the sync window (defaults to Time.current) + # @return [Hash] The trades stats that were collected + def collect_trades_stats(sync, account_ids:, source:, window_start: nil, window_end: nil) + return {} unless sync.respond_to?(:sync_stats) + return {} if account_ids.empty? + + window_start ||= sync.created_at || 30.minutes.ago + window_end ||= Time.current + + trade_scope = Entry.where(account_id: account_ids, source: source, entryable_type: "Trade") + trades_imported = trade_scope.where(created_at: window_start..window_end).count + + trades_stats = { + "trades_imported" => trades_imported + } + + merge_sync_stats(sync, trades_stats) + trades_stats + end + # Collects health/error statistics. # # @param sync [Sync] The sync record to update diff --git a/app/models/family.rb b/app/models/family.rb index 245b8a5bc..4feb0be00 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,7 +1,6 @@ class Family < ApplicationRecord - include MercuryConnectable - include CoinbaseConnectable - include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable, CoinstatsConnectable + include CoinbaseConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable + include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], diff --git a/app/models/family/snaptrade_connectable.rb b/app/models/family/snaptrade_connectable.rb new file mode 100644 index 000000000..c8c8876aa --- /dev/null +++ b/app/models/family/snaptrade_connectable.rb @@ -0,0 +1,30 @@ +module Family::SnaptradeConnectable + extend ActiveSupport::Concern + + included do + has_many :snaptrade_items, dependent: :destroy + end + + def can_connect_snaptrade? + # Families can configure their own Snaptrade credentials + true + end + + def create_snaptrade_item!(client_id:, consumer_key:, snaptrade_user_secret:, snaptrade_user_id: nil, item_name: nil) + snaptrade_item = snaptrade_items.create!( + name: item_name || "Snaptrade Connection", + client_id: client_id, + consumer_key: consumer_key, + snaptrade_user_id: snaptrade_user_id, + snaptrade_user_secret: snaptrade_user_secret + ) + + snaptrade_item.sync_later + + snaptrade_item + end + + def has_snaptrade_credentials? + snaptrade_items.where.not(client_id: nil).exists? + end +end diff --git a/app/models/provider/snaptrade.rb b/app/models/provider/snaptrade.rb new file mode 100644 index 000000000..5f4e84998 --- /dev/null +++ b/app/models/provider/snaptrade.rb @@ -0,0 +1,277 @@ +class Provider::Snaptrade + class Error < StandardError; end + class AuthenticationError < Error; end + class ConfigurationError < Error; end + class ApiError < Error + attr_reader :status_code, :response_body + + def initialize(message, status_code: nil, response_body: nil) + super(message) + @status_code = status_code + @response_body = response_body + end + end + + # Retry configuration for transient network failures + MAX_RETRIES = 3 + INITIAL_RETRY_DELAY = 2 # seconds + MAX_RETRY_DELAY = 30 # seconds + + attr_reader :client + + def initialize(client_id:, consumer_key:) + raise ConfigurationError, "client_id is required" if client_id.blank? + raise ConfigurationError, "consumer_key is required" if consumer_key.blank? + + configuration = SnapTrade::Configuration.new + configuration.client_id = client_id + configuration.consumer_key = consumer_key + @client = SnapTrade::Client.new(configuration) + end + + # Register a new SnapTrade user + # Returns { user_id: String, user_secret: String } + def register_user(user_id) + with_retries("register_user") do + response = client.authentication.register_snap_trade_user( + user_id: user_id + ) + { + user_id: response.user_id, + user_secret: response.user_secret + } + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "register_user") + end + + # Delete a SnapTrade user (resets all connections) + def delete_user(user_id:) + with_retries("delete_user") do + client.authentication.delete_snap_trade_user( + user_id: user_id + ) + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "delete_user") + end + + # List all registered users + def list_users + with_retries("list_users") do + client.authentication.list_snap_trade_users + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "list_users") + end + + # List all brokerage connections/authorizations + def list_connections(user_id:, user_secret:) + with_retries("list_connections") do + client.connections.list_brokerage_authorizations( + user_id: user_id, + user_secret: user_secret + ) + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "list_connections") + end + + # Delete a specific brokerage connection/authorization + # This frees up one of your connection slots + def delete_connection(user_id:, user_secret:, authorization_id:) + with_retries("delete_connection") do + client.connections.remove_brokerage_authorization( + user_id: user_id, + user_secret: user_secret, + authorization_id: authorization_id + ) + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "delete_connection") + end + + # Get connection portal URL (OAuth-like redirect to SnapTrade) + # Returns the redirect URL string + def get_connection_url(user_id:, user_secret:, redirect_url:, broker: nil) + with_retries("get_connection_url") do + response = client.authentication.login_snap_trade_user( + user_id: user_id, + user_secret: user_secret, + custom_redirect: redirect_url, + connection_type: "read", + broker: broker + ) + response.redirect_uri + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "get_connection_url") + end + + # List connected brokerage accounts + # Returns array of account objects + def list_accounts(user_id:, user_secret:) + with_retries("list_accounts") do + client.account_information.list_user_accounts( + user_id: user_id, + user_secret: user_secret + ) + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "list_accounts") + end + + # Get account details + def get_account_details(user_id:, user_secret:, account_id:) + with_retries("get_account_details") do + client.account_information.get_user_account_details( + user_id: user_id, + user_secret: user_secret, + account_id: account_id + ) + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "get_account_details") + end + + # Get positions/holdings for an account + # Returns array of position objects + def get_positions(user_id:, user_secret:, account_id:) + with_retries("get_positions") do + client.account_information.get_user_account_positions( + user_id: user_id, + user_secret: user_secret, + account_id: account_id + ) + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "get_positions") + end + + # Get all holdings across all accounts + def get_all_holdings(user_id:, user_secret:) + with_retries("get_all_holdings") do + client.account_information.get_all_user_holdings( + user_id: user_id, + user_secret: user_secret + ) + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "get_all_holdings") + end + + # Get holdings for a specific account (includes more details) + def get_holdings(user_id:, user_secret:, account_id:) + with_retries("get_holdings") do + client.account_information.get_user_holdings( + user_id: user_id, + user_secret: user_secret, + account_id: account_id + ) + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "get_holdings") + end + + # Get balances for an account + def get_balances(user_id:, user_secret:, account_id:) + with_retries("get_balances") do + client.account_information.get_user_account_balance( + user_id: user_id, + user_secret: user_secret, + account_id: account_id + ) + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "get_balances") + end + + # Get activity/transaction history for a specific account + # Supports pagination via start_date and end_date + def get_account_activities(user_id:, user_secret:, account_id:, start_date: nil, end_date: nil) + with_retries("get_account_activities") do + params = { + user_id: user_id, + user_secret: user_secret, + account_id: account_id + } + params[:start_date] = start_date.to_date.to_s if start_date + params[:end_date] = end_date.to_date.to_s if end_date + + client.account_information.get_account_activities(**params) + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "get_account_activities") + end + + # Get activities across all accounts (alternative endpoint) + def get_activities(user_id:, user_secret:, start_date: nil, end_date: nil, accounts: nil, brokerage_authorizations: nil, type: nil) + with_retries("get_activities") do + params = { + user_id: user_id, + user_secret: user_secret + } + params[:start_date] = start_date.to_date.to_s if start_date + params[:end_date] = end_date.to_date.to_s if end_date + params[:accounts] = accounts if accounts + params[:brokerage_authorizations] = brokerage_authorizations if brokerage_authorizations + params[:type] = type if type + + client.transactions_and_reporting.get_activities(**params) + end + rescue SnapTrade::ApiError => e + handle_api_error(e, "get_activities") + end + + private + + def handle_api_error(error, operation) + status = error.code + body = error.response_body + + Rails.logger.error("SnapTrade API error (#{operation}): #{status} - #{error.message}") + + case status + when 401, 403 + raise AuthenticationError, "Authentication failed: #{error.message}" + when 429 + raise ApiError.new("Rate limit exceeded. Please try again later.", status_code: status, response_body: body) + when 500..599 + raise ApiError.new("SnapTrade server error (#{status}). Please try again later.", status_code: status, response_body: body) + else + raise ApiError.new("SnapTrade API error: #{error.message}", status_code: status, response_body: body) + end + end + + def with_retries(operation_name, max_retries: MAX_RETRIES) + retries = 0 + + begin + yield + rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Errno::ECONNRESET, Errno::ETIMEDOUT => e + retries += 1 + + if retries <= max_retries + delay = calculate_retry_delay(retries) + Rails.logger.warn( + "SnapTrade API: #{operation_name} failed (attempt #{retries}/#{max_retries}): " \ + "#{e.class}: #{e.message}. Retrying in #{delay}s..." + ) + sleep(delay) + retry + else + Rails.logger.error( + "SnapTrade API: #{operation_name} failed after #{max_retries} retries: " \ + "#{e.class}: #{e.message}" + ) + raise ApiError.new("Network error after #{max_retries} retries: #{e.message}") + end + end + end + + def calculate_retry_delay(retry_count) + base_delay = INITIAL_RETRY_DELAY * (2 ** (retry_count - 1)) + jitter = base_delay * rand * 0.25 + [ base_delay + jitter, MAX_RETRY_DELAY ].min + end +end diff --git a/app/models/provider/snaptrade_adapter.rb b/app/models/provider/snaptrade_adapter.rb new file mode 100644 index 000000000..8f9a946fb --- /dev/null +++ b/app/models/provider/snaptrade_adapter.rb @@ -0,0 +1,105 @@ +class Provider::SnaptradeAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + # Register this adapter with the factory + Provider::Factory.register("SnaptradeAccount", self) + + # Define which account types this provider supports + # SnapTrade specializes in investment/brokerage accounts + def self.supported_account_types + %w[Investment Crypto] + end + + # Returns connection configurations for this provider + def self.connection_configs(family:) + return [] unless family.can_connect_snaptrade? + + [ { + key: "snaptrade", + name: I18n.t("providers.snaptrade.name"), + description: I18n.t("providers.snaptrade.connection_description"), + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_snaptrade_items_path( + accountable_type: accountable_type, + return_to: return_to + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_snaptrade_items_path( + account_id: account_id + ) + } + } ] + end + + def provider_name + "snaptrade" + end + + # Build a SnapTrade provider instance with family-specific credentials + # @param family [Family] The family to get credentials for (required) + # @return [Provider::Snaptrade, nil] Returns nil if credentials are not configured + def self.build_provider(family: nil) + return nil unless family.present? + + # Get family-specific credentials + snaptrade_item = family.snaptrade_items.where.not(client_id: nil).first + return nil unless snaptrade_item&.credentials_configured? + + Provider::Snaptrade.new( + client_id: snaptrade_item.client_id, + consumer_key: snaptrade_item.consumer_key + ) + end + + def sync_path + Rails.application.routes.url_helpers.sync_snaptrade_item_path(item) + end + + def item + provider_account.snaptrade_item + end + + def can_delete_holdings? + false + end + + def institution_domain + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + domain = metadata["domain"] + url = metadata["url"] + + # Derive domain from URL if missing + if domain.blank? && url.present? + begin + domain = URI.parse(url).host&.gsub(/^www\./, "") + rescue URI::InvalidURIError + Rails.logger.warn("Invalid institution URL for Snaptrade account #{provider_account.id}: #{url}") + end + end + + domain + end + + def institution_name + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["name"] || item&.institution_name + end + + def institution_url + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["url"] || item&.institution_url + end + + def institution_color + item&.institution_color + end +end diff --git a/app/models/snaptrade_account.rb b/app/models/snaptrade_account.rb new file mode 100644 index 000000000..8beeac9ae --- /dev/null +++ b/app/models/snaptrade_account.rb @@ -0,0 +1,198 @@ +class SnaptradeAccount < ApplicationRecord + include CurrencyNormalizable + + belongs_to :snaptrade_item + + # Association through account_providers for linking to Sure accounts + has_one :account_provider, as: :provider, dependent: :destroy + has_one :linked_account, through: :account_provider, source: :account + + validates :name, :currency, presence: true + + # Enqueue cleanup job after destruction to avoid blocking transaction with API call + after_destroy :enqueue_connection_cleanup + + # Helper to get the linked Sure account + def current_account + linked_account + end + + # Ensure there is an AccountProvider link for this SnapTrade account and the given Account. + # Safe and idempotent; returns the AccountProvider or nil if no account is provided. + def ensure_account_provider!(account = nil) + # If account_provider already exists, update it if needed + if account_provider.present? + account_provider.update!(account: account) if account && account_provider.account_id != account.id + return account_provider + end + + # Need an account to create the provider + acct = account || current_account + return nil unless acct + + provider = AccountProvider + .find_or_initialize_by(provider_type: "SnaptradeAccount", provider_id: id) + .tap do |p| + p.account = acct + p.save! + end + + # Reload the association so future accesses don't return stale/nil value + reload_account_provider + + provider + rescue => e + Rails.logger.warn("SnaptradeAccount##{id}: failed to ensure AccountProvider link: #{e.class} - #{e.message}") + nil + end + + # Import account data from SnapTrade API response + # Expected JSON structure: + # { + # "id": "uuid", + # "brokerage_authorization": "uuid", # just a string, not an object + # "name": "Robinhood Individual", + # "number": "123456", + # "institution_name": "Robinhood", + # "balance": { "total": { "amount": 1000.00, "currency": "USD" } }, + # "meta": { "type": "INDIVIDUAL", "institution_name": "Robinhood" } + # } + def upsert_from_snaptrade!(account_data) + # Deep convert SDK objects to hashes - .to_h only does top level, + # so we use JSON round-trip to get nested objects as hashes too + data = deep_convert_to_hash(account_data) + data = data.with_indifferent_access + + # Extract meta data + meta_data = (data[:meta] || {}).with_indifferent_access + + # Extract balance data - currency is nested in balance.total + balance_data = (data[:balance] || {}).with_indifferent_access + total_balance = (balance_data[:total] || {}).with_indifferent_access + + # Institution name can be at top level or in meta + institution_name = data[:institution_name] || meta_data[:institution_name] + + # brokerage_authorization is just a string ID, not an object + auth_id = data[:brokerage_authorization] + auth_id = auth_id[:id] if auth_id.is_a?(Hash) # handle both formats + + update!( + snaptrade_account_id: data[:id], + snaptrade_authorization_id: auth_id, + account_number: data[:number], + name: data[:name] || "#{institution_name} Account", + brokerage_name: institution_name, + currency: extract_currency_code(total_balance[:currency]) || "USD", + account_type: meta_data[:type] || data[:raw_type], + account_status: data[:status], + current_balance: total_balance[:amount], + institution_metadata: { + name: institution_name, + sync_status: data[:sync_status], + portfolio_group: data[:portfolio_group] + }.compact, + raw_payload: data + ) + end + + # Store holdings data from SnapTrade API + def upsert_holdings_snapshot!(holdings_data) + update!( + raw_holdings_payload: holdings_data, + last_holdings_sync: Time.current + ) + end + + # Store activities data from SnapTrade API + def upsert_activities_snapshot!(activities_data) + update!( + raw_activities_payload: activities_data, + last_activities_sync: Time.current + ) + end + + # Store balances data + # NOTE: This only updates cash_balance, NOT current_balance. + # current_balance represents total account value (holdings + cash) + # and is set by upsert_from_snaptrade! from the balance.total field. + def upsert_balances!(balances_data) + # Deep convert each balance entry to ensure we have hashes + data = Array(balances_data).map { |b| deep_convert_to_hash(b).with_indifferent_access } + + Rails.logger.info "SnaptradeAccount##{id} upsert_balances! - raw data: #{data.inspect}" + + # Find cash balance (usually in USD or account currency) + cash_entry = data.find { |b| b.dig(:currency, :code) == currency } || + data.find { |b| b.dig(:currency, :code) == "USD" } || + data.first + + if cash_entry + cash_value = cash_entry[:cash] + Rails.logger.info "SnaptradeAccount##{id} upsert_balances! - setting cash_balance=#{cash_value}" + + # Only update cash_balance, preserve current_balance (total account value) + update!(cash_balance: cash_value) + end + end + + # Get the SnapTrade provider instance via the parent item + def snaptrade_provider + snaptrade_item.snaptrade_provider + end + + # Get SnapTrade credentials for API calls + def snaptrade_credentials + snaptrade_item.snaptrade_credentials + end + + private + + # Enqueue a background job to clean up the SnapTrade connection + # This runs asynchronously after the record is destroyed to avoid + # blocking the DB transaction with an external API call + def enqueue_connection_cleanup + return unless snaptrade_authorization_id.present? + + SnaptradeConnectionCleanupJob.perform_later( + snaptrade_item_id: snaptrade_item_id, + authorization_id: snaptrade_authorization_id, + account_id: id + ) + end + + # Deep convert SDK objects to nested hashes + # The SnapTrade SDK returns objects that only convert the top level with .to_h + # We use JSON round-trip to ensure all nested objects become hashes + def deep_convert_to_hash(obj) + return obj if obj.is_a?(Hash) + + if obj.respond_to?(:to_json) + JSON.parse(obj.to_json) + elsif obj.respond_to?(:to_h) + obj.to_h + else + obj + end + rescue JSON::ParserError, TypeError + obj.respond_to?(:to_h) ? obj.to_h : {} + end + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for SnapTrade account #{id}, defaulting to USD") + end + + # Extract currency code from either a string or a currency object (hash with :code key) + # SnapTrade API may return currency as either format depending on the endpoint + def extract_currency_code(currency_value) + return nil if currency_value.blank? + + if currency_value.is_a?(Hash) + # Currency object: { code: "USD", id: "..." } + currency_value[:code] || currency_value["code"] + else + # String: "USD" + parse_currency(currency_value) + end + end +end diff --git a/app/models/snaptrade_account/activities_processor.rb b/app/models/snaptrade_account/activities_processor.rb new file mode 100644 index 000000000..bdfbfb29d --- /dev/null +++ b/app/models/snaptrade_account/activities_processor.rb @@ -0,0 +1,272 @@ +class SnaptradeAccount::ActivitiesProcessor + include SnaptradeAccount::DataHelpers + + # Map SnapTrade activity types to Sure activity labels + # SnapTrade types: https://docs.snaptrade.com/reference/get_activities + SNAPTRADE_TYPE_TO_LABEL = { + "BUY" => "Buy", + "SELL" => "Sell", + "DIVIDEND" => "Dividend", + "DIV" => "Dividend", + "CONTRIBUTION" => "Contribution", + "WITHDRAWAL" => "Withdrawal", + "TRANSFER_IN" => "Transfer", + "TRANSFER_OUT" => "Transfer", + "TRANSFER" => "Transfer", + "INTEREST" => "Interest", + "FEE" => "Fee", + "TAX" => "Fee", + "REI" => "Reinvestment", # Reinvestment + "REINVEST" => "Reinvestment", + "SPLIT" => "Other", + "SPLIT_REVERSE" => "Other", # Reverse stock split + "MERGER" => "Other", + "SPIN_OFF" => "Other", + "STOCK_DIVIDEND" => "Dividend", + "JOURNAL" => "Other", + "CASH" => "Contribution", # Cash deposit (non-retirement) + "CORP_ACTION" => "Other", # Corporate action + "OTHER" => "Other" + }.freeze + + # Activity types that result in Trade records (involves securities) + TRADE_TYPES = %w[BUY SELL REI REINVEST].freeze + + # Activity types that result in Transaction records (cash movements) + CASH_TYPES = %w[DIVIDEND DIV CONTRIBUTION WITHDRAWAL TRANSFER_IN TRANSFER_OUT TRANSFER INTEREST FEE TAX CASH].freeze + + def initialize(snaptrade_account) + @snaptrade_account = snaptrade_account + end + + def process + activities_data = @snaptrade_account.raw_activities_payload + return { trades: 0, transactions: 0 } if activities_data.blank? + + Rails.logger.info "SnaptradeAccount::ActivitiesProcessor - Processing #{activities_data.size} activities" + + @trades_count = 0 + @transactions_count = 0 + + activities_data.each do |activity_data| + process_activity(activity_data.with_indifferent_access) + rescue => e + Rails.logger.error "SnaptradeAccount::ActivitiesProcessor - Failed to process activity: #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace + end + + { trades: @trades_count, transactions: @transactions_count } + end + + private + + def account + @snaptrade_account.current_account + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def process_activity(data) + # Ensure we have indifferent access + data = data.with_indifferent_access if data.is_a?(Hash) + + activity_type = (data[:type] || data["type"])&.upcase + return if activity_type.blank? + + # Get external ID for deduplication + external_id = (data[:id] || data["id"]).to_s + return if external_id.blank? + + Rails.logger.info "SnaptradeAccount::ActivitiesProcessor - Processing activity: type=#{activity_type}, id=#{external_id}" + + # Determine if this is a trade or cash activity + if trade_activity?(activity_type) + process_trade(data, activity_type, external_id) + else + process_cash_activity(data, activity_type, external_id) + end + end + + def trade_activity?(activity_type) + TRADE_TYPES.include?(activity_type) + end + + def process_trade(data, activity_type, external_id) + # Extract and normalize symbol data + # SnapTrade activities have DIFFERENT structure than holdings: + # activity.symbol.symbol = "MSTR" (ticker string directly) + # activity.symbol.description = name + # Holdings have deeper nesting: symbol.symbol.symbol = ticker + raw_symbol_wrapper = data["symbol"] || data[:symbol] || {} + symbol_wrapper = raw_symbol_wrapper.is_a?(Hash) ? raw_symbol_wrapper.with_indifferent_access : {} + + # Get the symbol field - could be a string (ticker) or nested object + raw_symbol_data = symbol_wrapper["symbol"] || symbol_wrapper[:symbol] + + # Determine ticker based on data type + if raw_symbol_data.is_a?(String) + # Activities: symbol.symbol is the ticker string directly + ticker = raw_symbol_data + symbol_data = symbol_wrapper # Use the wrapper for description, etc. + elsif raw_symbol_data.is_a?(Hash) + # Holdings structure: symbol.symbol is an object with symbol inside + symbol_data = raw_symbol_data.with_indifferent_access + ticker = symbol_data["symbol"] || symbol_data[:symbol] + ticker = symbol_data["raw_symbol"] if ticker.is_a?(Hash) + else + ticker = nil + symbol_data = {} + end + + # Must have a symbol for trades + if ticker.blank? + Rails.logger.warn "SnaptradeAccount::ActivitiesProcessor - Skipping trade without symbol: #{external_id}" + return + end + + # Resolve security + security = resolve_security(ticker, symbol_data) + return unless security + + # Parse trade values + quantity = parse_decimal(data[:units]) || parse_decimal(data["units"]) || + parse_decimal(data[:quantity]) || parse_decimal(data["quantity"]) + price = parse_decimal(data[:price]) || parse_decimal(data["price"]) + + if quantity.nil? + Rails.logger.warn "SnaptradeAccount::ActivitiesProcessor - Skipping trade without quantity: #{external_id}" + return + end + + # Determine sign based on activity type + quantity = if activity_type == "SELL" + -quantity.abs + else + quantity.abs + end + + # Calculate amount + amount = if price + quantity * price + else + parse_decimal(data[:amount]) || parse_decimal(data["amount"]) || + parse_decimal(data[:trade_value]) || parse_decimal(data["trade_value"]) + end + + if amount.nil? + Rails.logger.warn "SnaptradeAccount::ActivitiesProcessor - Skipping trade without amount: #{external_id}" + return + end + + # Get the activity date + activity_date = parse_date(data[:settlement_date]) || parse_date(data["settlement_date"]) || + parse_date(data[:trade_date]) || parse_date(data["trade_date"]) || Date.current + + # Extract currency - handle both nested object and string + currency_data = data[:currency] || data["currency"] || symbol_data[:currency] || symbol_data["currency"] + currency = if currency_data.is_a?(Hash) + currency_data.with_indifferent_access[:code] + elsif currency_data.is_a?(String) + currency_data + else + account.currency + end + + description = data[:description] || data["description"] || "#{activity_type} #{ticker}" + + Rails.logger.info "SnaptradeAccount::ActivitiesProcessor - Importing trade: #{ticker} qty=#{quantity} price=#{price} date=#{activity_date}" + + result = import_adapter.import_trade( + external_id: external_id, + security: security, + quantity: quantity, + price: price, + amount: amount, + currency: currency, + date: activity_date, + name: description, + source: "snaptrade", + activity_label: label_from_type(activity_type) + ) + @trades_count += 1 if result + end + + def process_cash_activity(data, activity_type, external_id) + amount = parse_decimal(data[:amount]) || parse_decimal(data["amount"]) || + parse_decimal(data[:net_amount]) || parse_decimal(data["net_amount"]) + return if amount.nil? || amount.zero? + + # Get the activity date + activity_date = parse_date(data[:settlement_date]) || parse_date(data["settlement_date"]) || + parse_date(data[:trade_date]) || parse_date(data["trade_date"]) || Date.current + + # Build description + raw_symbol_data = data[:symbol] || data["symbol"] || {} + symbol_data = raw_symbol_data.is_a?(Hash) ? raw_symbol_data.with_indifferent_access : {} + symbol = symbol_data[:symbol] || symbol_data["symbol"] || symbol_data[:ticker] + description = data[:description] || data["description"] || build_description(activity_type, symbol) + + # Normalize amount sign for certain activity types + amount = normalize_cash_amount(amount, activity_type) + + # Extract currency - handle both nested object and string + currency_data = data[:currency] || data["currency"] + currency = if currency_data.is_a?(Hash) + currency_data.with_indifferent_access[:code] + elsif currency_data.is_a?(String) + currency_data + else + account.currency + end + + Rails.logger.info "SnaptradeAccount::ActivitiesProcessor - Importing cash activity: type=#{activity_type} amount=#{amount} date=#{activity_date}" + + result = import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: activity_date, + name: description, + source: "snaptrade", + investment_activity_label: label_from_type(activity_type) + ) + @transactions_count += 1 if result + end + + def normalize_cash_amount(amount, activity_type) + case activity_type + when "WITHDRAWAL", "TRANSFER_OUT", "FEE", "TAX" + -amount.abs # These should be negative (money out) + when "CONTRIBUTION", "TRANSFER_IN", "DIVIDEND", "DIV", "INTEREST", "CASH" + amount.abs # These should be positive (money in) + else + amount + end + end + + def build_description(activity_type, symbol) + type_label = label_from_type(activity_type) + if symbol.present? + "#{type_label} - #{symbol}" + else + type_label + end + end + + def label_from_type(activity_type) + normalized_type = activity_type&.upcase + label = SNAPTRADE_TYPE_TO_LABEL[normalized_type] + + if label.nil? && normalized_type.present? + # Log unmapped activity types for visibility - helps identify new types to add + Rails.logger.warn( + "SnaptradeAccount::ActivitiesProcessor - Unmapped activity type '#{normalized_type}' " \ + "for account #{@snaptrade_account.id}. Consider adding to SNAPTRADE_TYPE_TO_LABEL mapping." + ) + end + + label || "Other" + end +end diff --git a/app/models/snaptrade_account/data_helpers.rb b/app/models/snaptrade_account/data_helpers.rb new file mode 100644 index 000000000..17bb38dec --- /dev/null +++ b/app/models/snaptrade_account/data_helpers.rb @@ -0,0 +1,126 @@ +module SnaptradeAccount::DataHelpers + extend ActiveSupport::Concern + + private + + def parse_decimal(value) + return nil if value.nil? + + case value + when BigDecimal + value + when String + BigDecimal(value) + when Numeric + BigDecimal(value.to_s) + else + nil + end + rescue ArgumentError => e + Rails.logger.error("Failed to parse decimal value: #{value.inspect} - #{e.message}") + nil + end + + def parse_date(date_value) + return nil if date_value.nil? + + case date_value + when Date + date_value + when String + Date.parse(date_value) + when Time, DateTime, ActiveSupport::TimeWithZone + date_value.to_date + else + nil + end + rescue ArgumentError, TypeError => e + Rails.logger.error("Failed to parse date: #{date_value.inspect} - #{e.message}") + nil + end + + def resolve_security(symbol, symbol_data) + ticker = symbol.to_s.upcase.strip + return nil if ticker.blank? + + security = Security.find_by(ticker: ticker) + + # If security exists but has a bad name (looks like a hash), update it + if security && security.name&.start_with?("{") + new_name = extract_security_name(symbol_data, ticker) + Rails.logger.info "SnaptradeAccount - Fixing security name: #{security.name.first(50)}... -> #{new_name}" + security.update!(name: new_name) + end + + return security if security + + # Create new security + security_name = extract_security_name(symbol_data, ticker) + + Rails.logger.info "SnaptradeAccount - Creating security: ticker=#{ticker}, name=#{security_name}" + + Security.create!( + ticker: ticker, + name: security_name, + exchange_mic: extract_exchange(symbol_data), + country_code: extract_country_code(symbol_data) + ) + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e + # Handle race condition - another process may have created it + Rails.logger.error "Failed to create security #{ticker}: #{e.message}" + Security.find_by(ticker: ticker) # Retry find in case of race condition + end + + def extract_security_name(symbol_data, fallback_ticker) + # Try various paths where the name might be + name = symbol_data[:description] || symbol_data["description"] + + # If description is missing or looks like a type description, use ticker + if name.blank? || name.is_a?(Hash) || name =~ /^(COMMON STOCK|CRYPTOCURRENCY|ETF|MUTUAL FUND)$/i + name = fallback_ticker + end + + # Titleize for readability if it's all caps + name = name.titleize if name == name.upcase && name.length > 4 + + name + end + + def extract_exchange(symbol_data) + exchange = symbol_data[:exchange] || symbol_data["exchange"] + return nil unless exchange.is_a?(Hash) + + exchange.with_indifferent_access[:mic_code] || exchange.with_indifferent_access[:id] + end + + def extract_country_code(symbol_data) + # Try to extract country from currency or exchange + currency = symbol_data[:currency] + currency = currency.dig(:code) if currency.is_a?(Hash) + + case currency + when "USD" + "US" + when "CAD" + "CA" + when "GBP", "GBX" + "GB" + when "EUR" + nil # Could be many countries + else + nil + end + end + + def extract_currency(data, symbol_data = {}, fallback_currency = nil) + currency_data = data[:currency] || data["currency"] || symbol_data[:currency] || symbol_data["currency"] + + if currency_data.is_a?(Hash) + currency_data.with_indifferent_access[:code] + elsif currency_data.is_a?(String) + currency_data + else + fallback_currency + end + end +end diff --git a/app/models/snaptrade_account/holdings_processor.rb b/app/models/snaptrade_account/holdings_processor.rb new file mode 100644 index 000000000..e9216a34f --- /dev/null +++ b/app/models/snaptrade_account/holdings_processor.rb @@ -0,0 +1,135 @@ +class SnaptradeAccount::HoldingsProcessor + include SnaptradeAccount::DataHelpers + + def initialize(snaptrade_account) + @snaptrade_account = snaptrade_account + end + + def process + return unless account.present? + + holdings_data = @snaptrade_account.raw_holdings_payload + return if holdings_data.blank? + + Rails.logger.info "SnaptradeAccount::HoldingsProcessor - Processing #{holdings_data.size} holdings" + + # Log sample of first holding to understand structure + if holdings_data.first + sample = holdings_data.first + Rails.logger.info "SnaptradeAccount::HoldingsProcessor - Sample holding keys: #{sample.keys.first(10).join(', ')}" + if sample["symbol"] || sample[:symbol] + symbol_sample = sample["symbol"] || sample[:symbol] + Rails.logger.info "SnaptradeAccount::HoldingsProcessor - Symbol data keys: #{symbol_sample.keys.first(10).join(', ')}" if symbol_sample.is_a?(Hash) + end + end + + holdings_data.each_with_index do |holding_data, idx| + Rails.logger.info "SnaptradeAccount::HoldingsProcessor - Processing holding #{idx + 1}/#{holdings_data.size}" + process_holding(holding_data.with_indifferent_access) + rescue => e + Rails.logger.error "SnaptradeAccount::HoldingsProcessor - Failed to process holding #{idx + 1}: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace + end + end + + private + + def account + @snaptrade_account.current_account + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def process_holding(data) + # Extract security info from the holding + # SnapTrade has DEEPLY NESTED structure: + # holding.symbol.symbol.symbol = ticker (e.g., "TSLA") + # holding.symbol.symbol.description = name (e.g., "Tesla Inc") + raw_symbol_wrapper = data["symbol"] || data[:symbol] || {} + symbol_wrapper = raw_symbol_wrapper.is_a?(Hash) ? raw_symbol_wrapper.with_indifferent_access : {} + + # The actual security data is nested inside symbol.symbol + raw_symbol_data = symbol_wrapper["symbol"] || symbol_wrapper[:symbol] || {} + symbol_data = raw_symbol_data.is_a?(Hash) ? raw_symbol_data.with_indifferent_access : {} + + # Get the ticker - it's at symbol.symbol.symbol + ticker = symbol_data["symbol"] || symbol_data[:symbol] + + # If that's still a hash, we need to go deeper or use raw_symbol + if ticker.is_a?(Hash) + ticker = symbol_data["raw_symbol"] || symbol_data[:raw_symbol] + end + + return if ticker.blank? + + Rails.logger.info "SnaptradeAccount::HoldingsProcessor - Processing holding for ticker: #{ticker}" + + # Resolve or create the security + security = resolve_security(ticker, symbol_data) + return unless security + + # Parse values + quantity = parse_decimal(data["units"] || data[:units]) + price = parse_decimal(data["price"] || data[:price]) + return if quantity.nil? || price.nil? + + # Calculate amount + amount = quantity * price + + # Get the holding date (use current date if not provided) + holding_date = Date.current + + # Extract currency - it can be at the holding level or in symbol_data + currency_data = data["currency"] || data[:currency] || symbol_data["currency"] || symbol_data[:currency] + currency = if currency_data.is_a?(Hash) + currency_data.with_indifferent_access["code"] + elsif currency_data.is_a?(String) + currency_data + else + account.currency + end + + Rails.logger.info "SnaptradeAccount::HoldingsProcessor - Importing holding: #{ticker} qty=#{quantity} price=#{price} currency=#{currency}" + + # Import the holding via the adapter + import_adapter.import_holding( + security: security, + quantity: quantity, + amount: amount, + currency: currency, + date: holding_date, + price: price, + account_provider_id: @snaptrade_account.account_provider&.id, + source: "snaptrade", + delete_future_holdings: false + ) + + # Store cost basis if available + avg_price = data["average_purchase_price"] || data[:average_purchase_price] + if avg_price.present? + update_holding_cost_basis(security, avg_price) + end + end + + def update_holding_cost_basis(security, avg_cost) + # Find the most recent holding and update cost basis if not locked + holding = account.holdings + .where(security: security) + .where("cost_basis_source != 'manual' OR cost_basis_source IS NULL") + .order(date: :desc) + .first + + return unless holding + + # Store per-share cost, not total cost (cost_basis is per-share across the codebase) + cost_basis = parse_decimal(avg_cost) + return if cost_basis.nil? + + holding.update!( + cost_basis: cost_basis, + cost_basis_source: "provider" + ) + end +end diff --git a/app/models/snaptrade_account/processor.rb b/app/models/snaptrade_account/processor.rb new file mode 100644 index 000000000..b5edcb5e8 --- /dev/null +++ b/app/models/snaptrade_account/processor.rb @@ -0,0 +1,112 @@ +class SnaptradeAccount::Processor + include SnaptradeAccount::DataHelpers + + attr_reader :snaptrade_account + + def initialize(snaptrade_account) + @snaptrade_account = snaptrade_account + end + + def process + account = snaptrade_account.current_account + return unless account + + Rails.logger.info "SnaptradeAccount::Processor - Processing account #{snaptrade_account.id} -> Sure account #{account.id}" + + # Update account balance FIRST (before processing holdings/activities) + # This creates the current_anchor valuation needed for reverse sync + update_account_balance(account) + + # Process holdings + holdings_count = snaptrade_account.raw_holdings_payload&.size || 0 + Rails.logger.info "SnaptradeAccount::Processor - Holdings payload has #{holdings_count} items" + + if snaptrade_account.raw_holdings_payload.present? + Rails.logger.info "SnaptradeAccount::Processor - Processing holdings..." + SnaptradeAccount::HoldingsProcessor.new(snaptrade_account).process + else + Rails.logger.warn "SnaptradeAccount::Processor - No holdings payload to process" + end + + # Process activities (trades, dividends, etc.) + activities_count = snaptrade_account.raw_activities_payload&.size || 0 + Rails.logger.info "SnaptradeAccount::Processor - Activities payload has #{activities_count} items" + + if snaptrade_account.raw_activities_payload.present? + Rails.logger.info "SnaptradeAccount::Processor - Processing activities..." + SnaptradeAccount::ActivitiesProcessor.new(snaptrade_account).process + else + Rails.logger.warn "SnaptradeAccount::Processor - No activities payload to process" + end + + # Trigger immediate UI refresh so entries appear in the activity feed + # This is critical for fresh account links where the sync complete broadcast + # might be delayed by child syncs (balance calculations) + account.broadcast_sync_complete + Rails.logger.info "SnaptradeAccount::Processor - Broadcast sync complete for account #{account.id}" + + { holdings_processed: holdings_count > 0, activities_processed: activities_count > 0 } + end + + private + + def update_account_balance(account) + # Calculate total balance and cash balance from SnapTrade data + total_balance = calculate_total_balance + cash_balance = calculate_cash_balance + + Rails.logger.info "SnaptradeAccount::Processor - Balance update: total=#{total_balance}, cash=#{cash_balance}" + + # Update the cached fields on the account + account.assign_attributes( + balance: total_balance, + cash_balance: cash_balance, + currency: snaptrade_account.currency || account.currency + ) + account.save! + + # Create or update the current balance anchor valuation for linked accounts + # This is critical for reverse sync to work correctly + account.set_current_balance(total_balance) + end + + def calculate_total_balance + # Calculate total from holdings + cash for accuracy + # SnapTrade's current_balance can sometimes be stale or just the cash value + holdings_value = calculate_holdings_value + cash_value = snaptrade_account.cash_balance || 0 + + calculated_total = holdings_value + cash_value + + # Use calculated total if we have holdings, otherwise trust API value + if holdings_value > 0 + Rails.logger.info "SnaptradeAccount::Processor - Using calculated total: holdings=#{holdings_value} + cash=#{cash_value} = #{calculated_total}" + calculated_total + elsif snaptrade_account.current_balance.present? + Rails.logger.info "SnaptradeAccount::Processor - Using API total: #{snaptrade_account.current_balance}" + snaptrade_account.current_balance + else + calculated_total + end + end + + def calculate_cash_balance + # Use SnapTrade's cash_balance directly + # Note: Can be negative for margin accounts + cash = snaptrade_account.cash_balance + Rails.logger.info "SnaptradeAccount::Processor - Cash balance from API: #{cash.inspect}" + cash || BigDecimal("0") + end + + def calculate_holdings_value + holdings_data = snaptrade_account.raw_holdings_payload || [] + return 0 if holdings_data.empty? + + holdings_data.sum do |holding| + data = holding.is_a?(Hash) ? holding.with_indifferent_access : {} + units = parse_decimal(data[:units]) || 0 + price = parse_decimal(data[:price]) || 0 + units * price + end + end +end diff --git a/app/models/snaptrade_item.rb b/app/models/snaptrade_item.rb new file mode 100644 index 000000000..365d6af3f --- /dev/null +++ b/app/models/snaptrade_item.rb @@ -0,0 +1,217 @@ +class SnaptradeItem < ApplicationRecord + include Syncable, Provided, Unlinking + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + # Helper to detect if ActiveRecord Encryption is configured for this app + def self.encryption_ready? + creds_ready = Rails.application.credentials.active_record_encryption.present? + env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? + creds_ready || env_ready + end + + # Encrypt sensitive credentials if ActiveRecord encryption is configured + # client_id/consumer_key use deterministic encryption (may need querying) + # snaptrade_user_secret uses non-deterministic (more secure for pure secrets) + # Note: snaptrade_user_id is not encrypted as it's just an identifier, not a secret + if encryption_ready? + encrypts :client_id, deterministic: true + encrypts :consumer_key, deterministic: true + encrypts :snaptrade_user_secret + end + + validates :name, presence: true + validates :client_id, presence: true, on: :create + validates :consumer_key, presence: true, on: :create + # Note: snaptrade_user_id and snaptrade_user_secret are populated after user registration + # via ensure_user_registered!, so we don't validate them on create + + belongs_to :family + has_one_attached :logo + + has_many :snaptrade_accounts, dependent: :destroy + has_many :linked_accounts, through: :snaptrade_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def import_latest_snaptrade_data(sync: nil) + provider = snaptrade_provider + unless provider + Rails.logger.error "SnaptradeItem #{id} - Cannot import: provider is not configured" + raise StandardError, "SnapTrade provider is not configured" + end + + unless user_registered? + Rails.logger.error "SnaptradeItem #{id} - Cannot import: user not registered" + raise StandardError, "SnapTrade user not registered" + end + + SnaptradeItem::Importer.new(self, snaptrade_provider: provider, sync: sync).import + rescue => e + Rails.logger.error "SnaptradeItem #{id} - Failed to import data: #{e.message}" + raise + end + + def process_accounts + return [] if snaptrade_accounts.empty? + + results = [] + # Process only accounts that are linked to a Sure account + linked_snaptrade_accounts.includes(account_provider: :account).each do |snaptrade_account| + account = snaptrade_account.current_account + next unless account + next if account.pending_deletion? || account.disabled? + + begin + result = SnaptradeAccount::Processor.new(snaptrade_account).process + results << { snaptrade_account_id: snaptrade_account.id, success: true, result: result } + rescue => e + Rails.logger.error "SnaptradeItem #{id} - Failed to process account #{snaptrade_account.id}: #{e.message}" + results << { snaptrade_account_id: snaptrade_account.id, success: false, error: e.message } + end + end + + results + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + linked_accounts = accounts.reject { |a| a.pending_deletion? || a.disabled? } + return [] if linked_accounts.empty? + + results = [] + linked_accounts.each do |account| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue => e + Rails.logger.error "SnaptradeItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" + results << { account_id: account.id, success: false, error: e.message } + end + end + + results + end + + def upsert_snaptrade_snapshot!(accounts_snapshot) + assign_attributes( + raw_payload: accounts_snapshot + ) + + save! + end + + def has_completed_initial_setup? + # Setup is complete if we have any linked accounts + accounts.any? + end + + def sync_status_summary + total_accounts = total_accounts_count + linked_count = linked_accounts_count + unlinked_count = unlinked_accounts_count + + if total_accounts == 0 + I18n.t("snaptrade_item.sync_status.no_accounts") + elsif unlinked_count == 0 + I18n.t("snaptrade_item.sync_status.synced", count: linked_count) + else + I18n.t("snaptrade_item.sync_status.synced_with_setup", linked: linked_count, unlinked: unlinked_count) + end + end + + def linked_accounts_count + snaptrade_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + snaptrade_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + snaptrade_accounts.count + end + + def institution_display_name + institution_name.presence || institution_domain.presence || name + end + + def connected_institutions + snaptrade_accounts + .where.not(institution_metadata: nil) + .map { |acc| acc.institution_metadata } + .uniq { |inst| inst["name"] || inst["institution_name"] } + end + + def institution_summary + institutions = connected_institutions + case institutions.count + when 0 + I18n.t("snaptrade_item.institution_summary.none") + when 1 + institutions.first["name"] || institutions.first["institution_name"] || I18n.t("snaptrade_item.institution_summary.count", count: 1) + else + I18n.t("snaptrade_item.institution_summary.count", count: institutions.count) + end + end + + def credentials_configured? + client_id.present? && consumer_key.present? + end + + # Override Syncable#syncing? to also show syncing state when activities are being + # fetched in the background. This ensures the UI shows the spinner until all data + # is truly imported, not just when the main sync job completes. + def syncing? + super || snaptrade_accounts.where(activities_fetch_pending: true).exists? + end + + def fully_configured? + credentials_configured? && user_registered? + end + + # Get accounts linked via AccountProvider + def linked_snaptrade_accounts + snaptrade_accounts.joins(:account_provider) + end + + # Get all Sure accounts linked to this SnapTrade item + def accounts + snaptrade_accounts + .includes(account_provider: :account) + .filter_map { |sa| sa.current_account } + .uniq + end + + # Get unique brokerages from connected accounts + def connected_brokerages + snaptrade_accounts + .where.not(brokerage_name: nil) + .pluck(:brokerage_name) + .uniq + end + + def brokerage_summary + brokerages = connected_brokerages + case brokerages.count + when 0 + I18n.t("snaptrade_item.brokerage_summary.none") + when 1 + brokerages.first + else + I18n.t("snaptrade_item.brokerage_summary.count", count: brokerages.count) + end + end +end diff --git a/app/models/snaptrade_item/importer.rb b/app/models/snaptrade_item/importer.rb new file mode 100644 index 000000000..7e072f8ee --- /dev/null +++ b/app/models/snaptrade_item/importer.rb @@ -0,0 +1,458 @@ +class SnaptradeItem::Importer + include SyncStats::Collector + + attr_reader :snaptrade_item, :snaptrade_provider, :sync + + # Chunk size for fetching activities (365 days per chunk) + ACTIVITY_CHUNK_DAYS = 365 + MAX_ACTIVITY_CHUNKS = 3 # Up to 3 years of history + + # Minimum existing activities required before using incremental sync + # Prevents treating a partially synced account as "caught up" + MINIMUM_HISTORY_FOR_INCREMENTAL = 10 + + def initialize(snaptrade_item, snaptrade_provider:, sync: nil) + @snaptrade_item = snaptrade_item + @snaptrade_provider = snaptrade_provider + @sync = sync + end + + class CredentialsError < StandardError; end + + def import + Rails.logger.info "SnaptradeItem::Importer - Starting import for item #{snaptrade_item.id}" + + credentials = snaptrade_item.snaptrade_credentials + unless credentials + raise CredentialsError, "No SnapTrade credentials configured for item #{snaptrade_item.id}" + end + + # Step 1: Fetch and store all accounts + import_accounts(credentials) + + # Step 2: For LINKED accounts only, fetch holdings and activities + # Unlinked accounts just need basic info (name, balance) for the setup modal + # Query directly to avoid any association caching issues + linked_accounts = SnaptradeAccount + .where(snaptrade_item_id: snaptrade_item.id) + .joins(:account_provider) + + Rails.logger.info "SnaptradeItem::Importer - Found #{linked_accounts.count} linked accounts to process" + + linked_accounts.each do |snaptrade_account| + Rails.logger.info "SnaptradeItem::Importer - Processing linked account #{snaptrade_account.id} (#{snaptrade_account.snaptrade_account_id})" + import_account_data(snaptrade_account, credentials) + end + + # Update raw payload on the item + snaptrade_item.upsert_snaptrade_snapshot!(stats) + rescue Provider::Snaptrade::AuthenticationError => e + snaptrade_item.update!(status: :requires_update) + raise + end + + private + + # Convert SnapTrade SDK objects to hashes + # SDK objects don't have to_h but do have to_json + def sdk_object_to_hash(obj) + return obj if obj.is_a?(Hash) + + if obj.respond_to?(:to_json) + JSON.parse(obj.to_json) + elsif obj.respond_to?(:to_h) + obj.to_h + else + obj + end + rescue JSON::ParserError, TypeError + obj.respond_to?(:to_h) ? obj.to_h : {} + end + + # Extract activities array from API response + # get_account_activities returns a paginated object with .data accessor + # This handles both paginated responses and plain arrays + def extract_activities_from_response(response) + if response.respond_to?(:data) + # Paginated response (e.g., SnapTrade::PaginatedUniversalActivity) + Rails.logger.info "SnaptradeItem::Importer - Paginated response, extracting .data (#{response.data&.size || 0} items)" + response.data || [] + elsif response.is_a?(Array) + # Direct array response + Rails.logger.info "SnaptradeItem::Importer - Array response (#{response.size} items)" + response + else + Rails.logger.warn "SnaptradeItem::Importer - Unexpected response type: #{response.class}" + [] + end + end + + def stats + @stats ||= {} + end + + def persist_stats! + return unless sync&.respond_to?(:sync_stats) + merged = (sync.sync_stats || {}).merge(stats) + sync.update_columns(sync_stats: merged) + end + + def import_accounts(credentials) + Rails.logger.info "SnaptradeItem::Importer - Fetching accounts" + + accounts_data = snaptrade_provider.list_accounts( + user_id: credentials[:user_id], + user_secret: credentials[:user_secret] + ) + + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + stats["total_accounts"] = accounts_data.size + + # Track upstream account IDs to detect removed accounts + upstream_account_ids = [] + + accounts_data.each do |account_data| + begin + import_account(account_data, credentials) + upstream_account_ids << account_data.id.to_s if account_data.id + rescue => e + Rails.logger.error "SnaptradeItem::Importer - Failed to import account: #{e.message}" + stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1 + register_error(e, account_data: account_data) + end + end + + persist_stats! + + # Clean up accounts that no longer exist upstream + prune_removed_accounts(upstream_account_ids) + end + + def import_account(account_data, credentials) + # Find or create the SnaptradeAccount by SnapTrade's account ID + snaptrade_account_id = account_data.id.to_s + return if snaptrade_account_id.blank? + + snaptrade_account = snaptrade_item.snaptrade_accounts.find_or_initialize_by( + snaptrade_account_id: snaptrade_account_id + ) + + # Update from API data - pass raw SDK object, model handles conversion + snaptrade_account.upsert_from_snaptrade!(account_data) + + # Fetch and store balances + begin + balances = snaptrade_provider.get_balances( + user_id: credentials[:user_id], + user_secret: credentials[:user_secret], + account_id: snaptrade_account_id + ) + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + + # Pass raw SDK objects - model handles conversion + snaptrade_account.upsert_balances!(balances) + rescue => e + Rails.logger.warn "SnaptradeItem::Importer - Failed to fetch balances for account #{snaptrade_account_id}: #{e.message}" + end + + stats["accounts_imported"] = stats.fetch("accounts_imported", 0) + 1 + end + + def import_account_data(snaptrade_account, credentials) + snaptrade_account_id = snaptrade_account.snaptrade_account_id + return if snaptrade_account_id.blank? + + # Import holdings + import_holdings(snaptrade_account, credentials) + + # Import activities (chunked for history) + import_activities(snaptrade_account, credentials) + end + + def import_holdings(snaptrade_account, credentials) + Rails.logger.info "SnaptradeItem::Importer - Fetching holdings for account #{snaptrade_account.id} (#{snaptrade_account.snaptrade_account_id})" + + begin + holdings = snaptrade_provider.get_positions( + user_id: credentials[:user_id], + user_secret: credentials[:user_secret], + account_id: snaptrade_account.snaptrade_account_id + ) + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + + Rails.logger.info "SnaptradeItem::Importer - Got #{holdings.size} holdings from API" + + holdings_data = holdings.map { |h| sdk_object_to_hash(h) } + + # Log sample holding structure + if holdings_data.first + sample = holdings_data.first + Rails.logger.info "SnaptradeItem::Importer - Sample holding: #{sample.keys.join(', ')}" + if sample["symbol"] + Rails.logger.info "SnaptradeItem::Importer - Sample symbol keys: #{sample['symbol'].keys.join(', ')}" if sample["symbol"].is_a?(Hash) + Rails.logger.info "SnaptradeItem::Importer - Sample symbol.symbol: #{sample.dig('symbol', 'symbol')}" + Rails.logger.info "SnaptradeItem::Importer - Sample symbol.description: #{sample.dig('symbol', 'description')}" + end + end + + snaptrade_account.upsert_holdings_snapshot!(holdings_data) + + stats["holdings_found"] = stats.fetch("holdings_found", 0) + holdings_data.size + rescue => e + Rails.logger.error "SnaptradeItem::Importer - Failed to fetch holdings: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace + register_error(e, context: "holdings", account_id: snaptrade_account.id) + end + end + + def import_activities(snaptrade_account, credentials) + Rails.logger.info "SnaptradeItem::Importer - Fetching activities for account #{snaptrade_account.id} (#{snaptrade_account.snaptrade_account_id})" + + # Determine date range for fetching activities + # Use first_transaction_date from sync_status to know how far back history goes + first_tx_date = extract_first_transaction_date(snaptrade_account) + existing_count = snaptrade_account.raw_activities_payload&.size || 0 + + # User-configured sync start date acts as a floor - don't fetch activities before this date + user_sync_start = snaptrade_account.sync_start_date + + # Only do incremental sync if we already have meaningful history + # This ensures we do a full history fetch on first sync even if timestamps are set + can_do_incremental = snaptrade_item.last_synced_at.present? && + snaptrade_account.last_activities_sync.present? && + existing_count >= MINIMUM_HISTORY_FOR_INCREMENTAL + + if can_do_incremental + # Incremental sync - fetch from last sync minus buffer (synchronous) + start_date = snaptrade_account.last_activities_sync - 30.days + # Respect user's sync_start_date floor + start_date = [ start_date, user_sync_start ].compact.max + Rails.logger.info "SnaptradeItem::Importer - Incremental activities fetch from #{start_date} (existing: #{existing_count})" + fetch_all_activities(snaptrade_account, credentials, start_date: start_date) + else + # Full history - use user's sync_start_date if set, otherwise first_transaction_date + # Default to MAX_ACTIVITY_CHUNKS years ago to match chunk size + default_start = (MAX_ACTIVITY_CHUNKS * ACTIVITY_CHUNK_DAYS).days.ago.to_date + start_date = user_sync_start || first_tx_date || default_start + Rails.logger.info "SnaptradeItem::Importer - Full history fetch from #{start_date} (user_sync_start: #{user_sync_start || 'none'}, first_tx_date: #{first_tx_date || 'unknown'}, existing: #{existing_count})" + + # Try to fetch activities synchronously first + fetched_count = fetch_all_activities(snaptrade_account, credentials, start_date: start_date) + + if fetched_count == 0 && existing_count == 0 + # On fresh connection, SnapTrade may need time to sync data from brokerage + # Dispatch background job with retry logic instead of blocking the worker + Rails.logger.info( + "SnaptradeItem::Importer - No activities returned for account #{snaptrade_account.id}, " \ + "dispatching background fetch job (SnapTrade may still be syncing)" + ) + + SnaptradeActivitiesFetchJob.set(wait: 10.seconds).perform_later( + snaptrade_account, + start_date: start_date + ) + + # Mark the account as having pending activities + # The background job will clear this flag when done + snaptrade_account.update!(activities_fetch_pending: true) + end + end + + # Log what we have after fetching (may be 0 if job was dispatched) + final_count = snaptrade_account.reload.raw_activities_payload&.size || 0 + Rails.logger.info "SnaptradeItem::Importer - Activities stored: #{final_count}" + + if final_count > 0 && snaptrade_account.raw_activities_payload.first + sample = snaptrade_account.raw_activities_payload.first + Rails.logger.info "SnaptradeItem::Importer - Sample activity keys: #{sample.keys.join(', ')}" + Rails.logger.info "SnaptradeItem::Importer - Sample activity type: #{sample['type']}" + end + end + + # Extract first_transaction_date from account's sync_status + # Checks multiple locations: raw_payload and raw_activities_payload + def extract_first_transaction_date(snaptrade_account) + # Try 1: Check raw_payload (from list_accounts) + raw = snaptrade_account.raw_payload + if raw.is_a?(Hash) + date_str = raw.dig("sync_status", "transactions", "first_transaction_date") + return Date.parse(date_str) if date_str.present? + end + + # Try 2: Check activities payload (sync_status is nested in account object) + activities = snaptrade_account.raw_activities_payload + if activities.is_a?(Array) && activities.first.is_a?(Hash) + date_str = activities.first.dig("account", "sync_status", "transactions", "first_transaction_date") + return Date.parse(date_str) if date_str.present? + end + + nil + rescue ArgumentError, TypeError + nil + end + + # Fetch all activities using per-account endpoint with proper date range + # Uses get_account_activities which returns paginated data for the specific account + def fetch_all_activities(snaptrade_account, credentials, start_date:, end_date: nil) + # Ensure dates are proper Date objects (not strings or other types) + start_date = ensure_date(start_date) || 5.years.ago.to_date + end_date = ensure_date(end_date) || Date.current + all_activities = [] + + Rails.logger.info "SnaptradeItem::Importer - Fetching activities from #{start_date} to #{end_date}" + + begin + # Use get_account_activities (per-account endpoint) for better results + response = snaptrade_provider.get_account_activities( + user_id: credentials[:user_id], + user_secret: credentials[:user_secret], + account_id: snaptrade_account.snaptrade_account_id, + start_date: start_date, + end_date: end_date + ) + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + + # Handle paginated response + activities = extract_activities_from_response(response) + Rails.logger.info "SnaptradeItem::Importer - get_account_activities returned #{activities.size} items" + + activities_data = activities.map { |a| sdk_object_to_hash(a) } + all_activities.concat(activities_data) + + # If the per-account endpoint returned few results, also try the cross-account endpoint + # as a fallback (some brokerages may work better with one or the other) + if activities_data.size < 10 && (end_date - start_date).to_i > 365 + Rails.logger.info "SnaptradeItem::Importer - Few results from per-account endpoint, trying cross-account endpoint" + cross_account_activities = fetch_via_cross_account_endpoint( + snaptrade_account, credentials, start_date: start_date, end_date: end_date + ) + + if cross_account_activities.size > activities_data.size + Rails.logger.info "SnaptradeItem::Importer - Cross-account endpoint returned more: #{cross_account_activities.size} vs #{activities_data.size}" + all_activities = cross_account_activities + end + end + + # Only save if we actually got new activities + # Don't upsert empty arrays as this sets last_activities_sync incorrectly + if all_activities.any? + existing = snaptrade_account.raw_activities_payload || [] + merged = merge_activities(existing, all_activities) + snaptrade_account.upsert_activities_snapshot!(merged) + stats["activities_found"] = stats.fetch("activities_found", 0) + all_activities.size + end + + all_activities.size + rescue => e + Rails.logger.error "SnaptradeItem::Importer - Failed to fetch activities: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace + register_error(e, context: "activities", account_id: snaptrade_account.id) + 0 + end + end + + # Fallback: try the cross-account endpoint which may work better for some brokerages + def fetch_via_cross_account_endpoint(snaptrade_account, credentials, start_date:, end_date:) + activities = snaptrade_provider.get_activities( + user_id: credentials[:user_id], + user_secret: credentials[:user_secret], + start_date: start_date, + end_date: end_date, + accounts: snaptrade_account.snaptrade_account_id + ) + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + + activities = activities || [] + activities.map { |a| sdk_object_to_hash(a) } + rescue => e + Rails.logger.warn "SnaptradeItem::Importer - Cross-account endpoint fallback failed: #{e.message}" + [] + end + + # Merge activities, deduplicating by ID + # Fallback key includes symbol to distinguish activities with same date/type/amount + def merge_activities(existing, new_activities) + by_id = {} + + existing.each do |activity| + a = activity.with_indifferent_access + key = a[:id] || activity_fallback_key(a) + by_id[key] = activity + end + + new_activities.each do |activity| + a = activity.with_indifferent_access + key = a[:id] || activity_fallback_key(a) + by_id[key] = activity # Newer data wins + end + + by_id.values + end + + def activity_fallback_key(activity) + symbol = activity.dig(:symbol, :symbol) || activity.dig("symbol", "symbol") + [ activity[:settlement_date], activity[:type], activity[:amount], symbol ] + end + + def prune_removed_accounts(upstream_account_ids) + return if upstream_account_ids.blank? + + # Find accounts that no longer exist upstream + orphaned = snaptrade_item.snaptrade_accounts + .where.not(snaptrade_account_id: upstream_account_ids) + .where.not(snaptrade_account_id: nil) + + orphaned.each do |snaptrade_account| + # Only delete if not linked to a Sure account + if snaptrade_account.current_account.blank? + Rails.logger.info "SnaptradeItem::Importer - Pruning orphaned account #{snaptrade_account.id}" + snaptrade_account.destroy + stats["accounts_pruned"] = stats.fetch("accounts_pruned", 0) + 1 + end + end + end + + def register_error(error, account_data: nil, context: nil, account_id: nil) + # Extract account name safely from SDK object or hash + account_name = extract_account_name(account_data) + + stats["errors"] ||= [] + stats["errors"] << { + message: error.message, + context: context, + account_id: account_id, + account_name: account_name + }.compact + stats["errors"] = stats["errors"].last(10) + stats["total_errors"] = stats.fetch("total_errors", 0) + 1 + end + + def extract_account_name(account_data) + return nil if account_data.nil? + + if account_data.respond_to?(:name) + account_data.name + elsif account_data.respond_to?(:dig) + account_data.dig(:name) + elsif account_data.respond_to?(:[]) + account_data[:name] + end + end + + # Convert various date representations to a Date object + def ensure_date(value) + return nil if value.nil? + return value if value.is_a?(Date) + return value.to_date if value.is_a?(Time) || value.is_a?(DateTime) || value.is_a?(ActiveSupport::TimeWithZone) + + if value.is_a?(String) + Date.parse(value) + elsif value.respond_to?(:to_date) + value.to_date + else + nil + end + rescue ArgumentError, TypeError + nil + end +end diff --git a/app/models/snaptrade_item/provided.rb b/app/models/snaptrade_item/provided.rb new file mode 100644 index 000000000..952a496d3 --- /dev/null +++ b/app/models/snaptrade_item/provided.rb @@ -0,0 +1,134 @@ +module SnaptradeItem::Provided + extend ActiveSupport::Concern + + included do + before_destroy :delete_snaptrade_user + end + + def snaptrade_provider + return nil unless credentials_configured? + + Provider::Snaptrade.new( + client_id: client_id, + consumer_key: consumer_key + ) + end + + # Clean up SnapTrade user when item is destroyed + def delete_snaptrade_user + return unless user_registered? + + provider = snaptrade_provider + return unless provider + + Rails.logger.info "SnapTrade: Deleting user #{snaptrade_user_id} for family #{family_id}" + + provider.delete_user(user_id: snaptrade_user_id) + + Rails.logger.info "SnapTrade: Successfully deleted user #{snaptrade_user_id}" + rescue => e + # Log but don't block deletion - user may not exist or credentials may be invalid + Rails.logger.warn "SnapTrade: Failed to delete user #{snaptrade_user_id}: #{e.class} - #{e.message}" + end + + # User ID and secret for SnapTrade API calls + def snaptrade_credentials + return nil unless snaptrade_user_id.present? && snaptrade_user_secret.present? + + { + user_id: snaptrade_user_id, + user_secret: snaptrade_user_secret + } + end + + # Check if user is registered with SnapTrade + def user_registered? + snaptrade_user_id.present? && snaptrade_user_secret.present? + end + + # Register user with SnapTrade if not already registered + # Returns true if registration succeeded or already registered + # If existing credentials are invalid (user was deleted), clears them and re-registers + def ensure_user_registered! + # If we think we're registered, verify the user still exists + if user_registered? + if verify_user_exists? + return true + else + # User was deleted from SnapTrade API - clear local credentials and re-register + Rails.logger.warn "SnapTrade: User #{snaptrade_user_id} no longer exists, clearing credentials and re-registering" + update!(snaptrade_user_id: nil, snaptrade_user_secret: nil) + end + end + + provider = snaptrade_provider + raise StandardError, "SnapTrade provider not configured" unless provider + + # Use family ID with current timestamp to ensure uniqueness (avoids conflicts from previous deletions) + unique_user_id = "family_#{family_id}_#{Time.current.to_i}" + + Rails.logger.info "SnapTrade: Registering user #{unique_user_id} for family #{family_id}" + + result = provider.register_user(unique_user_id) + + Rails.logger.info "SnapTrade: Successfully registered user #{result[:user_id]}" + + update!( + snaptrade_user_id: result[:user_id], + snaptrade_user_secret: result[:user_secret] + ) + + true + rescue Provider::Snaptrade::ApiError => e + Rails.logger.error "SnapTrade user registration failed: #{e.class} - #{e.message}" + # Log status code but not response_body to avoid credential exposure + Rails.logger.error "SnapTrade error details: status=#{e.status_code}" if e.respond_to?(:status_code) + Rails.logger.debug { "SnapTrade response body: #{e.response_body&.truncate(500)}" } if e.respond_to?(:response_body) + + # Check if user already exists (shouldn't happen with timestamp suffix, but handle gracefully) + if e.message.include?("already registered") || e.message.include?("already exists") + Rails.logger.warn "SnapTrade: User already exists. Generating new unique ID." + raise StandardError, "User registration conflict. Please try again." + end + + raise + end + + # Verify that the stored user actually exists in SnapTrade + # Returns false if user doesn't exist, credentials are invalid, or verification fails + def verify_user_exists? + return false unless snaptrade_user_id.present? + + provider = snaptrade_provider + return false unless provider + + # Try to list connections - this will fail with 401/403 if user doesn't exist + provider.list_connections( + user_id: snaptrade_user_id, + user_secret: snaptrade_user_secret + ) + true + rescue Provider::Snaptrade::AuthenticationError => e + Rails.logger.warn "SnapTrade: User verification failed - #{e.message}" + false + rescue Provider::Snaptrade::ApiError => e + # Return false on API errors - caller can retry registration if needed + Rails.logger.warn "SnapTrade: User verification error - #{e.message}" + false + end + + # Get the connection portal URL for linking brokerages + def connection_portal_url(redirect_url:, broker: nil) + raise StandardError, "User not registered with SnapTrade" unless user_registered? + + provider = snaptrade_provider + raise StandardError, "SnapTrade provider not configured" unless provider + + provider.get_connection_url( + user_id: snaptrade_user_id, + user_secret: snaptrade_user_secret, + redirect_url: redirect_url, + broker: broker + ) + end +end diff --git a/app/models/snaptrade_item/sync_complete_event.rb b/app/models/snaptrade_item/sync_complete_event.rb new file mode 100644 index 000000000..647c32af8 --- /dev/null +++ b/app/models/snaptrade_item/sync_complete_event.rb @@ -0,0 +1,25 @@ +class SnaptradeItem::SyncCompleteEvent + attr_reader :snaptrade_item + + def initialize(snaptrade_item) + @snaptrade_item = snaptrade_item + end + + def broadcast + # Update UI with latest account data + snaptrade_item.accounts.each do |account| + account.broadcast_sync_complete + end + + # Update the SnapTrade item view + snaptrade_item.broadcast_replace_to( + snaptrade_item.family, + target: "snaptrade_item_#{snaptrade_item.id}", + partial: "snaptrade_items/snaptrade_item", + locals: { snaptrade_item: snaptrade_item } + ) + + # Let family handle sync notifications + snaptrade_item.family.broadcast_sync_complete + end +end diff --git a/app/models/snaptrade_item/syncer.rb b/app/models/snaptrade_item/syncer.rb new file mode 100644 index 000000000..7e69a967f --- /dev/null +++ b/app/models/snaptrade_item/syncer.rb @@ -0,0 +1,86 @@ +class SnaptradeItem::Syncer + include SyncStats::Collector + + attr_reader :snaptrade_item + + def initialize(snaptrade_item) + @snaptrade_item = snaptrade_item + end + + def perform_sync(sync) + Rails.logger.info "SnaptradeItem::Syncer - Starting sync for item #{snaptrade_item.id}" + + # Verify user is registered + unless snaptrade_item.user_registered? + raise StandardError, "User not registered with SnapTrade" + end + + # Phase 1: Import data from SnapTrade API + sync.update!(status_text: "Importing accounts from SnapTrade...") if sync.respond_to?(:status_text) + snaptrade_item.import_latest_snaptrade_data(sync: sync) + + # Phase 2: Collect setup statistics + finalize_setup_counts(sync) + + # Phase 3: Process holdings and activities for linked accounts + # Preload account_provider and account to avoid N+1 queries + linked_snaptrade_accounts = snaptrade_item.linked_snaptrade_accounts.includes(account_provider: :account) + if linked_snaptrade_accounts.any? + sync.update!(status_text: "Processing holdings and activities...") if sync.respond_to?(:status_text) + snaptrade_item.process_accounts + + # Phase 4: Schedule balance calculations + sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) + snaptrade_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + + # Phase 5: Collect transaction, trades, and holdings statistics + account_ids = linked_snaptrade_accounts.filter_map { |sa| sa.current_account&.id } + collect_transaction_stats(sync, account_ids: account_ids, source: "snaptrade") + collect_trades_stats(sync, account_ids: account_ids, source: "snaptrade") + collect_holdings_stats(sync, holdings_count: count_holdings, label: "processed") + end + + # Mark sync health + collect_health_stats(sync, errors: nil) + rescue Provider::Snaptrade::AuthenticationError => e + snaptrade_item.update!(status: :requires_update) + collect_health_stats(sync, errors: [ { message: e.message, category: "auth_error" } ]) + raise + rescue => e + collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ]) + raise + end + + # Public: called by Sync after finalization + def perform_post_sync + # no-op + end + + private + + def count_holdings + snaptrade_item.snaptrade_accounts.sum { |sa| Array(sa.raw_holdings_payload).size } + end + + def finalize_setup_counts(sync) + sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) + + total_accounts = snaptrade_item.total_accounts_count + linked_count = snaptrade_item.linked_accounts_count + unlinked_count = snaptrade_item.unlinked_accounts_count + + if unlinked_count > 0 + snaptrade_item.update!(pending_account_setup: true) + sync.update!(status_text: "#{unlinked_count} accounts need setup...") if sync.respond_to?(:status_text) + else + snaptrade_item.update!(pending_account_setup: false) + end + + # Collect setup stats + collect_setup_stats(sync, provider_accounts: snaptrade_item.snaptrade_accounts) + end +end diff --git a/app/models/snaptrade_item/unlinking.rb b/app/models/snaptrade_item/unlinking.rb new file mode 100644 index 000000000..e0e4fbf49 --- /dev/null +++ b/app/models/snaptrade_item/unlinking.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module SnaptradeItem::Unlinking + # Concern that encapsulates unlinking logic for a Snaptrade item. + extend ActiveSupport::Concern + + # Idempotently remove all connections between this Snaptrade item and local accounts. + # - Detaches any AccountProvider links for each SnaptradeAccount + # - Detaches Holdings that point at the AccountProvider links + # Returns a per-account result payload for observability + def unlink_all!(dry_run: false) + results = [] + + snaptrade_accounts.find_each do |provider_account| + links = AccountProvider.where(provider_type: "SnaptradeAccount", provider_id: provider_account.id).to_a + link_ids = links.map(&:id) + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + # Detach holdings for any provider links found + if link_ids.any? + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) + end + + # Destroy all provider links + links.each do |ap| + ap.destroy! + end + end + rescue StandardError => e + Rails.logger.warn( + "SnaptradeItem Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + # Record error for observability; continue with other accounts + result[:error] = e.message + end + end + + results + end +end diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 5ceb31549..6370f5a18 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -21,7 +21,7 @@
-<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @mercury_items.empty? && @coinbase_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @snaptrade_items.empty? %> <%= render "empty" %> <% else %>
@@ -53,6 +53,10 @@ <%= render @coinbase_items.sort_by(&:created_at) %> <% end %> + <% if @snaptrade_items.any? %> + <%= render @snaptrade_items.sort_by(&:created_at) %> + <% end %> + <% if @manual_accounts.any? %>
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %> diff --git a/app/views/holdings/_cost_basis_cell.html.erb b/app/views/holdings/_cost_basis_cell.html.erb index 24401b8bb..6e4b630bb 100644 --- a/app/views/holdings/_cost_basis_cell.html.erb +++ b/app/views/holdings/_cost_basis_cell.html.erb @@ -39,7 +39,7 @@ data-controller="cost-basis-form" data-cost-basis-form-qty-value="<%= holding.qty %>">

- <%= t(".set_cost_basis_header", ticker: holding.ticker, qty: number_with_precision(holding.qty, precision: 2)) %> + <%= t(".set_cost_basis_header", ticker: holding.ticker, qty: format_quantity(holding.qty)) %>

<% form_data = { turbo: false } diff --git a/app/views/holdings/_holding.html.erb b/app/views/holdings/_holding.html.erb index 57f0fd33a..164acfc1e 100644 --- a/app/views/holdings/_holding.html.erb +++ b/app/views/holdings/_holding.html.erb @@ -41,7 +41,7 @@ <% else %> <%= tag.p "--", class: "text-secondary" %> <% end %> - <%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-secondary" %> + <%= tag.p t(".shares", qty: format_quantity(holding.qty)), class: "font-normal text-secondary" %>
diff --git a/app/views/holdings/show.html.erb b/app/views/holdings/show.html.erb index 868b77f1d..b92463f2c 100644 --- a/app/views/holdings/show.html.erb +++ b/app/views/holdings/show.html.erb @@ -75,7 +75,7 @@ class: "space-y-3", data: drawer_form_data do |f| %>

- <%= t("holdings.cost_basis_cell.set_cost_basis_header", ticker: @holding.ticker, qty: number_with_precision(@holding.qty, precision: 4)) %> + <%= t("holdings.cost_basis_cell.set_cost_basis_header", ticker: @holding.ticker, qty: format_quantity(@holding.qty)) %>

diff --git a/app/views/settings/providers/_snaptrade_panel.html.erb b/app/views/settings/providers/_snaptrade_panel.html.erb new file mode 100644 index 000000000..5b74295ca --- /dev/null +++ b/app/views/settings/providers/_snaptrade_panel.html.erb @@ -0,0 +1,98 @@ +
+
+

<%= t("providers.snaptrade.description") %>

+ +

<%= t("providers.snaptrade.setup_title") %>

+
    +
  1. <%= t("providers.snaptrade.step_1_html") %>
  2. +
  3. <%= t("providers.snaptrade.step_2") %>
  4. +
  5. <%= t("providers.snaptrade.step_3") %>
  6. +
  7. <%= t("providers.snaptrade.step_4") %>
  8. +
+ +

<%= icon("alert-triangle", class: "inline-block w-4 h-4 mr-1") %><%= t("providers.snaptrade.free_tier_warning") %>

+
+ + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
+

<%= error_msg %>

+
+ <% end %> + + <% + snaptrade_item = Current.family.snaptrade_items.first_or_initialize(name: "SnapTrade Connection") + is_new_record = snaptrade_item.new_record? + is_configured = snaptrade_item.persisted? && snaptrade_item.credentials_configured? + is_registered = snaptrade_item.persisted? && snaptrade_item.user_registered? + %> + + <%= styled_form_with model: snaptrade_item, + url: is_new_record ? snaptrade_items_path : snaptrade_item_path(snaptrade_item), + scope: :snaptrade_item, + method: is_new_record ? :post : :patch, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :client_id, + label: t("providers.snaptrade.client_id_label"), + placeholder: is_new_record ? t("providers.snaptrade.client_id_placeholder") : t("providers.snaptrade.client_id_update_placeholder"), + type: :password %> + + <%= form.text_field :consumer_key, + label: t("providers.snaptrade.consumer_key_label"), + placeholder: is_new_record ? t("providers.snaptrade.consumer_key_placeholder") : t("providers.snaptrade.consumer_key_update_placeholder"), + type: :password %> + +
+ <%= form.submit is_new_record ? t("providers.snaptrade.save_button") : t("providers.snaptrade.update_button"), + class: "btn btn--primary" %> +
+ <% end %> + + <% items = local_assigns[:snaptrade_items] || @snaptrade_items || Current.family.snaptrade_items.where.not(client_id: [nil, ""]) %> + +
+ <% if items&.any? %> + <% item = items.first %> +
+
+ <% if item.user_registered? %> +
+

+ <% if item.snaptrade_accounts.any? %> + <%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %> + <% if item.unlinked_accounts_count > 0 %> + (<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>) + <% end %> + <% else %> + <%= t("providers.snaptrade.status_ready") %> + <% end %> +

+ <% else %> +
+

<%= t("providers.snaptrade.status_needs_registration") %>

+ <% end %> +
+ + <% if item.snaptrade_accounts.any? %> +
+ <%= link_to t("providers.snaptrade.setup_accounts_button"), + setup_accounts_snaptrade_item_path(item), + class: "btn btn--secondary btn--sm" %> +
+ <% end %> +
+ + <% if item.snaptrade_accounts.any? %> +
+

<%= t("providers.snaptrade.connected_brokerages") %> <%= item.brokerage_summary %>

+
+ <% end %> + <% else %> +
+
+

<%= t("providers.snaptrade.status_not_configured") %>

+
+ <% end %> +
+
diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 03175b748..92e7176ae 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -53,4 +53,10 @@ <%= render "settings/providers/coinbase_panel" %> <% end %> + + <%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false do %> + + <%= render "settings/providers/snaptrade_panel" %> + + <% end %>
diff --git a/app/views/snaptrade_items/_snaptrade_item.html.erb b/app/views/snaptrade_items/_snaptrade_item.html.erb new file mode 100644 index 000000000..ffa141edf --- /dev/null +++ b/app/views/snaptrade_items/_snaptrade_item.html.erb @@ -0,0 +1,154 @@ +<%# locals: (snaptrade_item:) %> + +<%= tag.div id: dom_id(snaptrade_item) do %> +
+ +
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
+ <% if snaptrade_item.logo.attached? %> + <%= image_tag snaptrade_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> + <% else %> +
+ <%= tag.p snaptrade_item.name.first.upcase, class: "text-primary text-xs font-medium" %> +
+ <% end %> +
+ + <% unlinked_count = snaptrade_item.unlinked_accounts_count %> + +
+
+ <%= tag.p snaptrade_item.name, class: "font-medium text-primary" %> + <% if unlinked_count > 0 %> + <%= link_to setup_accounts_snaptrade_item_path(snaptrade_item), + data: { turbo_frame: :modal }, + class: "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning hover:bg-warning/20 transition-colors" do %> + <%= icon "alert-circle", size: "xs" %> + <%= t(".accounts_need_setup", count: unlinked_count) %> + <% end %> + <% end %> + <% if snaptrade_item.scheduled_for_deletion? %> +

<%= t(".deletion_in_progress") %>

+ <% end %> +
+ <% if snaptrade_item.snaptrade_accounts.any? %> +

+ <%= snaptrade_item.brokerage_summary %> +

+ <% end %> + <% if snaptrade_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif snaptrade_item.requires_update? %> +
+ <%= icon "alert-triangle", size: "sm", color: "warning" %> + <%= tag.span t(".requires_update") %> +
+ <% elsif snaptrade_item.sync_error.present? %> +
+ <%= render DS::Tooltip.new(text: snaptrade_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> + <%= tag.span t(".error"), class: "text-destructive" %> +
+ <% else %> +

+ <% if snaptrade_item.last_synced_at %> + <%= t(".status", timestamp: time_ago_in_words(snaptrade_item.last_synced_at), summary: snaptrade_item.sync_status_summary) %> + <% else %> + <%= t(".status_never") %> + <% end %> +

+ <% end %> +
+
+ +
+ <% if snaptrade_item.requires_update? || !snaptrade_item.user_registered? %> + <%= render DS::Link.new( + text: t(".reconnect"), + icon: "link", + variant: "secondary", + href: connect_snaptrade_item_path(snaptrade_item) + ) %> + <% else %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_snaptrade_item_path(snaptrade_item), + disabled: snaptrade_item.syncing? + ) %> + <% end %> + + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "link", + text: t(".connect_brokerage"), + icon: "plus", + href: connect_snaptrade_item_path(snaptrade_item) + ) %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: snaptrade_item_path(snaptrade_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(snaptrade_item.name, high_severity: true) + ) %> + <% end %> +
+
+ + <% unless snaptrade_item.scheduled_for_deletion? %> +
+ <% if snaptrade_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: snaptrade_item.accounts %> +
+ <%= link_to connect_snaptrade_item_path(snaptrade_item), + class: "text-sm text-secondary hover:text-primary flex items-center gap-1 transition-colors" do %> + <%= icon "plus", size: "sm" %> + <%= t(".add_another_brokerage") %> + <% end %> +
+ <% end %> + + <%# Sync summary (collapsible) - using shared ProviderSyncSummary component %> + <% stats = snaptrade_item.syncs.ordered.first&.sync_stats || {} %> + <% activities_pending = snaptrade_item.snaptrade_accounts.any?(&:activities_fetch_pending) %> + <%= render ProviderSyncSummary.new( + stats: stats, + provider_item: snaptrade_item, + institutions_count: snaptrade_item.snaptrade_accounts.map(&:brokerage_name).uniq.compact.size, + activities_pending: activities_pending + ) %> + + <% if unlinked_count > 0 %> +
+

<%= t(".setup_needed") %>

+

<%= t(".setup_description") %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_snaptrade_item_path(snaptrade_item), + frame: :modal + ) %> +
+ <% elsif snaptrade_item.snaptrade_accounts.empty? %> +
+

<%= t(".no_accounts_title") %>

+

<%= t(".no_accounts_description") %>

+ <%= render DS::Link.new( + text: t(".connect_brokerage"), + icon: "link", + variant: "primary", + href: connect_snaptrade_item_path(snaptrade_item) + ) %> +
+ <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/snaptrade_items/select_existing_account.html.erb b/app/views/snaptrade_items/select_existing_account.html.erb new file mode 100644 index 000000000..ab4d49ff2 --- /dev/null +++ b/app/views/snaptrade_items/select_existing_account.html.erb @@ -0,0 +1,67 @@ +<% content_for :title, t("snaptrade_items.select_existing_account.title", default: "Link to SnapTrade Account") %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t("snaptrade_items.select_existing_account.header", default: "Link Existing Account")) do %> +
+ <%= icon "link", class: "text-primary" %> + <%= t("snaptrade_items.select_existing_account.subtitle", default: "Select a SnapTrade account to link to") %> <%= @account.name %> +
+ <% end %> + + <% dialog.with_body do %> + <% if @snaptrade_accounts.blank? %> +
+ <%= icon "alert-circle", class: "text-warning mx-auto mb-4", size: "lg" %> +

<%= t("snaptrade_items.select_existing_account.no_accounts", default: "No unlinked SnapTrade accounts available.") %>

+

<%= t("snaptrade_items.select_existing_account.connect_hint", default: "You may need to connect a brokerage first.") %>

+ <%= link_to t("snaptrade_items.select_existing_account.settings_link", default: "Go to Provider Settings"), settings_providers_path, class: "btn btn--primary btn--sm mt-4" %> +
+ <% else %> +
+
+

+ <%= t("snaptrade_items.select_existing_account.linking_to", default: "Linking to account:") %> + <%= @account.name %> +

+
+ + <% @snaptrade_accounts.each do |snaptrade_account| %> + <%= form_with url: link_existing_account_snaptrade_items_path, + method: :post, + local: true, + class: "border border-primary rounded-lg p-4 hover:bg-surface transition-colors" do |form| %> + <%= hidden_field_tag :account_id, @account.id %> + <%= hidden_field_tag :snaptrade_account_id, snaptrade_account.id %> + +
+
+

<%= snaptrade_account.name %>

+

+ <% if snaptrade_account.brokerage_name.present? %> + <%= snaptrade_account.brokerage_name %> • + <% end %> + <%= t("snaptrade_items.select_existing_account.balance_label", default: "Balance:") %> + <%= number_to_currency(snaptrade_account.current_balance || 0, unit: Money::Currency.new(snaptrade_account.currency || "USD").symbol) %> +

+
+ <%= render DS::Button.new( + text: t("snaptrade_items.select_existing_account.link_button", default: "Link"), + variant: "primary", + size: "sm", + type: "submit" + ) %> +
+ <% end %> + <% end %> +
+ +
+ <%= render DS::Link.new( + text: t("snaptrade_items.select_existing_account.cancel_button", default: "Cancel"), + variant: "secondary", + href: account_path(@account) + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/snaptrade_items/setup_accounts.html.erb b/app/views/snaptrade_items/setup_accounts.html.erb new file mode 100644 index 000000000..9331c4854 --- /dev/null +++ b/app/views/snaptrade_items/setup_accounts.html.erb @@ -0,0 +1,201 @@ +<% content_for :title, t("snaptrade_items.setup_accounts.title", default: "Set Up SnapTrade Accounts") %> + +<%= render DS::Dialog.new(disable_click_outside: true) do |dialog| %> + <% dialog.with_header(title: t("snaptrade_items.setup_accounts.header", default: "Set Up Your SnapTrade Accounts")) do %> +
+ <%= icon "trending-up", class: "text-primary" %> + <%= t("snaptrade_items.setup_accounts.subtitle", default: "Select which brokerage accounts to link") %> +
+ <% end %> + + <% dialog.with_body do %> +
+ <%# Always show the info box %> +
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +
+

+ <%= t("snaptrade_items.setup_accounts.info_title", default: "SnapTrade Investment Data") %> +

+
    +
  • <%= t("snaptrade_items.setup_accounts.info_holdings", default: "Holdings with current prices and quantities") %>
  • +
  • <%= t("snaptrade_items.setup_accounts.info_cost_basis", default: "Cost basis per position (when available)") %>
  • +
  • <%= t("snaptrade_items.setup_accounts.info_activities", default: "Trade history with activity labels (Buy, Sell, Dividend, etc.)") %>
  • +
  • <%= t("snaptrade_items.setup_accounts.info_history", default: "Up to 3 years of transaction history") %>
  • +
+

+ <%= icon "alert-triangle", size: "xs", class: "inline-block mr-1" %> + <%= t("snaptrade_items.setup_accounts.free_tier_note", default: "SnapTrade free tier allows 5 brokerage connections. Check your SnapTrade dashboard for current usage.") %> +

+
+
+
+ + <% if @waiting_for_sync %> + <%# Syncing state - show spinner with manual refresh option %> +
+
+

+ <%= t("snaptrade_items.setup_accounts.loading", default: "Fetching accounts from SnapTrade...") %> +

+

+ <%= t("snaptrade_items.setup_accounts.loading_hint", default: "Click Refresh to check for accounts.") %> +

+
+
+ <%= render DS::Link.new( + text: t("snaptrade_items.setup_accounts.refresh", default: "Refresh"), + variant: "secondary", + icon: "refresh-cw", + href: setup_accounts_snaptrade_item_path(@snaptrade_item), + frame: "_top" + ) %> + <%= render DS::Link.new( + text: t("snaptrade_items.setup_accounts.cancel_button", default: "Cancel"), + variant: "ghost", + href: accounts_path, + frame: "_top" + ) %> +
+ <% elsif @no_accounts_found %> + <%# No accounts found after sync completed %> +
+ <%= icon "alert-circle", size: "lg", class: "text-warning" %> +

+ <%= t("snaptrade_items.setup_accounts.no_accounts_title", default: "No Accounts Found") %> +

+

+ <%= t("snaptrade_items.setup_accounts.no_accounts_message", default: "No brokerage accounts were found. This can happen if you cancelled the connection or if your brokerage isn't supported.") %> +

+
+
+ <%= render DS::Link.new( + text: t("snaptrade_items.setup_accounts.try_again", default: "Connect Brokerage"), + variant: "primary", + href: connect_snaptrade_item_path(@snaptrade_item), + frame: "_top" + ) %> + <%= render DS::Link.new( + text: t("snaptrade_items.setup_accounts.back_to_settings", default: "Back to Settings"), + variant: "secondary", + href: settings_providers_path, + frame: "_top" + ) %> +
+ <% else %> + <%= form_with url: complete_account_setup_snaptrade_item_path(@snaptrade_item), + method: :post, + data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: t("snaptrade_items.setup_accounts.creating", default: "Creating Accounts..."), + turbo_frame: "_top" + } do |form| %> + + <% if @unlinked_accounts.any? %> +
+

<%= t("snaptrade_items.setup_accounts.available_accounts", default: "Available Accounts") %>

+ + <% @unlinked_accounts.each do |snaptrade_account| %> +
+
+ + +
+
+ + +

+ <%= t("snaptrade_items.setup_accounts.sync_start_date_help", default: "Leave blank for all available history") %> +

+
+
+ <% end %> +
+ +
+ <%= render DS::Button.new( + text: t("snaptrade_items.setup_accounts.create_button", default: "Create Selected Accounts"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1", + data: { loading_button_target: "button" } + ) %> + <%= render DS::Link.new( + text: t("snaptrade_items.setup_accounts.cancel_button", default: "Cancel"), + variant: "secondary", + href: accounts_path, + frame: "_top" + ) %> +
+ <% end %> + + <% if @linked_accounts.any? %> +
"> +

<%= t("snaptrade_items.setup_accounts.linked_accounts", default: "Already Linked") %>

+ <% @linked_accounts.each do |snaptrade_account| %> +
+
+
+ <%= icon "check-circle", class: "text-success" %> +
+

<%= snaptrade_account.name %>

+

+ <%= t("snaptrade_items.setup_accounts.linked_to", default: "Linked to:") %> + <%= link_to snaptrade_account.current_account.name, account_path(snaptrade_account.current_account), class: "link" %> +

+
+
+
+
+ <% end %> +
+ + <%# Show Done button when all accounts are linked (no unlinked) %> + <% if @unlinked_accounts.blank? %> +
+ <%= render DS::Link.new( + text: t("snaptrade_items.setup_accounts.done_button", default: "Done"), + variant: "primary", + href: accounts_path, + frame: "_top" + ) %> +
+ <% end %> + <% end %> + + <% end %> + <% end %> +
+ <% end %> +<% end %> diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 0cb225287..a03fb993b 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,5 +1,28 @@ { "ignored_warnings": [ + { + "warning_type": "Redirect", + "warning_code": 18, + "fingerprint": "556f2fdd1f091ed50811cb2cce28dd2b987cd0a2eed4d19bea138c8c083a3a5d", + "check_name": "Redirect", + "message": "Possible unprotected redirect", + "file": "app/controllers/snaptrade_items_controller.rb", + "line": 125, + "link": "https://brakemanscanner.org/docs/warning_types/redirect/", + "code": "redirect_to(Current.family.snaptrade_items.find(params[:id]).connection_portal_url(:redirect_url => callback_snaptrade_items_url(:item_id => Current.family.snaptrade_items.find(params[:id]).id)), :allow_other_host => true)", + "render_path": null, + "location": { + "type": "method", + "class": "SnaptradeItemsController", + "method": "connect" + }, + "user_input": "Current.family.snaptrade_items.find(params[:id]).connection_portal_url(:redirect_url => callback_snaptrade_items_url(:item_id => Current.family.snaptrade_items.find(params[:id]).id))", + "confidence": "Weak", + "cwe_id": [ + 601 + ], + "note": "Intentional redirect to SnapTrade's external OAuth portal for brokerage connection" + }, { "warning_type": "Redirect", "warning_code": 18, diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index 1392b6612..af9db0312 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -4,5 +4,6 @@ # Use this to limit dissemination of sensitive information. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. Rails.application.config.filter_parameters += [ - :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :openai_access_token + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :openai_access_token, + :client_id, :consumer_key, :snaptrade_user_id, :snaptrade_user_secret ] diff --git a/config/locales/views/components/en.yml b/config/locales/views/components/en.yml index b305abea7..9230a79ec 100644 --- a/config/locales/views/components/en.yml +++ b/config/locales/views/components/en.yml @@ -15,6 +15,7 @@ en: imported: "Imported: %{count}" updated: "Updated: %{count}" skipped: "Skipped: %{count}" + fetching: "Fetching from brokerage..." protected: one: "%{count} entry protected (not overwritten)" other: "%{count} entries protected (not overwritten)" @@ -28,6 +29,11 @@ en: title: Holdings found: "Found: %{count}" processed: "Processed: %{count}" + trades: + title: Trades + imported: "Imported: %{count}" + skipped: "Skipped: %{count}" + fetching: "Fetching activities from brokerage..." health: title: Health view_error_details: View error details diff --git a/config/locales/views/snaptrade_items/en.yml b/config/locales/views/snaptrade_items/en.yml new file mode 100644 index 000000000..a5ee65d7f --- /dev/null +++ b/config/locales/views/snaptrade_items/en.yml @@ -0,0 +1,143 @@ +en: + snaptrade_items: + create: + success: "Successfully configured SnapTrade." + update: + success: "Successfully updated SnapTrade configuration." + destroy: + success: "Scheduled SnapTrade connection for deletion." + connect: + registration_failed: "Failed to register with SnapTrade: %{message}" + portal_error: "Failed to connect to SnapTrade: %{message}" + callback: + success: "Brokerage connected! Please select which accounts to link." + no_item: "SnapTrade configuration not found." + complete_account_setup: + success: + one: "Successfully linked %{count} account." + other: "Successfully linked %{count} accounts." + no_accounts: "No accounts were selected for linking." + preload_accounts: + not_configured: "SnapTrade is not configured." + select_accounts: + not_configured: "SnapTrade is not configured." + select_existing_account: + not_found: "Account or SnapTrade configuration not found." + title: "Link to SnapTrade Account" + header: "Link Existing Account" + subtitle: "Select a SnapTrade account to link to" + no_accounts: "No unlinked SnapTrade accounts available." + connect_hint: "You may need to connect a brokerage first." + settings_link: "Go to Provider Settings" + linking_to: "Linking to account:" + balance_label: "Balance:" + link_button: "Link" + cancel_button: "Cancel" + link_existing_account: + success: "Successfully linked to SnapTrade account." + failed: "Failed to link account: %{message}" + not_found: "Account not found." + setup_accounts: + title: "Set Up SnapTrade Accounts" + header: "Set Up Your SnapTrade Accounts" + subtitle: "Select which brokerage accounts to link" + syncing: "Fetching your accounts..." + loading: "Fetching accounts from SnapTrade..." + loading_hint: "This page will auto-refresh while loading." + refresh: "Refresh" + info_title: "SnapTrade Investment Data" + info_holdings: "Holdings with current prices and quantities" + info_cost_basis: "Cost basis per position (when available)" + info_activities: "Trade history with activity labels (Buy, Sell, Dividend, etc.)" + info_history: "Up to 3 years of transaction history" + free_tier_note: "SnapTrade free tier allows 5 brokerage connections. Check your SnapTrade dashboard for current usage." + no_accounts_title: "No Accounts Found" + no_accounts_message: "No brokerage accounts were found. This can happen if you cancelled the connection or if your brokerage isn't supported." + try_again: "Connect Brokerage" + back_to_settings: "Back to Settings" + available_accounts: "Available Accounts" + balance_label: "Balance:" + account_number: "Account:" + create_button: "Create Selected Accounts" + cancel_button: "Cancel" + creating: "Creating Accounts..." + done_button: "Done" + linked_accounts: "Already Linked" + linked_to: "Linked to:" + snaptrade_item: + accounts_need_setup: + one: "%{count} account needs setup" + other: "%{count} accounts need setup" + deletion_in_progress: "Deletion in progress..." + syncing: "Syncing..." + requires_update: "Connection needs update" + error: "Sync error" + status: "Last synced %{timestamp} ago - %{summary}" + status_never: "Never synced" + reconnect: "Reconnect" + connect_brokerage: "Connect Brokerage" + add_another_brokerage: "Connect another brokerage" + delete: "Delete" + setup_needed: "Accounts need setup" + setup_description: "Some accounts from SnapTrade need to be linked to Sure accounts." + setup_action: "Setup Accounts" + no_accounts_title: "No accounts discovered" + no_accounts_description: "Connect a brokerage to import your investment accounts." + + providers: + snaptrade: + name: "SnapTrade" + connection_description: "Connect to your brokerage via SnapTrade (25+ brokers supported)" + description: "SnapTrade connects to 25+ major brokerages (Fidelity, Vanguard, Schwab, Robinhood, etc.) and provides full trade history with activity labels and cost basis." + setup_title: "Setup instructions:" + step_1_html: "Create an account at dashboard.snaptrade.com" + step_2: "Copy your Client ID and Consumer Key from the dashboard" + step_3: "Enter your credentials below and click Save" + step_4: "Go to the Accounts page and use 'Connect another brokerage' to link your investment accounts" + free_tier_warning: "Free tier includes 5 brokerage connections. Additional connections require a paid SnapTrade plan." + client_id_label: "Client ID" + client_id_placeholder: "Enter your SnapTrade Client ID" + client_id_update_placeholder: "Enter new Client ID to update" + consumer_key_label: "Consumer Key" + consumer_key_placeholder: "Enter your SnapTrade Consumer Key" + consumer_key_update_placeholder: "Enter new Consumer Key to update" + save_button: "Save Configuration" + update_button: "Update Configuration" + status_connected: + one: "%{count} account from SnapTrade" + other: "%{count} accounts from SnapTrade" + needs_setup: + one: "%{count} needs setup" + other: "%{count} need setup" + status_ready: "Ready to connect brokerages" + status_needs_registration: "Credentials saved. Go to Accounts page to connect brokerages." + status_not_configured: "Not configured" + setup_accounts_button: "Setup Accounts" + connect_button: "Connect Brokerage" + connected_brokerages: "Connected:" + + snaptrade_item: + sync_status: + no_accounts: "No accounts found" + synced: + one: "%{count} account synced" + other: "%{count} accounts synced" + synced_with_setup: "%{linked} synced, %{unlinked} need setup" + institution_summary: + none: "No institutions connected" + count: + one: "%{count} institution" + other: "%{count} institutions" + brokerage_summary: + none: "No brokerages connected" + count: + one: "%{count} brokerage" + other: "%{count} brokerages" + syncer: + discovering: "Discovering accounts..." + importing: "Importing accounts from SnapTrade..." + processing: "Processing holdings and activities..." + calculating: "Calculating balances..." + checking_config: "Checking account configuration..." + needs_setup: "%{count} accounts need setup..." + activities_fetching_async: "Activities are being fetched in the background. This may take up to a minute for fresh brokerage connections." diff --git a/config/routes.rb b/config/routes.rb index 73d25a030..261adcff1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -34,6 +34,24 @@ Rails.application.routes.draw do end end + resources :snaptrade_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do + collection do + get :preload_accounts + get :select_accounts + post :link_accounts + get :select_existing_account + post :link_existing_account + get :callback + end + + member do + post :sync + get :connect + get :setup_accounts + post :complete_account_setup + end + end + # CoinStats routes resources :coinstats_items, only: [ :index, :new, :create, :update, :destroy ] do collection do diff --git a/db/migrate/20260121173939_create_snaptrade_items_and_accounts.rb b/db/migrate/20260121173939_create_snaptrade_items_and_accounts.rb new file mode 100644 index 000000000..9748e5977 --- /dev/null +++ b/db/migrate/20260121173939_create_snaptrade_items_and_accounts.rb @@ -0,0 +1,79 @@ +class CreateSnaptradeItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + # Create provider items table (stores per-family connection credentials) + create_table :snaptrade_items, id: :uuid, if_not_exists: true do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :name + + # Institution metadata + t.string :institution_id + t.string :institution_name + t.string :institution_domain + t.string :institution_url + t.string :institution_color + + # Status and lifecycle + t.string :status, default: "good" + t.boolean :scheduled_for_deletion, default: false + t.boolean :pending_account_setup, default: false + + # Sync settings + t.datetime :sync_start_date + t.datetime :last_synced_at + + # Raw data storage + t.jsonb :raw_payload + t.jsonb :raw_institution_payload + + # Provider-specific credential fields + t.string :client_id + t.string :consumer_key + t.string :snaptrade_user_id + t.string :snaptrade_user_secret + + t.timestamps + end + + add_index :snaptrade_items, :status unless index_exists?(:snaptrade_items, :status) + + # Create provider accounts table (stores individual account data from provider) + create_table :snaptrade_accounts, id: :uuid, if_not_exists: true do |t| + t.references :snaptrade_item, null: false, foreign_key: true, type: :uuid + + # Account identification + t.string :name + + # SnapTrade-specific IDs (snaptrade_account_id is SnapTrade's UUID for this account) + t.string :snaptrade_account_id + t.string :snaptrade_authorization_id + t.string :account_number + t.string :brokerage_name + + # Account details + t.string :currency + t.decimal :current_balance, precision: 19, scale: 4 + t.decimal :cash_balance, precision: 19, scale: 4 + t.string :account_status + t.string :account_type + t.string :provider + + # Metadata and raw data + t.jsonb :institution_metadata + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + t.jsonb :raw_holdings_payload, default: [] + t.jsonb :raw_activities_payload, default: [] + + # Sync tracking + t.datetime :last_holdings_sync + t.datetime :last_activities_sync + t.boolean :activities_fetch_pending, default: false + + t.timestamps + end + + unless index_exists?(:snaptrade_accounts, :snaptrade_account_id) + add_index :snaptrade_accounts, :snaptrade_account_id, unique: true + end + end +end diff --git a/db/migrate/20260122115442_add_activities_fetch_pending_to_snaptrade_accounts.rb b/db/migrate/20260122115442_add_activities_fetch_pending_to_snaptrade_accounts.rb new file mode 100644 index 000000000..29054bae6 --- /dev/null +++ b/db/migrate/20260122115442_add_activities_fetch_pending_to_snaptrade_accounts.rb @@ -0,0 +1,7 @@ +class AddActivitiesFetchPendingToSnaptradeAccounts < ActiveRecord::Migration[7.2] + def change + unless column_exists?(:snaptrade_accounts, :activities_fetch_pending) + add_column :snaptrade_accounts, :activities_fetch_pending, :boolean, default: false + end + end +end diff --git a/db/migrate/20260122160000_add_sync_start_date_to_snaptrade_accounts.rb b/db/migrate/20260122160000_add_sync_start_date_to_snaptrade_accounts.rb new file mode 100644 index 000000000..e0ff1b51f --- /dev/null +++ b/db/migrate/20260122160000_add_sync_start_date_to_snaptrade_accounts.rb @@ -0,0 +1,7 @@ +class AddSyncStartDateToSnaptradeAccounts < ActiveRecord::Migration[7.2] + def change + unless column_exists?(:snaptrade_accounts, :sync_start_date) + add_column :snaptrade_accounts, :sync_start_date, :date + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 14a9ea149..4a1957462 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_01_21_101345) do +ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -1158,6 +1158,61 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_21_101345) do t.index ["status"], name: "index_simplefin_items_on_status" end + create_table "snaptrade_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "snaptrade_item_id", null: false + t.string "name" + t.string "account_id" + t.string "snaptrade_account_id" + t.string "snaptrade_authorization_id" + t.string "account_number" + t.string "brokerage_name" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.decimal "cash_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "account_type" + t.string "provider" + t.jsonb "institution_metadata" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.jsonb "raw_holdings_payload", default: [] + t.jsonb "raw_activities_payload", default: [] + t.datetime "last_holdings_sync" + t.datetime "last_activities_sync" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "activities_fetch_pending", default: false + t.date "sync_start_date" + t.index ["account_id"], name: "index_snaptrade_accounts_on_account_id", unique: true + t.index ["snaptrade_account_id"], name: "index_snaptrade_accounts_on_snaptrade_account_id", unique: true + t.index ["snaptrade_item_id"], name: "index_snaptrade_accounts_on_snaptrade_item_id" + end + + create_table "snaptrade_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_id" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false + t.datetime "sync_start_date" + t.datetime "last_synced_at" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.string "client_id" + t.string "consumer_key" + t.string "snaptrade_user_id" + t.string "snaptrade_user_secret" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_snaptrade_items_on_family_id" + t.index ["status"], name: "index_snaptrade_items_on_status" + end + create_table "sso_audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "user_id" t.string "event_type", null: false @@ -1428,6 +1483,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_21_101345) do add_foreign_key "sessions", "users" add_foreign_key "simplefin_accounts", "simplefin_items" add_foreign_key "simplefin_items", "families" + add_foreign_key "snaptrade_accounts", "snaptrade_items" + add_foreign_key "snaptrade_items", "families" add_foreign_key "sso_audit_logs", "users" add_foreign_key "subscriptions", "families" add_foreign_key "syncs", "syncs", column: "parent_id" diff --git a/test/fixtures/snaptrade_accounts.yml b/test/fixtures/snaptrade_accounts.yml new file mode 100644 index 000000000..a1e845b70 --- /dev/null +++ b/test/fixtures/snaptrade_accounts.yml @@ -0,0 +1,36 @@ +# Minimal fixtures for SnapTrade accounts +# Per CLAUDE.md: Keep fixtures minimal (2-3 per model for base cases) + +fidelity_401k: + snaptrade_item: configured_item + name: "Fidelity 401(k)" + snaptrade_account_id: "acc_123" + snaptrade_authorization_id: "auth_456" + account_number: "1234567890" + brokerage_name: "Fidelity" + currency: "USD" + current_balance: 50000.00 + cash_balance: 1500.00 + account_status: "active" + account_type: "401k" + provider: "fidelity" + raw_payload: {} + raw_holdings_payload: [] + raw_activities_payload: [] + +vanguard_ira: + snaptrade_item: configured_item + name: "Vanguard IRA" + snaptrade_account_id: "acc_456" + snaptrade_authorization_id: "auth_789" + account_number: "0987654321" + brokerage_name: "Vanguard" + currency: "USD" + current_balance: 75000.00 + cash_balance: 500.00 + account_status: "active" + account_type: "ira" + provider: "vanguard" + raw_payload: {} + raw_holdings_payload: [] + raw_activities_payload: [] diff --git a/test/fixtures/snaptrade_items.yml b/test/fixtures/snaptrade_items.yml new file mode 100644 index 000000000..d8201a91e --- /dev/null +++ b/test/fixtures/snaptrade_items.yml @@ -0,0 +1,20 @@ +# Minimal fixtures for SnapTrade items +# Per CLAUDE.md: Keep fixtures minimal (2-3 per model for base cases) + +configured_item: + family: dylan_family + name: "SnapTrade Connection" + client_id: "test_client_id" + consumer_key: "test_consumer_key" + snaptrade_user_id: "user_123" + snaptrade_user_secret: "secret_abc" + status: good + scheduled_for_deletion: false + pending_account_setup: false + +unconfigured_item: + family: empty + name: "Pending Setup" + status: good + scheduled_for_deletion: false + pending_account_setup: true diff --git a/test/models/holding_test.rb b/test/models/holding_test.rb index 7e49fe6ba..1a384bdd1 100644 --- a/test/models/holding_test.rb +++ b/test/models/holding_test.rb @@ -89,14 +89,21 @@ class HoldingTest < ActiveSupport::TestCase assert_equal Money.new(200.00, "USD"), @amzn.avg_cost end - test "avg_cost treats zero cost_basis as unknown" do + test "avg_cost treats zero cost_basis as unknown when not locked" do # Some providers return 0 when they don't have cost basis data # This should be treated as "unknown" (return nil), not as $0 cost - @amzn.update!(cost_basis: 0) + @amzn.update!(cost_basis: 0, cost_basis_locked: false) assert_nil @amzn.avg_cost end + test "avg_cost returns zero cost_basis when locked (e.g., airdrops)" do + # User-set $0 cost basis is valid for airdrops and should be honored + @amzn.update!(cost_basis: 0, cost_basis_source: "manual", cost_basis_locked: true) + + assert_equal Money.new(0, "USD"), @amzn.avg_cost + end + test "trend returns nil when cost basis is unknown" do # Without cost basis, we can't calculate unrealized gain/loss assert_nil @amzn.trend diff --git a/test/models/snaptrade_account/activities_processor_test.rb b/test/models/snaptrade_account/activities_processor_test.rb new file mode 100644 index 000000000..dedde9de5 --- /dev/null +++ b/test/models/snaptrade_account/activities_processor_test.rb @@ -0,0 +1,258 @@ +require "test_helper" + +class SnaptradeAccount::ActivitiesProcessorTest < ActiveSupport::TestCase + include SecuritiesTestHelper + + setup do + @family = families(:dylan_family) + @snaptrade_item = snaptrade_items(:configured_item) + @snaptrade_account = snaptrade_accounts(:fidelity_401k) + + # Create a linked Sure account for the SnapTrade account + @account = @family.accounts.create!( + name: "Test Investment", + balance: 50000, + cash_balance: 1000, + currency: "USD", + accountable: Investment.new + ) + + # Link the SnapTrade account to the Sure account + @snaptrade_account.ensure_account_provider!(@account) + @snaptrade_account.reload + end + + test "processes buy trade activity" do + @snaptrade_account.update!(raw_activities_payload: [ + build_trade_activity( + id: "trade_001", + type: "BUY", + symbol: "AAPL", + units: 10, + price: 150.00, + settlement_date: Date.current.to_s + ) + ]) + + processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account) + processor.process + + # Verify a trade was created (external_id is on entry, not trade) + entry = @account.entries.find_by(external_id: "trade_001", source: "snaptrade") + assert_not_nil entry, "Entry should be created" + assert entry.entryable.is_a?(Trade), "Entry should be a Trade" + + trade = entry.entryable + assert_equal 10, trade.qty + assert_equal 150.00, trade.price.to_f + assert_equal "Buy", trade.investment_activity_label + end + + test "processes sell trade activity with negative quantity" do + @snaptrade_account.update!(raw_activities_payload: [ + build_trade_activity( + id: "trade_002", + type: "SELL", + symbol: "AAPL", + units: 5, + price: 160.00, + settlement_date: Date.current.to_s + ) + ]) + + processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account) + processor.process + + entry = @account.entries.find_by(external_id: "trade_002", source: "snaptrade") + assert_not_nil entry + trade = entry.entryable + assert_equal(-5, trade.qty) # Sell should be negative + assert_equal "Sell", trade.investment_activity_label + end + + test "processes dividend cash activity" do + @snaptrade_account.update!(raw_activities_payload: [ + build_cash_activity( + id: "div_001", + type: "DIVIDEND", + amount: 25.50, + settlement_date: Date.current.to_s, + symbol: "VTI" + ) + ]) + + processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account) + processor.process + + entry = @account.entries.find_by(external_id: "div_001", source: "snaptrade") + assert_not_nil entry, "Entry should be created" + assert entry.entryable.is_a?(Transaction), "Entry should be a Transaction" + + transaction = entry.entryable + assert_equal "Dividend", transaction.investment_activity_label + end + + test "processes contribution with positive amount" do + @snaptrade_account.update!(raw_activities_payload: [ + build_cash_activity( + id: "contrib_001", + type: "CONTRIBUTION", + amount: 500.00, + settlement_date: Date.current.to_s + ) + ]) + + processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account) + processor.process + + entry = @account.entries.find_by(external_id: "contrib_001", source: "snaptrade") + assert_not_nil entry + # Amount is on entry, not transaction + assert_equal 500.00, entry.amount.to_f # Positive for contributions + assert_equal "Contribution", entry.entryable.investment_activity_label + end + + test "processes withdrawal with negative amount" do + @snaptrade_account.update!(raw_activities_payload: [ + build_cash_activity( + id: "withdraw_001", + type: "WITHDRAWAL", + amount: 200.00, + settlement_date: Date.current.to_s + ) + ]) + + processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account) + processor.process + + entry = @account.entries.find_by(external_id: "withdraw_001", source: "snaptrade") + assert_not_nil entry + assert_equal(-200.00, entry.amount.to_f) # Negative for withdrawals + assert_equal "Withdrawal", entry.entryable.investment_activity_label + end + + test "maps all known activity types correctly" do + type_mappings = { + "BUY" => "Buy", + "SELL" => "Sell", + "DIVIDEND" => "Dividend", + "DIV" => "Dividend", + "CONTRIBUTION" => "Contribution", + "WITHDRAWAL" => "Withdrawal", + "TRANSFER_IN" => "Transfer", + "TRANSFER_OUT" => "Transfer", + "INTEREST" => "Interest", + "FEE" => "Fee", + "TAX" => "Fee", + "REI" => "Reinvestment", + "REINVEST" => "Reinvestment", + "CASH" => "Contribution", + "CORP_ACTION" => "Other", + "SPLIT_REVERSE" => "Other" + } + + type_mappings.each do |snaptrade_type, expected_label| + actual = SnaptradeAccount::ActivitiesProcessor::SNAPTRADE_TYPE_TO_LABEL[snaptrade_type] + assert_equal expected_label, actual, "Type #{snaptrade_type} should map to #{expected_label}" + end + end + + test "logs unmapped activity types" do + @snaptrade_account.update!(raw_activities_payload: [ + build_cash_activity( + id: "unknown_001", + type: "SOME_NEW_TYPE", + amount: 100.00, + settlement_date: Date.current.to_s + ) + ]) + + # Capture log output + log_output = StringIO.new + old_logger = Rails.logger + Rails.logger = Logger.new(log_output) + + processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account) + processor.process + + Rails.logger = old_logger + + assert_includes log_output.string, "Unmapped activity type 'SOME_NEW_TYPE'" + end + + test "skips activities without external_id" do + @snaptrade_account.update!(raw_activities_payload: [ + build_cash_activity( + id: nil, + type: "DIVIDEND", + amount: 50.00, + settlement_date: Date.current.to_s + ) + ]) + + processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account) + processor.process + + # No entry should be created with snaptrade source + assert_equal 0, @account.entries.where(source: "snaptrade").count + end + + test "skips processing when no linked account" do + # Remove the account provider link + @snaptrade_account.account_provider&.destroy + @snaptrade_account.reload + + @snaptrade_account.update!(raw_activities_payload: [ + build_trade_activity( + id: "trade_orphan", + type: "BUY", + symbol: "AAPL", + units: 10, + price: 150.00, + settlement_date: Date.current.to_s + ) + ]) + + processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account) + processor.process + + # No entries should be created with this external_id + assert_equal 0, Entry.where(external_id: "trade_orphan").count + end + + private + + def build_trade_activity(id:, type:, symbol:, units:, price:, settlement_date:) + { + "id" => id, + "type" => type, + "symbol" => { + "symbol" => symbol, + "description" => "#{symbol} Inc" + }, + "units" => units, + "price" => price, + "settlement_date" => settlement_date, + "currency" => { "code" => "USD" } + } + end + + def build_cash_activity(id:, type:, amount:, settlement_date:, symbol: nil) + activity = { + "id" => id, + "type" => type, + "amount" => amount, + "settlement_date" => settlement_date, + "currency" => { "code" => "USD" } + } + + if symbol + activity["symbol"] = { + "symbol" => symbol, + "description" => "#{symbol} Fund" + } + end + + activity + end +end diff --git a/test/models/snaptrade_account_test.rb b/test/models/snaptrade_account_test.rb new file mode 100644 index 000000000..e500c1085 --- /dev/null +++ b/test/models/snaptrade_account_test.rb @@ -0,0 +1,139 @@ +require "test_helper" + +class SnaptradeAccountTest < ActiveSupport::TestCase + fixtures :families, :snaptrade_items, :snaptrade_accounts + setup do + @family = families(:dylan_family) + @snaptrade_item = snaptrade_items(:configured_item) + @snaptrade_account = snaptrade_accounts(:fidelity_401k) + end + + test "validates presence of name" do + @snaptrade_account.name = nil + assert_not @snaptrade_account.valid? + assert_includes @snaptrade_account.errors[:name], "can't be blank" + end + + test "validates presence of currency" do + @snaptrade_account.currency = nil + assert_not @snaptrade_account.valid? + assert_includes @snaptrade_account.errors[:currency], "can't be blank" + end + + test "ensure_account_provider! creates link when account provided" do + account = @family.accounts.create!( + name: "Test Investment", + balance: 10000, + currency: "USD", + accountable: Investment.new + ) + + assert_nil @snaptrade_account.account_provider + + @snaptrade_account.ensure_account_provider!(account) + @snaptrade_account.reload + + assert_not_nil @snaptrade_account.account_provider + assert_equal account, @snaptrade_account.current_account + end + + test "ensure_account_provider! updates link when account changes" do + account1 = @family.accounts.create!( + name: "First Account", + balance: 10000, + currency: "USD", + accountable: Investment.new + ) + account2 = @family.accounts.create!( + name: "Second Account", + balance: 20000, + currency: "USD", + accountable: Investment.new + ) + + @snaptrade_account.ensure_account_provider!(account1) + assert_equal account1, @snaptrade_account.reload.current_account + + @snaptrade_account.ensure_account_provider!(account2) + assert_equal account2, @snaptrade_account.reload.current_account + end + + test "ensure_account_provider! is idempotent" do + account = @family.accounts.create!( + name: "Test Investment", + balance: 10000, + currency: "USD", + accountable: Investment.new + ) + + @snaptrade_account.ensure_account_provider!(account) + provider1 = @snaptrade_account.reload.account_provider + + @snaptrade_account.ensure_account_provider!(account) + provider2 = @snaptrade_account.reload.account_provider + + assert_equal provider1.id, provider2.id + end + + test "upsert_holdings_snapshot! stores holdings and updates timestamp" do + holdings = [ + { "symbol" => { "symbol" => "AAPL" }, "units" => 10 }, + { "symbol" => { "symbol" => "MSFT" }, "units" => 5 } + ] + + @snaptrade_account.upsert_holdings_snapshot!(holdings) + + assert_equal holdings, @snaptrade_account.raw_holdings_payload + assert_not_nil @snaptrade_account.last_holdings_sync + end + + test "upsert_activities_snapshot! stores activities and updates timestamp" do + activities = [ + { "id" => "act1", "type" => "BUY", "amount" => 1000 }, + { "id" => "act2", "type" => "DIVIDEND", "amount" => 50 } + ] + + @snaptrade_account.upsert_activities_snapshot!(activities) + + assert_equal activities, @snaptrade_account.raw_activities_payload + assert_not_nil @snaptrade_account.last_activities_sync + end + + test "upsert_from_snaptrade! extracts data from API response" do + # Use a Hash that mimics the SnapTrade SDK response structure + api_response = { + "id" => "new_account_id", + "brokerage_authorization" => "auth_xyz", + "number" => "9999999", + "name" => "Schwab Brokerage", + "status" => "active", + "balance" => { + "total" => { "amount" => 125000, "currency" => "USD" } + }, + "meta" => { "type" => "INDIVIDUAL", "institution_name" => "Charles Schwab" } + } + + @snaptrade_account.upsert_from_snaptrade!(api_response) + + assert_equal "new_account_id", @snaptrade_account.snaptrade_account_id + assert_equal "auth_xyz", @snaptrade_account.snaptrade_authorization_id + assert_equal "9999999", @snaptrade_account.account_number + assert_equal "Schwab Brokerage", @snaptrade_account.name + assert_equal "Charles Schwab", @snaptrade_account.brokerage_name + assert_equal 125000, @snaptrade_account.current_balance.to_i + assert_equal "INDIVIDUAL", @snaptrade_account.account_type + end + + test "snaptrade_credentials returns credentials from parent item" do + credentials = @snaptrade_account.snaptrade_credentials + + assert_equal "user_123", credentials[:user_id] + assert_equal "secret_abc", credentials[:user_secret] + end + + test "snaptrade_provider returns provider from parent item" do + provider = @snaptrade_account.snaptrade_provider + + assert_instance_of Provider::Snaptrade, provider + end +end diff --git a/test/models/snaptrade_item_test.rb b/test/models/snaptrade_item_test.rb new file mode 100644 index 000000000..79e6c6689 --- /dev/null +++ b/test/models/snaptrade_item_test.rb @@ -0,0 +1,78 @@ +require "test_helper" + +class SnaptradeItemTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + end + + test "validates presence of name" do + item = SnaptradeItem.new(family: @family, client_id: "test", consumer_key: "test") + assert_not item.valid? + assert_includes item.errors[:name], "can't be blank" + end + + test "validates presence of client_id on create" do + item = SnaptradeItem.new(family: @family, name: "Test", consumer_key: "test") + assert_not item.valid? + assert_includes item.errors[:client_id], "can't be blank" + end + + test "validates presence of consumer_key on create" do + item = SnaptradeItem.new(family: @family, name: "Test", client_id: "test") + assert_not item.valid? + assert_includes item.errors[:consumer_key], "can't be blank" + end + + test "credentials_configured? returns true when credentials are set" do + item = SnaptradeItem.new( + family: @family, + name: "Test", + client_id: "test_client_id", + consumer_key: "test_consumer_key" + ) + assert item.credentials_configured? + end + + test "credentials_configured? returns false when credentials are missing" do + item = SnaptradeItem.new(family: @family, name: "Test") + assert_not item.credentials_configured? + end + + test "user_registered? returns false when user_id and secret are blank" do + item = SnaptradeItem.new( + family: @family, + name: "Test", + client_id: "test", + consumer_key: "test" + ) + assert_not item.user_registered? + end + + test "user_registered? returns true when user_id and secret are present" do + item = SnaptradeItem.new( + family: @family, + name: "Test", + client_id: "test", + consumer_key: "test", + snaptrade_user_id: "user_123", + snaptrade_user_secret: "secret_abc" + ) + assert item.user_registered? + end + + test "snaptrade_provider returns nil when credentials not configured" do + item = SnaptradeItem.new(family: @family, name: "Test") + assert_nil item.snaptrade_provider + end + + test "snaptrade_provider returns provider instance when configured" do + item = SnaptradeItem.new( + family: @family, + name: "Test", + client_id: "test_client_id", + consumer_key: "test_consumer_key" + ) + provider = item.snaptrade_provider + assert_instance_of Provider::Snaptrade, provider + end +end