mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 08:34:26 +00:00
* feat(snaptrade): add device-flow OAuth alongside the browser redirect SnapTrade could only be connected through the authorization-code + PKCE flow, which needs a confidential OAuth client: SNAPTRADE_OAUTH_CLIENT_SECRET and a redirect URI registered on the OAuth app. A deployment that cannot register one had no path at all. Add the device grant (RFC 8628) as a second way to obtain the same token, so people can pick the flow that suits their deployment. Both grants end at SnaptradeItem#apply_oauth_tokens!, so a device-authorized item is indistinguishable from a redirect-authorized one from there on -- same Bearer data calls, refresh, revocation and sync. Nothing about existing authorized items changes: no schema change, no migration, and the PKCE path is untouched. - Provider::Snaptrade gains start_device_authorization and poll_device_token, with endpoints read from SnapTrade's OAuth metadata document (cached). - oauth_configured? now means "some flow is available" (public client id), which is what gates syncing and the provider panel; the new authorization_code_configured? gates the redirect flow specifically. - Token and revocation requests authenticate as a public client when no secret is configured -- client_id in the body instead of HTTP Basic. Without this a device-authorized item would authorize fine and then fail at its first token rotation. - The settings panel offers both when both are available; every other entry point picks one through SnaptradeItemsHelper#snaptrade_authorize_path. - The device page carries a failed attempt's code back into the form, so "not confirmed yet" is a retry rather than a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk * fix(snaptrade): keep the provider panel's setup-step keys and cover both flows Two test_unit failures from the panel change. The setup steps were reordered and their keys renamed, which orphaned the translations twelve locales already had for them and broke the test asserting `oauth_setup_step_3`. The rename bought nothing: reword the steps in place instead, leaving the callback URL on step 2 where the interpolation lives. The panel tests stubbed `oauth_configured?`, which no longer decides which buttons render -- that is now `authorization_code_configured?`. Stub both, so the "configured" cases test the deployment they name, and add the device-only case that was previously unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk * fix(snaptrade): address device-flow review findings Two real bugs from the bot reviews, plus consistency work. The completion form posts into the `drawer` frame so errors re-render in place, but a successful redirect was then followed as a frame navigation. Both destinations carry the layout's empty `drawer` frame, so Turbo swapped that in and merely closed the dialog: the notice was lost and `return_to=setup_accounts` never advanced. Success now breaks out with a redirect stream action, the same mechanism holdings and categorizes already use, while errors keep rendering in the drawer. RFC 8628 §3.1 requires a confidential client to authenticate its device authorization request, and the panel offers the device code on deployments that configured a secret. That request now carries the same client authentication as the token request. Token endpoint resolution is now shared by all three grants, since whatever issued a token has to be what refreshes it. It reads the discovery document only when already cached and never fetches it, so the browser flow keeps working off the constant it has always used -- no new network call on refresh and no new way for an existing authorized item to fail. Also: the drawer no longer asks the provider whether it is configured, the controller tells it; and the test helpers restore the previous OAuth config rather than clearing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk * fix(snaptrade): reject a device authorization response that cannot drive the flow A 2xx missing device_code, user_code or a verification URI was passed straight to the drawer, which then rendered a blank code and a link to nowhere -- a dead end the user could only abandon. Every one of those fields is load-bearing, and a response without them is partial or schema-changed, so fail with a message instead. Same reasoning as the results-array check in get_positions. verification_uri_complete substitutes for verification_uri when present, since the drawer prefers it for the link anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk * fix(snaptrade): filter device-flow codes from request logs complete_oauth_device_flow receives the device code as a request parameter, and none of the existing filter_parameters patterns is a substring of "device_code" -- ParameterFilter matches on substrings, and "token", "_key", "secret", "code_verifier" and "code_challenge" all miss it. So Rails' default "Processing by ... Parameters: {...}" line was writing it in plaintext. That matters more here than ordinary log hygiene: the device code is the only capability check on redemption. Unlike the redirect flow's state, nothing binds a device code to the family that requested it, so anyone who can read the logs could redeem another family's in-flight authorization into their own item and pick up a token for that family's brokerage data. Adds :device_code, :user_code and :verification_uri_complete (which embeds the user code) to the filter list, with a regression test in the style of the existing Sophtron credential-filtering test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk * fix(snaptrade): bind a pending device authorization to its session The device code was posted back from the drawer as a form field, so the request body was the only thing deciding which item a pending authorization redeemed into. Nothing tied a code to the family that asked for it -- the guarantee `state` gives the redirect flow -- so a code recovered from anywhere could be redeemed into an item belonging to someone else, handing them a token for the victim's brokerage data. Hold the pending authorization in the session instead, where oauth_callback already keeps its code_verifier and state: - start_oauth_device_flow records the code, what the page displays, the family, the item and the return_to context under :snaptrade_device_flow. - complete_oauth_device_flow reads the code from there and refuses unless the flow was started by this session for this family and this item. A device_code parameter is no longer read at all, so there is no longer a way to inject one. - return_to and accountable_type come from the session too, so completion needs nothing from the form to find its way back. The code now never reaches the browser, which also makes the previous commit's log filtering a second line of defence rather than the only one. A failed attempt keeps the code only while it is still redeemable: expired_token and access_denied clear it so the page offers a fresh start, while a transient failure leaves it in place to retry. expires_in and interval are no longer carried anywhere, since nothing ever read them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk * fix(snaptrade): use one token endpoint for every grant poll_device_token resolved the token endpoint from the cached discovery document while exchange_code and refresh_tokens used TOKEN_URL, so which URL a device-issued token was refreshed at depended on whether the 12h metadata cache was still warm. If the discovered endpoint ever differed from the constant, a device-authorized item would work until the cache lapsed and then fail its first rotation -- and fail invisibly, since a refresh failure marks the connection requires_update. Resolve it by removing the choice rather than by making refresh depend on discovery. RFC 8628 §3.4 redeems a device code at the authorization server's token endpoint, the same one the authorization code grant uses: there is one token endpoint, not one per grant, and nothing to keep in sync between issuing a token and refreshing it. TOKEN_URL is also the endpoint the browser flow has been using in production, so it is the one with evidence behind it. Discovery is still consulted, but only for device_authorization_endpoint, which has no hardcoded equivalent. This also keeps refresh free of any network dependency it did not already have: reintroducing discovery there would have put a fetch, with retries and backoff, in front of every token rotation on items that never needed one. Also restore the previous OAuth configuration in the missing-client-id test instead of leaving the client id nil, which made it order-dependent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3f2UyefTKJrgvNjPnhMRk --------- Co-authored-by: Claude <noreply@anthropic.com>
605 lines
24 KiB
Ruby
605 lines
24 KiB
Ruby
# SnapTrade API client using SnapTrade OAuth apps (pre-release feature).
|
|
#
|
|
# Auth model:
|
|
# - Instance admin registers an OAuth app on dashboard.snaptrade.com and sets
|
|
# SNAPTRADE_OAUTH_CLIENT_ID (and, for the browser flow, SNAPTRADE_OAUTH_CLIENT_SECRET).
|
|
# - Users authorize through either grant, and both end at the same place:
|
|
# * authorization-code + PKCE -- a browser redirect. Needs a confidential
|
|
# client (client secret) and a registered redirect URI.
|
|
# * device code (RFC 8628) -- a user code confirmed on SnapTrade. Needs
|
|
# only the public client id, so a deployment that cannot register a
|
|
# confidential client still has a working path.
|
|
# Either way the per-item access/refresh tokens are stored (encrypted) on
|
|
# SnaptradeItem by #apply_oauth_tokens!.
|
|
# - Data calls send Authorization: Bearer <access_token>. The SnapTrade user
|
|
# is implicit in the token; there is no userId/userSecret.
|
|
class Provider::Snaptrade
|
|
class Error < StandardError; end
|
|
# `oauth_error` carries the machine-readable `error` code from an OAuth error
|
|
# response when there was one (RFC 6749 §5.2, RFC 8628 §3.5). The device flow
|
|
# needs it to tell "the user hasn't approved this yet" apart from a real
|
|
# failure; the message itself is never safe to show, since it can carry
|
|
# upstream detail.
|
|
class AuthenticationError < Error
|
|
attr_reader :oauth_error
|
|
|
|
def initialize(message = nil, oauth_error: nil)
|
|
super(message)
|
|
@oauth_error = oauth_error
|
|
end
|
|
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
|
|
|
|
MAX_RETRIES = 3
|
|
INITIAL_RETRY_DELAY = 2 # seconds
|
|
MAX_RETRY_DELAY = 30 # seconds
|
|
|
|
API_BASE_URL = "https://api.snaptrade.com".freeze
|
|
AUTHORIZE_URL = "https://dashboard.snaptrade.com/oauth/authorize".freeze
|
|
TOKEN_URL = "https://api.snaptrade.com/oauth/token/".freeze
|
|
REVOKE_URL = "https://api.snaptrade.com/oauth/revoke_token/".freeze
|
|
DASHBOARD_URL = "https://dashboard.snaptrade.com".freeze
|
|
OAUTH_DISCOVERY_URL = "https://api.snaptrade.com/.well-known/oauth-authorization-server".freeze
|
|
DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code".freeze
|
|
# What the drawer needs to be usable at all: a code to show, a place to enter
|
|
# it, and a code to redeem afterwards. verification_uri_complete substitutes
|
|
# for verification_uri when present.
|
|
DEVICE_AUTHORIZATION_FIELDS = %w[device_code user_code verification_uri].freeze
|
|
# The discovery document is static; cached so device authorization doesn't pay
|
|
# a blocking round trip for it on every attempt.
|
|
OAUTH_METADATA_CACHE_KEY = "snaptrade:oauth_authorization_server_metadata".freeze
|
|
OAUTH_METADATA_CACHE_TTL = 12.hours
|
|
TOKEN_EXPIRY_LEEWAY = 60 # seconds; refresh this long before actual expiry
|
|
|
|
# Filtered out of get_positions so they never reach raw_holdings_payload:
|
|
# units are per-contract and symbols OCC-style, which neither
|
|
# SnaptradeAccount::HoldingsProcessor nor the units * price sum in
|
|
# SnaptradeAccount::Processor#calculate_holdings_value models. A denylist, so
|
|
# equity-like kinds added later still import rather than being dropped.
|
|
UNSUPPORTED_INSTRUMENT_KINDS = %w[option future cfd].freeze
|
|
|
|
class << self
|
|
# Some OAuth flow is available. The device flow needs nothing but the public
|
|
# client id, so this is what gates syncing and the provider panel; only the
|
|
# browser redirect needs more (see authorization_code_configured?).
|
|
def oauth_configured?
|
|
oauth_client_id.present?
|
|
end
|
|
|
|
# The authorization-code + PKCE flow additionally needs a confidential
|
|
# client: this implementation authenticates the token request with the
|
|
# secret, and the redirect URI has to be registered on the OAuth app.
|
|
def authorization_code_configured?
|
|
oauth_client_id.present? && oauth_client_secret.present?
|
|
end
|
|
|
|
def oauth_client_id
|
|
Rails.configuration.x.snaptrade&.oauth_client_id
|
|
end
|
|
|
|
def oauth_client_secret
|
|
Rails.configuration.x.snaptrade&.oauth_client_secret
|
|
end
|
|
|
|
# PKCE pair per RFC 7636 (S256)
|
|
def generate_pkce
|
|
verifier = SecureRandom.urlsafe_base64(64).delete("=")[0, 128]
|
|
challenge = Base64.urlsafe_encode64(OpenSSL::Digest::SHA256.digest(verifier), padding: false)
|
|
{ verifier: verifier, challenge: challenge }
|
|
end
|
|
|
|
def authorize_url(redirect_uri:, state:, code_challenge:, scope: "read")
|
|
raise ConfigurationError, "SnapTrade OAuth client secret is not configured" unless authorization_code_configured?
|
|
|
|
params = {
|
|
response_type: "code",
|
|
client_id: oauth_client_id,
|
|
redirect_uri: redirect_uri,
|
|
scope: scope,
|
|
state: state,
|
|
code_challenge: code_challenge,
|
|
code_challenge_method: "S256"
|
|
}
|
|
"#{AUTHORIZE_URL}?#{params.to_query}"
|
|
end
|
|
|
|
def exchange_code(code:, redirect_uri:, code_verifier:)
|
|
raise ConfigurationError, "SnapTrade OAuth client secret is not configured" unless authorization_code_configured?
|
|
|
|
params = {
|
|
grant_type: "authorization_code",
|
|
code: code,
|
|
redirect_uri: redirect_uri,
|
|
code_verifier: code_verifier
|
|
}
|
|
|
|
token_request(params)
|
|
end
|
|
|
|
# --- Device flow (RFC 8628) ---
|
|
|
|
# Step 1: ask SnapTrade for a device code and the user code to confirm.
|
|
# Returns the parsed payload: device_code, user_code, verification_uri,
|
|
# verification_uri_complete, expires_in, interval.
|
|
def start_device_authorization(scope: "read")
|
|
raise ConfigurationError, "SnapTrade OAuth is not configured" unless oauth_configured?
|
|
|
|
endpoint = oauth_authorization_server_metadata["device_authorization_endpoint"]
|
|
raise ApiError.new("SnapTrade OAuth metadata missing device_authorization_endpoint") if endpoint.blank?
|
|
|
|
# Retried despite being a POST: a lost response means we never learned the
|
|
# user code, so the device code it created is useless to us and expires on
|
|
# its own. Nothing has been applied that a retry could duplicate.
|
|
response = with_retries("POST #{endpoint}") do
|
|
oauth_connection.post(endpoint) do |request|
|
|
request.headers["Authorization"] = basic_auth_header if confidential_client?
|
|
request.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
request.body = URI.encode_www_form(with_client_credentials({ scope: scope }))
|
|
end
|
|
end
|
|
|
|
payload = parse_json(response.body)
|
|
|
|
unless response.success?
|
|
raise ApiError.new(
|
|
"SnapTrade OAuth device authorization failed: #{oauth_error_summary(payload, response.status)}",
|
|
status_code: response.status, response_body: response.body
|
|
)
|
|
end
|
|
|
|
# A 2xx missing any of these is a partial or schema-changed response, and
|
|
# every one of them is load-bearing: without them the drawer renders a
|
|
# blank code and a link to nowhere, which the user can only abandon.
|
|
# Better to fail with a message they can act on. Same reasoning as the
|
|
# results-array check in get_positions.
|
|
missing = DEVICE_AUTHORIZATION_FIELDS.reject { |field| payload[field].present? }
|
|
missing -= [ "verification_uri" ] if payload["verification_uri_complete"].present?
|
|
|
|
if missing.any?
|
|
raise ApiError.new(
|
|
"SnapTrade OAuth device authorization response is missing #{missing.join(', ')}",
|
|
status_code: response.status, response_body: response.body
|
|
)
|
|
end
|
|
|
|
payload
|
|
end
|
|
|
|
# Step 2: redeem the device code once the user has confirmed it. Until they
|
|
# do, SnapTrade answers 400 authorization_pending -- an AuthenticationError
|
|
# whose oauth_error the caller reads to keep waiting rather than give up.
|
|
def poll_device_token(device_code:)
|
|
raise ConfigurationError, "SnapTrade OAuth is not configured" unless oauth_configured?
|
|
raise ArgumentError, "device_code is required" if device_code.blank?
|
|
|
|
token_request({ grant_type: DEVICE_CODE_GRANT, device_code: device_code })
|
|
end
|
|
|
|
# Endpoints come from the authorization server's own metadata rather than
|
|
# being hardcoded, since SnapTrade's OAuth support is pre-release. Cached
|
|
# because the document is static and device authorization would otherwise
|
|
# pay for it twice per attempt.
|
|
def oauth_authorization_server_metadata
|
|
cached = Rails.cache.read(OAUTH_METADATA_CACHE_KEY)
|
|
return cached if cached.present?
|
|
|
|
response = with_retries("GET #{OAUTH_DISCOVERY_URL}") do
|
|
oauth_connection.get(OAUTH_DISCOVERY_URL)
|
|
end
|
|
|
|
payload = parse_json(response.body)
|
|
unless response.success? && payload.is_a?(Hash) && payload.present?
|
|
raise ApiError.new(
|
|
"SnapTrade OAuth metadata request failed: #{oauth_error_summary(payload, response.status)}",
|
|
status_code: response.status, response_body: response.body
|
|
)
|
|
end
|
|
|
|
Rails.cache.write(OAUTH_METADATA_CACHE_KEY, payload, expires_in: OAUTH_METADATA_CACHE_TTL)
|
|
payload
|
|
end
|
|
|
|
def refresh_tokens(refresh_token:)
|
|
token_request({ grant_type: "refresh_token", refresh_token: refresh_token })
|
|
end
|
|
|
|
# Best-effort revocation (RFC 7009). Returns true on success.
|
|
def revoke_token(token:)
|
|
return false if token.blank?
|
|
raise ConfigurationError, "SnapTrade OAuth is not configured" unless oauth_configured?
|
|
|
|
response = oauth_connection.post(REVOKE_URL) do |request|
|
|
request.headers["Authorization"] = basic_auth_header if confidential_client?
|
|
request.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
request.body = URI.encode_www_form(with_client_credentials({ token: token }))
|
|
end
|
|
response.success?
|
|
rescue Faraday::Error => e
|
|
Rails.logger.warn("SnapTrade token revocation failed: #{e.class} - #{e.message}")
|
|
false
|
|
end
|
|
|
|
private
|
|
|
|
# All three grants share TOKEN_URL. RFC 8628 §3.4 redeems a device code at
|
|
# the authorization server's token endpoint -- the same one the
|
|
# authorization code uses -- so there is one endpoint here, not one per
|
|
# grant, and nothing to keep in sync between issuing a token and
|
|
# refreshing it. Discovery is consulted only for
|
|
# device_authorization_endpoint, which has no hardcoded equivalent.
|
|
def token_request(params)
|
|
raise ConfigurationError, "SnapTrade OAuth is not configured" unless oauth_configured?
|
|
|
|
# Not retried: a token request consumes a single-use authorization or
|
|
# device code, or rotates the refresh token. If the response is lost
|
|
# after SnapTrade processed it, replaying the same params would fail
|
|
# with invalid_grant even though the original request succeeded.
|
|
response = without_retry("POST #{TOKEN_URL}") do
|
|
oauth_connection.post(TOKEN_URL) do |request|
|
|
request.headers["Authorization"] = basic_auth_header if confidential_client?
|
|
request.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
request.body = URI.encode_www_form(with_client_credentials(params))
|
|
end
|
|
end
|
|
|
|
payload = parse_json(response.body)
|
|
return payload if response.success?
|
|
|
|
error = oauth_error_summary(payload, response.status)
|
|
if (400..499).cover?(response.status)
|
|
raise AuthenticationError.new(
|
|
"SnapTrade OAuth token request failed: #{error}",
|
|
oauth_error: oauth_error_code(payload)
|
|
)
|
|
end
|
|
|
|
raise ApiError.new(
|
|
"SnapTrade OAuth token request failed: #{error}",
|
|
status_code: response.status, response_body: response.body
|
|
)
|
|
end
|
|
|
|
# A client secret means the token endpoint authenticates the client with
|
|
# HTTP Basic. Without one -- a device-flow-only deployment -- the client
|
|
# is public and identifies itself with client_id in the form body, which
|
|
# is also what RFC 8628 specifies for the device grant.
|
|
def confidential_client?
|
|
oauth_client_secret.present?
|
|
end
|
|
|
|
def with_client_credentials(params)
|
|
return params if confidential_client?
|
|
|
|
params.merge(client_id: oauth_client_id)
|
|
end
|
|
|
|
def basic_auth_header
|
|
"Basic #{Base64.strict_encode64("#{oauth_client_id}:#{oauth_client_secret}")}"
|
|
end
|
|
|
|
def oauth_error_summary(payload, status)
|
|
return "HTTP #{status}" unless payload.is_a?(Hash)
|
|
|
|
payload["error_description"].presence || payload["error"].presence || "HTTP #{status}"
|
|
end
|
|
|
|
def oauth_error_code(payload)
|
|
payload["error"].presence if payload.is_a?(Hash)
|
|
end
|
|
|
|
def oauth_connection
|
|
Faraday.new do |faraday|
|
|
faraday.options.timeout = 30
|
|
faraday.options.open_timeout = 10
|
|
end
|
|
end
|
|
|
|
def parse_json(body)
|
|
body.present? ? JSON.parse(body) : {}
|
|
rescue JSON::ParserError
|
|
{}
|
|
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 OAuth: #{operation_name} failed (attempt #{retries}/#{max_retries}): " \
|
|
"#{e.class}: #{e.message}. Retrying in #{delay}s..."
|
|
)
|
|
sleep(delay)
|
|
retry
|
|
else
|
|
Rails.logger.error(
|
|
"SnapTrade OAuth: #{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
|
|
|
|
# For requests that must not be replayed (single-use codes, token rotation):
|
|
# translate a network failure into an ApiError without retrying.
|
|
def without_retry(operation_name)
|
|
yield
|
|
rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Errno::ECONNRESET, Errno::ETIMEDOUT => e
|
|
Rails.logger.error("SnapTrade OAuth: #{operation_name} failed (not retried, non-idempotent): #{e.class}: #{e.message}")
|
|
raise ApiError.new("Network error (not retried, non-idempotent request): #{e.message}")
|
|
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
|
|
|
|
attr_reader :snaptrade_item
|
|
|
|
def initialize(snaptrade_item)
|
|
raise ConfigurationError, "snaptrade_item is required" if snaptrade_item.nil?
|
|
@snaptrade_item = snaptrade_item
|
|
end
|
|
|
|
# --- Data methods. The SnapTrade user is implicit in the Bearer token. ---
|
|
|
|
# Returns Array<Hash> of brokerage accounts
|
|
def list_accounts
|
|
get_json("/api/v1/accounts")
|
|
end
|
|
|
|
# Returns Array<Hash> of balance entries
|
|
def get_balances(account_id:)
|
|
get_json("/api/v1/accounts/#{account_id}/balances")
|
|
end
|
|
|
|
# Returns Array<Hash> of positions
|
|
def get_positions(account_id:)
|
|
response = get_json("/api/v1/accounts/#{account_id}/positions/all")
|
|
results = response["results"] if response.is_a?(Hash)
|
|
|
|
# An empty `results` is a legitimately empty account, but a missing one is
|
|
# a partial or schema-changed response. Raising leaves the previous
|
|
# snapshot in place rather than overwriting it with nothing.
|
|
unless results.is_a?(Array)
|
|
raise ApiError.new(
|
|
"SnapTrade positions response has no results array " \
|
|
"(keys: #{response.is_a?(Hash) ? response.keys.inspect : response.class})"
|
|
)
|
|
end
|
|
|
|
results.reject { |position| unsupported_instrument?(position) }
|
|
end
|
|
|
|
# Returns raw JSON: paginated form is {"data" => [...]}, may also be a plain Array
|
|
def get_account_activities(account_id:, start_date: nil, end_date: nil)
|
|
params = {}
|
|
params[:startDate] = start_date.to_date.to_s if start_date
|
|
params[:endDate] = end_date.to_date.to_s if end_date
|
|
get_json("/api/v1/accounts/#{account_id}/activities", params)
|
|
end
|
|
|
|
# Cross-account activities endpoint. Returns Array<Hash>.
|
|
def get_activities(start_date: nil, end_date: nil, accounts: nil, brokerage_authorizations: nil, type: nil)
|
|
params = {}
|
|
params[:startDate] = start_date.to_date.to_s if start_date
|
|
params[:endDate] = end_date.to_date.to_s if end_date
|
|
params[:accounts] = accounts if accounts
|
|
params[:brokerageAuthorizations] = brokerage_authorizations if brokerage_authorizations
|
|
params[:type] = type if type
|
|
get_json("/api/v1/activities", params)
|
|
end
|
|
|
|
# Returns Array<Hash> of brokerage authorizations (connections)
|
|
def list_connections
|
|
get_json("/api/v1/authorizations")
|
|
end
|
|
|
|
def delete_connection(authorization_id:)
|
|
request_json(:delete, "/api/v1/authorizations/#{authorization_id}")
|
|
end
|
|
|
|
# Connection portal URL (loginUser). Returns the redirect URL string.
|
|
def get_connection_url(redirect_url:, broker: nil)
|
|
body = { customRedirect: redirect_url, connectionType: "read" }
|
|
body[:broker] = broker if broker
|
|
response = request_json(:post, "/api/v1/snapTrade/login", body: body)
|
|
response["redirectURI"] || response["redirectUri"]
|
|
end
|
|
|
|
private
|
|
|
|
def unsupported_instrument?(position)
|
|
return false unless position.is_a?(Hash)
|
|
|
|
instrument = position["instrument"]
|
|
return false unless instrument.is_a?(Hash)
|
|
|
|
UNSUPPORTED_INSTRUMENT_KINDS.include?(instrument["kind"].to_s.downcase)
|
|
end
|
|
|
|
def get_json(path, params = {})
|
|
request_json(:get, path, params: params)
|
|
end
|
|
|
|
def request_json(method, path, params: {}, body: nil, retry_on_auth_failure: true)
|
|
ensure_fresh_token!
|
|
operation = "#{method.to_s.upcase} #{path}"
|
|
used_access_token = snaptrade_item.oauth_access_token
|
|
|
|
# Only GET is safe to retry: a lost response to a POST/DELETE (e.g.
|
|
# get_connection_url, delete_connection) may already have been applied by
|
|
# SnapTrade, so replaying it risks duplicate side effects.
|
|
retrier = method == :get ? method(:with_retries) : method(:without_retry)
|
|
response = retrier.call(operation) do
|
|
api_connection.public_send(method, "#{API_BASE_URL}#{path}") do |request|
|
|
request.headers["Authorization"] = "Bearer #{used_access_token}"
|
|
request.headers["Accept"] = "application/json"
|
|
request.params.update(params) if params.present?
|
|
if body
|
|
request.headers["Content-Type"] = "application/json"
|
|
request.body = body.to_json
|
|
end
|
|
end
|
|
end
|
|
|
|
if response.status == 401 && retry_on_auth_failure
|
|
refresh_access_token!(previous_access_token: used_access_token)
|
|
return request_json(method, path, params: params, body: body, retry_on_auth_failure: false)
|
|
end
|
|
|
|
handle_response(response, operation)
|
|
end
|
|
|
|
def ensure_fresh_token!
|
|
raise AuthenticationError, "SnapTrade item has no access token" if snaptrade_item.oauth_access_token.blank?
|
|
|
|
expires_at = snaptrade_item.oauth_token_expires_at
|
|
return if expires_at.blank? || expires_at > TOKEN_EXPIRY_LEEWAY.seconds.from_now
|
|
|
|
refresh_access_token!
|
|
end
|
|
|
|
# Guards against a concurrent refresh-token rotation race: multiple threads/processes
|
|
# (e.g. per-account jobs sharing one SnapTrade item) may all observe an expiring/rejected
|
|
# token and attempt to refresh at once. If SnapTrade rotates refresh tokens as single-use,
|
|
# every refresh after the first would fail with invalid_grant and needlessly brick the
|
|
# item. Taking a DB row lock and re-checking freshness after reload ensures only one
|
|
# caller actually performs the HTTP refresh; the rest observe the winner's fresh token.
|
|
#
|
|
# `previous_access_token`, when present, means we're refreshing reactively after a 401 on
|
|
# that specific token (called from request_json). In that case we skip the HTTP refresh
|
|
# only if the DB row's access token has already changed since we made the failed request
|
|
# (i.e. another caller already won the race) -- an expiry-based freshness check would be
|
|
# wrong here since the server rejected a token we believed was still time-valid.
|
|
# When `previous_access_token` is absent, we're refreshing proactively (from
|
|
# ensure_fresh_token!) and skip only if the reloaded row is still time-fresh.
|
|
def refresh_access_token!(previous_access_token: nil)
|
|
snaptrade_item.with_lock do
|
|
snaptrade_item.reload
|
|
|
|
if previous_access_token.present?
|
|
next if snaptrade_item.oauth_access_token != previous_access_token
|
|
else
|
|
expires_at = snaptrade_item.oauth_token_expires_at
|
|
next if expires_at.present? && expires_at > TOKEN_EXPIRY_LEEWAY.seconds.from_now
|
|
end
|
|
|
|
refresh_token = snaptrade_item.oauth_refresh_token
|
|
raise AuthenticationError, "SnapTrade item has no refresh token" if refresh_token.blank?
|
|
|
|
payload = self.class.refresh_tokens(refresh_token: refresh_token)
|
|
snaptrade_item.apply_oauth_tokens!(payload)
|
|
end
|
|
rescue AuthenticationError => e
|
|
mark_requires_update!
|
|
DebugLogEntry.capture(
|
|
category: "provider_sync",
|
|
level: :error,
|
|
message: "SnapTrade token refresh failed: #{e.message}",
|
|
source: "Provider::Snaptrade",
|
|
provider_key: "snaptrade",
|
|
family: snaptrade_item.try(:family),
|
|
metadata: { snaptrade_item_id: snaptrade_item.try(:id) }
|
|
)
|
|
raise
|
|
end
|
|
|
|
def mark_requires_update!
|
|
snaptrade_item.update!(status: :requires_update)
|
|
rescue StandardError => e
|
|
Rails.logger.warn("SnapTrade: could not mark item requires_update: #{e.message}")
|
|
end
|
|
|
|
def handle_response(response, operation)
|
|
if response.success?
|
|
return {} if response.body.blank?
|
|
begin
|
|
JSON.parse(response.body)
|
|
rescue JSON::ParserError
|
|
raise ApiError.new("SnapTrade API error (#{operation}): invalid JSON response",
|
|
status_code: response.status, response_body: response.body)
|
|
end
|
|
else
|
|
Rails.logger.error("SnapTrade API error (#{operation}): #{response.status}")
|
|
case response.status
|
|
when 401, 403
|
|
mark_requires_update!
|
|
raise AuthenticationError, "Authentication failed (#{operation}): HTTP #{response.status}"
|
|
when 429
|
|
raise ApiError.new("Rate limit exceeded. Please try again later.",
|
|
status_code: response.status, response_body: response.body)
|
|
when 500..599
|
|
raise ApiError.new("SnapTrade server error (#{response.status}). Please try again later.",
|
|
status_code: response.status, response_body: response.body)
|
|
else
|
|
raise ApiError.new("SnapTrade API error (#{operation}): HTTP #{response.status}",
|
|
status_code: response.status, response_body: response.body)
|
|
end
|
|
end
|
|
end
|
|
|
|
def api_connection
|
|
@api_connection ||= Faraday.new do |faraday|
|
|
faraday.options.timeout = 30
|
|
faraday.options.open_timeout = 10
|
|
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 without_retry(operation_name)
|
|
yield
|
|
rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Errno::ECONNRESET, Errno::ETIMEDOUT => e
|
|
Rails.logger.error("SnapTrade API: #{operation_name} failed (not retried, non-idempotent): #{e.class}: #{e.message}")
|
|
raise ApiError.new("Network error (not retried, non-idempotent request): #{e.message}")
|
|
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
|