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/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| %>
+
+
+
+
+ <%= snaptrade_account.name %>
+
+ <% if snaptrade_account.brokerage_name.present? %>
+ <%= snaptrade_account.brokerage_name %> •
+ <% end %>
+ <% if snaptrade_account.account_type.present? %>
+ <%= snaptrade_account.account_type.titleize %> •
+ <% end %>
+ <%= t("snaptrade_items.setup_accounts.balance_label", default: "Balance:") %>
+ <%= number_to_currency(snaptrade_account.current_balance || 0, unit: Money::Currency.new(snaptrade_account.currency || "USD").symbol) %>
+
+ <% if snaptrade_account.account_number.present? %>
+ <%= t("snaptrade_items.setup_accounts.account_number", default: "Account:") %> •••<%= snaptrade_account.account_number.last(4) %>
+ <% end %>
+
+
+
+
+ <%= t("snaptrade_items.setup_accounts.sync_start_date_label", default: "Import transactions from:") %>
+
+
+
+ <%= 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