Files
sure/app/models/provider/snaptrade.rb
LPW a83f70425f 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>
2026-01-22 20:52:49 +01:00

278 lines
8.6 KiB
Ruby

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