Add SnapTrade brokerage integration with full trade history support (#737)

* Introduce SnapTrade integration with models, migrations, views, and activity processing logic.

* Refactor SnapTrade activities processing: improve activity fetching flow, handle pending states, and update UI elements for enhanced user feedback.

* Update Brakeman ignore file to include intentional redirect for SnapTrade OAuth portal.

* Refactor SnapTrade models, views, and processing logic: add currency extraction helper, improve pending state handling, optimize migration checks, and enhance user feedback in UI.

* Remove encryption for SnapTrade `snaptrade_user_id`, as it is an identifier, not a secret.

* Introduce `SnaptradeConnectionCleanupJob` to asynchronously handle SnapTrade connection cleanup and improve i18n for SnapTrade item status messages.

* Update SnapTrade encryption: make `snaptrade_user_secret` non-deterministic to enhance security.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
LPW
2026-01-22 14:52:49 -05:00
committed by GitHub
parent 179552657c
commit a83f70425f
52 changed files with 4417 additions and 25 deletions

View File

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

View File

@@ -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();
}

View File

@@ -28,15 +28,22 @@
<% end %>
<%# Transactions section - shown if provider collects transaction stats %>
<% if has_transaction_stats? %>
<% if has_transaction_stats? || activities_pending? %>
<div>
<h4 class="text-primary font-medium mb-1"><%= t("provider_sync_summary.transactions.title") %></h4>
<div class="flex items-center gap-3 flex-wrap">
<span><%= t("provider_sync_summary.transactions.seen", count: tx_seen) %></span>
<span><%= t("provider_sync_summary.transactions.imported", count: tx_imported) %></span>
<span><%= t("provider_sync_summary.transactions.updated", count: tx_updated) %></span>
<span><%= t("provider_sync_summary.transactions.skipped", count: tx_skipped) %></span>
</div>
<% if activities_pending? && !has_transaction_stats? %>
<div class="flex items-center gap-2">
<%= helpers.icon "loader-circle", size: "sm", class: "animate-spin text-secondary" %>
<span class="text-secondary"><%= t("provider_sync_summary.transactions.fetching") %></span>
</div>
<% else %>
<div class="flex items-center gap-3 flex-wrap">
<span><%= t("provider_sync_summary.transactions.seen", count: tx_seen) %></span>
<span><%= t("provider_sync_summary.transactions.imported", count: tx_imported) %></span>
<span><%= t("provider_sync_summary.transactions.updated", count: tx_updated) %></span>
<span><%= t("provider_sync_summary.transactions.skipped", count: tx_skipped) %></span>
</div>
<% end %>
<%# Protected entries detail - shown when entries were skipped due to protection %>
<% if has_skipped_entries? %>
@@ -81,6 +88,26 @@
</div>
<% end %>
<%# Trades section - shown if provider collects trades stats (investment activities) %>
<% if has_trades_stats? || activities_pending? %>
<div>
<h4 class="text-primary font-medium mb-1"><%= t("provider_sync_summary.trades.title") %></h4>
<% if activities_pending? && !has_trades_stats? %>
<div class="flex items-center gap-2">
<%= helpers.icon "loader-circle", size: "sm", class: "animate-spin text-secondary" %>
<span class="text-secondary"><%= t("provider_sync_summary.trades.fetching") %></span>
</div>
<% else %>
<div class="flex items-center gap-3 flex-wrap">
<span><%= t("provider_sync_summary.trades.imported", count: trades_imported) %></span>
<% if trades_skipped > 0 %>
<span><%= t("provider_sync_summary.trades.skipped", count: trades_skipped) %></span>
<% end %>
</div>
<% end %>
</div>
<% end %>
<%# Health section - always shown %>
<div>
<h4 class="text-primary font-medium mb-1"><%= t("provider_sync_summary.health.title") %></h4>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" ],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
</div>
</header>
<% 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 %>
<div class="space-y-2">
@@ -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? %>
<div id="manual-accounts">
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %>

View File

@@ -39,7 +39,7 @@
data-controller="cost-basis-form"
data-cost-basis-form-qty-value="<%= holding.qty %>">
<h4 class="font-medium text-sm mb-3">
<%= 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)) %>
</h4>
<%
form_data = { turbo: false }

View File

@@ -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" %>
</div>
<div class="col-span-2 text-right">

View File

@@ -75,7 +75,7 @@
class: "space-y-3",
data: drawer_form_data do |f| %>
<p class="text-xs text-secondary mb-2">
<%= 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)) %>
</p>
<!-- Total cost basis input -->
<div class="form-field">

View File

@@ -0,0 +1,98 @@
<div class="space-y-4">
<div class="prose prose-sm text-secondary">
<p><%= t("providers.snaptrade.description") %></p>
<p class="text-primary font-medium"><%= t("providers.snaptrade.setup_title") %></p>
<ol>
<li><%= t("providers.snaptrade.step_1_html") %></li>
<li><%= t("providers.snaptrade.step_2") %></li>
<li><%= t("providers.snaptrade.step_3") %></li>
<li><%= t("providers.snaptrade.step_4") %></li>
</ol>
<p class="text-warning text-sm"><%= icon("alert-triangle", class: "inline-block w-4 h-4 mr-1") %><%= t("providers.snaptrade.free_tier_warning") %></p>
</div>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<% end %>
<%
snaptrade_item = Current.family.snaptrade_items.first_or_initialize(name: "SnapTrade Connection")
is_new_record = snaptrade_item.new_record?
is_configured = snaptrade_item.persisted? && snaptrade_item.credentials_configured?
is_registered = snaptrade_item.persisted? && snaptrade_item.user_registered?
%>
<%= styled_form_with model: snaptrade_item,
url: is_new_record ? snaptrade_items_path : snaptrade_item_path(snaptrade_item),
scope: :snaptrade_item,
method: is_new_record ? :post : :patch,
data: { turbo: true },
class: "space-y-3" do |form| %>
<%= form.text_field :client_id,
label: t("providers.snaptrade.client_id_label"),
placeholder: is_new_record ? t("providers.snaptrade.client_id_placeholder") : t("providers.snaptrade.client_id_update_placeholder"),
type: :password %>
<%= form.text_field :consumer_key,
label: t("providers.snaptrade.consumer_key_label"),
placeholder: is_new_record ? t("providers.snaptrade.consumer_key_placeholder") : t("providers.snaptrade.consumer_key_update_placeholder"),
type: :password %>
<div class="flex justify-end">
<%= form.submit is_new_record ? t("providers.snaptrade.save_button") : t("providers.snaptrade.update_button"),
class: "btn btn--primary" %>
</div>
<% end %>
<% items = local_assigns[:snaptrade_items] || @snaptrade_items || Current.family.snaptrade_items.where.not(client_id: [nil, ""]) %>
<div class="border-t border-primary pt-4 mt-4">
<% if items&.any? %>
<% item = items.first %>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<% if item.user_registered? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary">
<% if item.snaptrade_accounts.any? %>
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
<% if item.unlinked_accounts_count > 0 %>
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
<% end %>
<% else %>
<%= t("providers.snaptrade.status_ready") %>
<% end %>
</p>
<% else %>
<div class="w-2 h-2 bg-warning rounded-full"></div>
<p class="text-sm text-secondary"><%= t("providers.snaptrade.status_needs_registration") %></p>
<% end %>
</div>
<% if item.snaptrade_accounts.any? %>
<div class="flex items-center gap-2">
<%= link_to t("providers.snaptrade.setup_accounts_button"),
setup_accounts_snaptrade_item_path(item),
class: "btn btn--secondary btn--sm" %>
</div>
<% end %>
</div>
<% if item.snaptrade_accounts.any? %>
<div class="mt-3 text-sm text-secondary">
<p><%= t("providers.snaptrade.connected_brokerages") %> <%= item.brokerage_summary %></p>
</div>
<% end %>
<% else %>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
<p class="text-sm text-secondary"><%= t("providers.snaptrade.status_not_configured") %></p>
</div>
<% end %>
</div>
</div>

View File

@@ -53,4 +53,10 @@
<%= render "settings/providers/coinbase_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false do %>
<turbo-frame id="snaptrade-providers-panel">
<%= render "settings/providers/snaptrade_panel" %>
</turbo-frame>
<% end %>
</div>

View File

@@ -0,0 +1,154 @@
<%# locals: (snaptrade_item:) %>
<%= tag.div id: dom_id(snaptrade_item) do %>
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<div class="flex items-center justify-center h-8 w-8 bg-primary/10 rounded-full">
<% if snaptrade_item.logo.attached? %>
<%= image_tag snaptrade_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %>
<% else %>
<div class="flex items-center justify-center">
<%= tag.p snaptrade_item.name.first.upcase, class: "text-primary text-xs font-medium" %>
</div>
<% end %>
</div>
<% unlinked_count = snaptrade_item.unlinked_accounts_count %>
<div class="pl-1 text-sm">
<div class="flex items-center gap-2">
<%= 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" %>
<span><%= t(".accounts_need_setup", count: unlinked_count) %></span>
<% end %>
<% end %>
<% if snaptrade_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
</div>
<% if snaptrade_item.snaptrade_accounts.any? %>
<p class="text-xs text-secondary">
<%= snaptrade_item.brokerage_summary %>
</p>
<% end %>
<% if snaptrade_item.syncing? %>
<div class="text-secondary flex items-center gap-1">
<%= icon "loader", size: "sm", class: "animate-spin" %>
<%= tag.span t(".syncing") %>
</div>
<% elsif snaptrade_item.requires_update? %>
<div class="text-warning flex items-center gap-1">
<%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span t(".requires_update") %>
</div>
<% elsif snaptrade_item.sync_error.present? %>
<div class="text-secondary flex items-center gap-1">
<%= render DS::Tooltip.new(text: snaptrade_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %>
<%= tag.span t(".error"), class: "text-destructive" %>
</div>
<% else %>
<p class="text-secondary">
<% 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 %>
</p>
<% end %>
</div>
</div>
<div class="flex items-center gap-2">
<% 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 %>
</div>
</summary>
<% unless snaptrade_item.scheduled_for_deletion? %>
<div class="space-y-4 mt-4">
<% if snaptrade_item.accounts.any? %>
<%= render "accounts/index/account_groups", accounts: snaptrade_item.accounts %>
<div class="flex justify-end pt-2">
<%= 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" %>
<span><%= t(".add_another_brokerage") %></span>
<% end %>
</div>
<% 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 %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
<p class="text-secondary text-sm"><%= t(".setup_description") %></p>
<%= render DS::Link.new(
text: t(".setup_action"),
icon: "settings",
variant: "primary",
href: setup_accounts_snaptrade_item_path(snaptrade_item),
frame: :modal
) %>
</div>
<% elsif snaptrade_item.snaptrade_accounts.empty? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_title") %></p>
<p class="text-secondary text-sm"><%= t(".no_accounts_description") %></p>
<%= render DS::Link.new(
text: t(".connect_brokerage"),
icon: "link",
variant: "primary",
href: connect_snaptrade_item_path(snaptrade_item)
) %>
</div>
<% end %>
</div>
<% end %>
</details>
<% end %>

View File

@@ -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 %>
<div class="flex items-center gap-2">
<%= icon "link", class: "text-primary" %>
<span class="text-primary"><%= t("snaptrade_items.select_existing_account.subtitle", default: "Select a SnapTrade account to link to") %> <%= @account.name %></span>
</div>
<% end %>
<% dialog.with_body do %>
<% if @snaptrade_accounts.blank? %>
<div class="text-center py-8">
<%= icon "alert-circle", class: "text-warning mx-auto mb-4", size: "lg" %>
<p class="text-secondary"><%= t("snaptrade_items.select_existing_account.no_accounts", default: "No unlinked SnapTrade accounts available.") %></p>
<p class="text-sm text-secondary mt-2"><%= t("snaptrade_items.select_existing_account.connect_hint", default: "You may need to connect a brokerage first.") %></p>
<%= 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" %>
</div>
<% else %>
<div class="space-y-4">
<div class="bg-surface border border-primary p-4 rounded-lg mb-4">
<p class="text-sm text-primary">
<strong><%= t("snaptrade_items.select_existing_account.linking_to", default: "Linking to account:") %></strong>
<%= @account.name %>
</p>
</div>
<% @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 %>
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-primary"><%= snaptrade_account.name %></h4>
<p class="text-sm text-secondary">
<% 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) %>
</p>
</div>
<%= render DS::Button.new(
text: t("snaptrade_items.select_existing_account.link_button", default: "Link"),
variant: "primary",
size: "sm",
type: "submit"
) %>
</div>
<% end %>
<% end %>
</div>
<div class="mt-6">
<%= render DS::Link.new(
text: t("snaptrade_items.select_existing_account.cancel_button", default: "Cancel"),
variant: "secondary",
href: account_path(@account)
) %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -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 %>
<div class="flex items-center gap-2">
<%= icon "trending-up", class: "text-primary" %>
<span class="text-primary"><%= t("snaptrade_items.setup_accounts.subtitle", default: "Select which brokerage accounts to link") %></span>
</div>
<% end %>
<% dialog.with_body do %>
<div class="space-y-6">
<%# Always show the info box %>
<div class="bg-surface border border-primary p-4 rounded-lg">
<div class="flex items-start gap-3">
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<div>
<p class="text-sm text-primary mb-2">
<strong><%= t("snaptrade_items.setup_accounts.info_title", default: "SnapTrade Investment Data") %></strong>
</p>
<ul class="text-xs text-secondary space-y-1 list-disc list-inside">
<li><%= t("snaptrade_items.setup_accounts.info_holdings", default: "Holdings with current prices and quantities") %></li>
<li><%= t("snaptrade_items.setup_accounts.info_cost_basis", default: "Cost basis per position (when available)") %></li>
<li><%= t("snaptrade_items.setup_accounts.info_activities", default: "Trade history with activity labels (Buy, Sell, Dividend, etc.)") %></li>
<li><%= t("snaptrade_items.setup_accounts.info_history", default: "Up to 3 years of transaction history") %></li>
</ul>
<p class="text-xs text-warning mt-2">
<%= 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.") %>
</p>
</div>
</div>
</div>
<% if @waiting_for_sync %>
<%# Syncing state - show spinner with manual refresh option %>
<div class="flex flex-col items-center justify-center py-6 space-y-3">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<p class="text-secondary text-center">
<%= t("snaptrade_items.setup_accounts.loading", default: "Fetching accounts from SnapTrade...") %>
</p>
<p class="text-xs text-secondary text-center">
<%= t("snaptrade_items.setup_accounts.loading_hint", default: "Click Refresh to check for accounts.") %>
</p>
</div>
<div class="flex gap-3 justify-center">
<%= 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"
) %>
</div>
<% elsif @no_accounts_found %>
<%# No accounts found after sync completed %>
<div class="flex flex-col items-center justify-center py-6 space-y-3">
<%= icon "alert-circle", size: "lg", class: "text-warning" %>
<p class="text-primary text-center font-medium">
<%= t("snaptrade_items.setup_accounts.no_accounts_title", default: "No Accounts Found") %>
</p>
<p class="text-secondary text-center text-sm">
<%= 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.") %>
</p>
</div>
<div class="flex gap-3 justify-center">
<%= 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"
) %>
</div>
<% 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? %>
<div class="space-y-4">
<h3 class="font-medium text-primary"><%= t("snaptrade_items.setup_accounts.available_accounts", default: "Available Accounts") %></h3>
<% @unlinked_accounts.each do |snaptrade_account| %>
<div class="border border-primary rounded-lg p-4 hover:bg-surface transition-colors">
<div class="flex items-center gap-3">
<input type="checkbox"
id="account_<%= snaptrade_account.id %>"
name="account_ids[]"
value="<%= snaptrade_account.id %>"
checked
class="cursor-pointer">
<label for="account_<%= snaptrade_account.id %>" class="flex-1 cursor-pointer">
<h4 class="font-medium text-primary"><%= snaptrade_account.name %></h4>
<p class="text-sm text-secondary">
<% 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) %>
</p>
<% if snaptrade_account.account_number.present? %>
<p class="text-xs text-secondary"><%= t("snaptrade_items.setup_accounts.account_number", default: "Account:") %> •••<%= snaptrade_account.account_number.last(4) %></p>
<% end %>
</label>
</div>
<div class="mt-3 pl-7" onclick="event.stopPropagation();">
<label for="sync_start_<%= snaptrade_account.id %>" class="block text-xs text-secondary mb-1">
<%= t("snaptrade_items.setup_accounts.sync_start_date_label", default: "Import transactions from:") %>
</label>
<input type="date"
id="sync_start_<%= snaptrade_account.id %>"
name="sync_start_dates[<%= snaptrade_account.id %>]"
value="<%= snaptrade_account.sync_start_date %>"
onclick="event.stopPropagation();"
autocomplete="off"
class="bg-container border border-primary rounded px-2 py-1 text-sm text-primary">
<p class="text-xs text-secondary mt-1">
<%= t("snaptrade_items.setup_accounts.sync_start_date_help", default: "Leave blank for all available history") %>
</p>
</div>
</div>
<% end %>
</div>
<div class="flex gap-3 mt-6">
<%= 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"
) %>
</div>
<% end %>
<% if @linked_accounts.any? %>
<div class="<%= "border-t border-primary pt-6 mt-6" if @unlinked_accounts.any? %>">
<h3 class="font-medium text-primary mb-4"><%= t("snaptrade_items.setup_accounts.linked_accounts", default: "Already Linked") %></h3>
<% @linked_accounts.each do |snaptrade_account| %>
<div class="border border-success/20 bg-success/5 rounded-lg p-4 mb-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<%= icon "check-circle", class: "text-success" %>
<div>
<h4 class="font-medium text-primary"><%= snaptrade_account.name %></h4>
<p class="text-sm text-secondary">
<%= 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" %>
</p>
</div>
</div>
</div>
</div>
<% end %>
</div>
<%# Show Done button when all accounts are linked (no unlinked) %>
<% if @unlinked_accounts.blank? %>
<div class="flex justify-end mt-4">
<%= render DS::Link.new(
text: t("snaptrade_items.setup_accounts.done_button", default: "Done"),
variant: "primary",
href: accounts_path,
frame: "_top"
) %>
</div>
<% end %>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
<% end %>