Centralize sdk_object_to_hash logic in DataHelpers module and update all references for improved reusability and consistency. Add error handling for partial and failed SnapTrade account linking. (#741)

Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
This commit is contained in:
LPW
2026-01-22 16:19:44 -05:00
committed by GitHub
parent bf76d6b88d
commit 9858b36dc7
6 changed files with 41 additions and 52 deletions

View File

@@ -215,10 +215,23 @@ class SnaptradeItemsController < ApplicationController
# 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).")
if errors.any?
# Partial success - some linked, some failed
redirect_to accounts_path, notice: t(".partial_success", linked: linked_count, failed: errors.size,
default: "Linked #{linked_count} account(s). #{errors.size} failed to link.")
else
redirect_to accounts_path, notice: t(".success", count: linked_count, default: "Successfully linked #{linked_count} account(s).")
end
else
redirect_to setup_accounts_snaptrade_item_path(@snaptrade_item),
alert: t(".no_accounts", default: "No accounts were selected for linking.")
if errors.any?
# All failed
redirect_to setup_accounts_snaptrade_item_path(@snaptrade_item),
alert: t(".link_failed", default: "Failed to link accounts: %{errors}", errors: errors.first)
else
redirect_to setup_accounts_snaptrade_item_path(@snaptrade_item),
alert: t(".no_accounts", default: "No accounts were selected for linking.")
end
end
end

View File

@@ -8,6 +8,8 @@
# SnaptradeActivitiesFetchJob.perform_later(snaptrade_account, start_date: 5.years.ago.to_date)
#
class SnaptradeActivitiesFetchJob < ApplicationJob
include SnaptradeAccount::DataHelpers
queue_as :default
# Prevent concurrent jobs for the same account - only one fetch at a time
@@ -115,20 +117,6 @@ class SnaptradeActivitiesFetchJob < ApplicationJob
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)

View File

@@ -1,5 +1,6 @@
class SnaptradeAccount < ApplicationRecord
include CurrencyNormalizable
include SnaptradeAccount::DataHelpers
belongs_to :snaptrade_item
@@ -60,7 +61,7 @@ class SnaptradeAccount < ApplicationRecord
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 = sdk_object_to_hash(account_data)
data = data.with_indifferent_access
# Extract meta data
@@ -118,7 +119,7 @@ class SnaptradeAccount < ApplicationRecord
# 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 }
data = Array(balances_data).map { |b| sdk_object_to_hash(b).with_indifferent_access }
Rails.logger.info "SnaptradeAccount##{id} upsert_balances! - raw data: #{data.inspect}"
@@ -161,23 +162,6 @@ class SnaptradeAccount < ApplicationRecord
)
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

View File

@@ -3,6 +3,23 @@ module SnaptradeAccount::DataHelpers
private
# Convert SnapTrade SDK objects to hashes
# SDK objects don't have proper to_h but do have to_json
# Uses JSON round-trip to ensure all nested objects become hashes
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
def parse_decimal(value)
return nil if value.nil?

View File

@@ -1,5 +1,6 @@
class SnaptradeItem::Importer
include SyncStats::Collector
include SnaptradeAccount::DataHelpers
attr_reader :snaptrade_item, :snaptrade_provider, :sync
@@ -53,22 +54,6 @@ class SnaptradeItem::Importer
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

View File

@@ -16,6 +16,8 @@ en:
success:
one: "Successfully linked %{count} account."
other: "Successfully linked %{count} accounts."
partial_success: "Linked %{linked} account(s). %{failed} failed to link."
link_failed: "Failed to link accounts: %{errors}"
no_accounts: "No accounts were selected for linking."
preload_accounts:
not_configured: "SnapTrade is not configured."