mirror of
https://github.com/we-promise/sure.git
synced 2026-04-20 20:44:08 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
352
app/controllers/snaptrade_items_controller.rb
Normal file
352
app/controllers/snaptrade_items_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
216
app/jobs/snaptrade_activities_fetch_job.rb
Normal file
216
app/jobs/snaptrade_activities_fetch_job.rb
Normal 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
|
||||
71
app/jobs/snaptrade_connection_cleanup_job.rb
Normal file
71
app/jobs/snaptrade_connection_cleanup_job.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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" ],
|
||||
|
||||
30
app/models/family/snaptrade_connectable.rb
Normal file
30
app/models/family/snaptrade_connectable.rb
Normal 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
|
||||
277
app/models/provider/snaptrade.rb
Normal file
277
app/models/provider/snaptrade.rb
Normal 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
|
||||
105
app/models/provider/snaptrade_adapter.rb
Normal file
105
app/models/provider/snaptrade_adapter.rb
Normal 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
|
||||
198
app/models/snaptrade_account.rb
Normal file
198
app/models/snaptrade_account.rb
Normal 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
|
||||
272
app/models/snaptrade_account/activities_processor.rb
Normal file
272
app/models/snaptrade_account/activities_processor.rb
Normal 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
|
||||
126
app/models/snaptrade_account/data_helpers.rb
Normal file
126
app/models/snaptrade_account/data_helpers.rb
Normal 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
|
||||
135
app/models/snaptrade_account/holdings_processor.rb
Normal file
135
app/models/snaptrade_account/holdings_processor.rb
Normal 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
|
||||
112
app/models/snaptrade_account/processor.rb
Normal file
112
app/models/snaptrade_account/processor.rb
Normal 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
|
||||
217
app/models/snaptrade_item.rb
Normal file
217
app/models/snaptrade_item.rb
Normal 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
|
||||
458
app/models/snaptrade_item/importer.rb
Normal file
458
app/models/snaptrade_item/importer.rb
Normal 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
|
||||
134
app/models/snaptrade_item/provided.rb
Normal file
134
app/models/snaptrade_item/provided.rb
Normal 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
|
||||
25
app/models/snaptrade_item/sync_complete_event.rb
Normal file
25
app/models/snaptrade_item/sync_complete_event.rb
Normal 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
|
||||
86
app/models/snaptrade_item/syncer.rb
Normal file
86
app/models/snaptrade_item/syncer.rb
Normal 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
|
||||
49
app/models/snaptrade_item/unlinking.rb
Normal file
49
app/models/snaptrade_item/unlinking.rb
Normal 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
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
98
app/views/settings/providers/_snaptrade_panel.html.erb
Normal file
98
app/views/settings/providers/_snaptrade_panel.html.erb
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
154
app/views/snaptrade_items/_snaptrade_item.html.erb
Normal file
154
app/views/snaptrade_items/_snaptrade_item.html.erb
Normal 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 %>
|
||||
67
app/views/snaptrade_items/select_existing_account.html.erb
Normal file
67
app/views/snaptrade_items/select_existing_account.html.erb
Normal 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 %>
|
||||
201
app/views/snaptrade_items/setup_accounts.html.erb
Normal file
201
app/views/snaptrade_items/setup_accounts.html.erb
Normal 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 %>
|
||||
Reference in New Issue
Block a user