mirror of
https://github.com/we-promise/sure.git
synced 2026-04-08 14:54:49 +00:00
* 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>
278 lines
8.6 KiB
Ruby
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
|