mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 12:55:20 +00:00
* Add SnapTrade OAuth device flow * Add SnapTrade OAuth device flow ### Motivation - Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint. - Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling. ### Description - Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers. - Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly. - Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity. - Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses. - Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI. - Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence. ### Testing - Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed. - Ran `bin/rubocop` against modified files and no offenses were reported. * Add SnapTrade OAuth device flow ### Motivation - Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint. - Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling. ### Description - Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers. - Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly. - Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity. - Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses. - Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI. - Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence. ### Testing - Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed. - Ran `bin/rubocop` against modified files and no offenses were reported. * Add SnapTrade OAuth device flow ### Motivation - Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint. - Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling. ### Description - Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers. - Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly. - Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity. - Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses. - Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI. - Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence. ### Testing - Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed. - Ran `bin/rubocop` against modified files and no offenses were reported. * Add SnapTrade OAuth device flow ### Motivation - Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint. - Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling. ### Description - Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers. - Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly. - Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity. - Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses. - Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI. - Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence. ### Testing - Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed. - Ran `bin/rubocop` against modified files and no offenses were reported. * Add SnapTrade OAuth device flow (provider, model, controller, routes, migration, tests) ### Motivation - Add support for SnapTrade OAuth 2.0 device authorization flow so users can authorize SnapTrade via device codes in addition to the existing portal flow. - Persist OAuth token metadata on `SnaptradeItem` for subsequent polling and usage by the provider and UI. ### Description - Implemented OAuth device flow in the provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token`, plus HTTP helper methods `oauth_connection`, `oauth_client_id`, and `parse_oauth_response` in `Provider::Snaptrade`. - Added controller endpoints `start_oauth_device_flow` and `complete_oauth_device_flow` in `SnaptradeItemsController` with robust error handling and helpers `oauth_error_payload` and `parse_oauth_error_body` to surface OAuth error fields. - Extended `SnaptradeItem` with encrypted columns for `oauth_access_token`, `oauth_refresh_token`, token metadata, `oauth_token_active?`, and model methods `start_oauth_device_flow` and `complete_oauth_device_flow!` to store token metadata. - Added migration `AddOauthDeviceFlowToSnaptradeItems` and updated `db/schema.rb` to include the new columns, registered a new initializer `config/initializers/snaptrade.rb` to read `SNAPTRADE_OAUTH_CLIENT_ID`, and updated `.env*.example` files to document `SNAPTRADE_OAUTH_CLIENT_ID`. - Exposed new routes `start_oauth_device_flow` and `complete_oauth_device_flow` for `snaptrade_items`. ### Testing - Added unit tests for provider OAuth behavior in `test/models/provider/snaptrade_oauth_test.rb`, model token persistence in `test/models/snaptrade_item_oauth_test.rb`, and controller error propagation in `test/controllers/snaptrade_items_controller_test.rb`. - Ran the test suite with `bin/rails test` and the full test run (including the new SnapTrade OAuth tests) passed. * Add SnapTrade OAuth device flow (start/poll), store tokens, and tests ### Motivation - Add support for SnapTrade OAuth Device Authorization flow so administrators can start device authorization and poll for tokens without exposing provider internals. - Persist OAuth token metadata on `SnaptradeItem` so the app can reuse and surface token state for SnapTrade integrations. - Improve error handling and sanitization for OAuth API errors returned by SnapTrade to avoid leaking upstream internals. ### Description - Introduces new environment examples and initializer: adds `SNAPTRADE_OAUTH_CLIENT_ID` to `.env.local.example`/`.env.test.example` and configures `Rails.configuration.x.snaptrade.oauth_client_id` in `config/initializers/snaptrade.rb`. - Extends `Provider::Snaptrade` with OAuth device flow support, adding discovery URL, device grant constant, Faraday `oauth_connection`, and methods `oauth_authorization_server_metadata`, `start_device_authorization`, `poll_device_token`, `oauth_client_id`, and `parse_oauth_response`, plus improved retry and API error wrapping. - Adds DB migration and schema changes to store OAuth fields on `snaptrade_items` (`oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, `oauth_token_expires_at`) and marks those attributes encrypted when ActiveRecord encryption is enabled. - Adds `SnaptradeItem` helpers `start_oauth_device_flow`, `complete_oauth_device_flow!`, and `oauth_token_active?` to start/poll and persist token metadata. - Adds controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` with sanitized error responses and helper methods `oauth_error_payload` and `parse_oauth_error_body`, and wires new member routes in `config/routes.rb`. - Updates `SnaptradeItemsController` before_action lists to include the new actions and adds user-facing error message helper `start_oauth_device_flow_error_message`. ### Testing - Added unit tests `test/models/provider/snaptrade_oauth_test.rb` to validate discovery, device authorization request, token polling, missing client ID handling, and OAuth error propagation, and all assertions passed. - Added `test/models/snaptrade_item_oauth_test.rb` to assert `complete_oauth_device_flow!` stores tokens and expiry and that `oauth_token_active?` reports correctly, and the test passed. - Extended `test/controllers/snaptrade_items_controller_test.rb` with controller-level tests confirming sanitized OAuth error payloads and behavior for start/complete endpoints, and these controller tests passed. * Add SnapTrade OAuth device flow support and token storage ### Motivation - Add support for the OAuth 2.0 device authorization flow for SnapTrade so users can link brokerages via a device-code flow without traditional browser-based client redirects. - Persist OAuth token metadata on the SnaptradeItem so tokens can be reused and expiration tracked. - Surface and sanitize provider error payloads to callers while avoiding leakage of internal configuration details. ### Description - Added new DB columns and a migration (`oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, `oauth_token_expires_at`) and updated `db/schema.rb` to reflect the change. - Encrypted the new token fields on `SnaptradeItem` and added `oauth_token_active?`, `start_oauth_device_flow`, and `complete_oauth_device_flow!` helpers to the model. - Implemented OAuth logic in `Provider::Snaptrade` including discovery (`OAUTH_DISCOVERY_URL`), `start_device_authorization`, `poll_device_token`, request parsing, retry handling, and a small Faraday connection wrapper; added configuration accessors for `oauth_client_id` via `config.x.snaptrade`. - Added controller endpoints `start_oauth_device_flow` and `complete_oauth_device_flow` with safe error handling and helpers to format OAuth error payloads, and registered new member routes for `snaptrade_items`. - Added an initializer to load `SNAPTRADE_OAUTH_CLIENT_ID` into `Rails.configuration.x.snaptrade`, and updated `.env.local.example` and `.env.test.example` to include the new env var. - Added unit tests for provider OAuth behavior (`test/models/provider/snaptrade_oauth_test.rb`), SnaptradeItem token persistence (`test/models/snaptrade_item_oauth_test.rb`), and controller error handling (`test/controllers/snaptrade_items_controller_test.rb`), and updated controller tests accordingly. ### Testing - Ran provider-level tests in `Provider::SnaptradeOauthTest` to validate discovery, device authorization, token polling, and error handling, which passed. - Ran model tests in `SnaptradeItemOauthTest` to verify token metadata is stored and `oauth_token_active?` works, which passed. - Ran controller tests in `SnaptradeItemsControllerTest` that exercise the start/complete endpoints and error sanitization, which passed. * Add SnapTrade OAuth device-flow support and token storage ### Motivation - Add support for SnapTrade OAuth device authorization so users can perform device-code based OAuth without embedding secrets in the browser. - Persist OAuth token metadata on `SnaptradeItem` and expose programmatic start/complete endpoints while avoiding leaking provider internals on errors. - Make OAuth client ID configurable via environment or credentials for Sure deployments. ### Description - Introduce `SNAPTRADE_OAUTH_CLIENT_ID` to `.env` examples and add `config.x.snaptrade.oauth_client_id` initializer for configuration. - Add migration `AddOauthDeviceFlowToSnaptradeItems` and update `schema.rb` to add `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at` to `snaptrade_items`. - Persist and encrypt new token fields in `SnaptradeItem` and add helper methods `oauth_token_active?`, `start_oauth_device_flow`, and `complete_oauth_device_flow!`. - Extend `Provider::Snaptrade` with OAuth discovery, device authorization (`start_device_authorization`), token polling (`poll_device_token`), Faraday-based `oauth_connection`, parsing and error-wrapping logic, and retry handling. - Add controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` to `SnaptradeItemsController`, plus helper methods to format OAuth error payloads and user-facing error messages. - Wire up new routes (`post :start_oauth_device_flow`, `post :complete_oauth_device_flow`) and add `config/initializers/snaptrade.rb`. - Add comprehensive tests for the OAuth flow and controller error handling and update a system test stub to avoid provider leakage during UI tests. ### Testing - Added `Provider::SnaptradeOauthTest` which stubs discovery, device authorization and token endpoints and asserts request payloads and responses; tests passed. - Added `SnaptradeItemOauthTest` which verifies token metadata persistence and `oauth_token_active?`; test passed. - Extended `SnaptradeItemsControllerTest` with scenarios for successful and failing device-flow completion and start flow error handling; tests passed. - Ran the affected system test changes in `TradesTest` (provider stubbing and modal behavior); the updated tests passed.
610 lines
23 KiB
Ruby
610 lines
23 KiB
Ruby
class SnaptradeItemsController < ApplicationController
|
|
before_action :set_snaptrade_item, only: [ :show, :edit, :update, :destroy, :sync, :connect, :setup_accounts, :complete_account_setup, :connections, :start_oauth_device_flow, :complete_oauth_device_flow, :delete_connection, :delete_orphaned_user ]
|
|
before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :connect, :callback, :setup_accounts, :complete_account_setup, :connections, :start_oauth_device_flow, :complete_oauth_device_flow, :delete_connection, :delete_orphaned_user ]
|
|
|
|
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 ||= t("snaptrade_items.default_name")
|
|
|
|
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
|
|
@snaptrade_item.ensure_user_registered! unless @snaptrade_item.user_registered?
|
|
|
|
redirect_url = callback_snaptrade_items_url(item_id: @snaptrade_item.id)
|
|
portal_url = @snaptrade_item.connection_portal_url(redirect_url: redirect_url)
|
|
redirect_to portal_url, allow_other_host: true
|
|
rescue ActiveRecord::Encryption::Errors::Decryption => e
|
|
Rails.logger.error "SnapTrade decryption error for item #{@snaptrade_item.id}: #{e.class} - #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
|
|
redirect_to settings_providers_path, alert: t(".decryption_failed")
|
|
rescue => e
|
|
Rails.logger.error "SnapTrade connection error: #{e.class} - #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
|
|
redirect_to settings_providers_path, alert: t(".connection_failed", message: e.message)
|
|
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
|
|
snaptrade_item.sync_later unless snaptrade_item.syncing?
|
|
|
|
stored_return_to, stored_accountable_type = clear_snaptrade_resume_context
|
|
return_to = params[:return_to].presence || stored_return_to
|
|
accountable_type = params[:accountable_type].presence || stored_accountable_type
|
|
|
|
if return_to == "setup_accounts"
|
|
redirect_to setup_accounts_snaptrade_item_path(snaptrade_item, accountable_type: accountable_type.presence), notice: t(".success")
|
|
else
|
|
redirect_to accounts_path, notice: t(".success")
|
|
end
|
|
else
|
|
clear_snaptrade_resume_context
|
|
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?
|
|
|
|
# We trigger an initial or recovery sync if there are no accounts, we aren't currently syncing,
|
|
# and the last attempt didn't successfully complete. (If it completed and found 0 accounts, we stop here to avoid an infinite loop.)
|
|
latest_sync = @snaptrade_item.syncs.ordered.first
|
|
should_sync = latest_sync.nil? || !latest_sync.completed?
|
|
|
|
if no_accounts && !@snaptrade_item.syncing? && should_sync
|
|
@snaptrade_item.sync_later
|
|
end
|
|
|
|
# Existing unlinked, visible investment/crypto accounts that could be linked instead of creating duplicates
|
|
@linkable_accounts = Current.family.accounts
|
|
.visible
|
|
.where(accountable_type: %w[Investment Crypto])
|
|
.left_joins(:account_providers)
|
|
.where(account_providers: { id: nil })
|
|
.order(:name)
|
|
|
|
# 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
|
|
|
|
if errors.any?
|
|
# Partial success - some linked, some failed
|
|
redirect_to accounts_path,
|
|
notice: t(".partial_success", count: linked_count, failed_count: 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
|
|
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
|
|
|
|
# Fetch connections list for Turbo Frame
|
|
def connections
|
|
data = build_connections_list
|
|
render partial: "snaptrade_items/connections_list", layout: false, locals: {
|
|
connections: data[:connections],
|
|
orphaned_users: data[:orphaned_users],
|
|
snaptrade_item: @snaptrade_item,
|
|
error: @error
|
|
}
|
|
end
|
|
|
|
def start_oauth_device_flow
|
|
render json: @snaptrade_item.start_oauth_device_flow(scope: params[:scope].presence || "read")
|
|
rescue ActiveRecord::Encryption::Errors::Decryption => e
|
|
Rails.logger.error "SnapTrade decryption error for item #{@snaptrade_item.id}: #{e.class} - #{e.message}"
|
|
render json: { error: t("snaptrade_items.connect.decryption_failed") }, status: :unprocessable_entity
|
|
rescue Provider::Snaptrade::Error => e
|
|
Rails.logger.error "SnapTrade OAuth device authorization error: #{e.class} - #{e.message}"
|
|
render json: { error: start_oauth_device_flow_error_message }, status: :unprocessable_entity
|
|
end
|
|
|
|
def complete_oauth_device_flow
|
|
if params[:device_code].blank?
|
|
render json: { error: "device_code is required" }, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
token_response = @snaptrade_item.complete_oauth_device_flow!(device_code: params[:device_code])
|
|
render json: {
|
|
token_type: token_response["token_type"],
|
|
scope: token_response["scope"],
|
|
expires_in: token_response["expires_in"],
|
|
expires_at: @snaptrade_item.oauth_token_expires_at&.iso8601
|
|
}
|
|
rescue Provider::Snaptrade::ApiError => e
|
|
render json: oauth_error_payload(e), status: e.status_code || :unprocessable_entity
|
|
rescue Provider::Snaptrade::Error, ActiveRecord::ActiveRecordError, ActiveRecord::Encryption::Errors::Base => e
|
|
Rails.logger.error "SnapTrade OAuth device token error: #{e.class} - #{e.message}"
|
|
render json: { error: complete_oauth_device_flow_error_message }, status: :unprocessable_entity
|
|
end
|
|
|
|
# Delete a brokerage connection
|
|
def delete_connection
|
|
authorization_id = params[:authorization_id]
|
|
|
|
if authorization_id.blank?
|
|
redirect_to settings_providers_path, alert: t(".failed", message: t(".missing_authorization_id"))
|
|
return
|
|
end
|
|
|
|
# Delete all local SnaptradeAccounts for this connection (triggers cleanup job)
|
|
accounts_deleted = @snaptrade_item.snaptrade_accounts
|
|
.where(snaptrade_authorization_id: authorization_id)
|
|
.destroy_all
|
|
.size
|
|
|
|
# If no local accounts existed (orphan), delete directly from API
|
|
api_deletion_failed = false
|
|
if accounts_deleted == 0
|
|
provider = @snaptrade_item.snaptrade_provider
|
|
creds = @snaptrade_item.snaptrade_credentials
|
|
|
|
if provider && creds&.dig(:user_id) && creds&.dig(:user_secret)
|
|
provider.delete_connection(
|
|
user_id: creds[:user_id],
|
|
user_secret: creds[:user_secret],
|
|
authorization_id: authorization_id
|
|
)
|
|
else
|
|
Rails.logger.warn "SnapTrade: Cannot delete orphaned connection #{authorization_id} - missing credentials"
|
|
api_deletion_failed = true
|
|
end
|
|
end
|
|
|
|
respond_to do |format|
|
|
if api_deletion_failed
|
|
format.html { redirect_to settings_providers_path, alert: t(".api_deletion_failed") }
|
|
format.turbo_stream do
|
|
flash.now[:alert] = t(".api_deletion_failed")
|
|
render turbo_stream: flash_notification_stream_items
|
|
end
|
|
else
|
|
format.html { redirect_to settings_providers_path, notice: t(".success") }
|
|
format.turbo_stream { render turbo_stream: turbo_stream.remove("connection_#{authorization_id}") }
|
|
end
|
|
end
|
|
rescue Provider::Snaptrade::ApiError => e
|
|
respond_to do |format|
|
|
format.html { redirect_to settings_providers_path, alert: t(".failed", message: e.message) }
|
|
format.turbo_stream do
|
|
flash.now[:alert] = t(".failed", message: e.message)
|
|
render turbo_stream: flash_notification_stream_items
|
|
end
|
|
end
|
|
end
|
|
|
|
# Delete an orphaned SnapTrade user (and all their connections)
|
|
def delete_orphaned_user
|
|
user_id = params[:user_id]
|
|
|
|
# Security: verify this is actually an orphaned user
|
|
unless @snaptrade_item.orphaned_users.include?(user_id)
|
|
respond_to do |format|
|
|
format.html { redirect_to settings_providers_path, alert: t(".failed") }
|
|
format.turbo_stream do
|
|
flash.now[:alert] = t(".failed")
|
|
render turbo_stream: flash_notification_stream_items
|
|
end
|
|
end
|
|
return
|
|
end
|
|
|
|
if @snaptrade_item.delete_orphaned_user(user_id)
|
|
respond_to do |format|
|
|
format.html { redirect_to settings_providers_path, notice: t(".success") }
|
|
format.turbo_stream { render turbo_stream: turbo_stream.remove("orphaned_user_#{user_id.parameterize}") }
|
|
end
|
|
else
|
|
respond_to do |format|
|
|
format.html { redirect_to settings_providers_path, alert: t(".failed") }
|
|
format.turbo_stream do
|
|
flash.now[:alert] = t(".failed")
|
|
render turbo_stream: flash_notification_stream_items
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# Collection actions for account linking flow
|
|
|
|
def preload_accounts
|
|
snaptrade_item = current_snaptrade_item
|
|
unless snaptrade_item
|
|
redirect_to settings_providers_path, alert: t(".not_configured", default: "SnapTrade is not configured.")
|
|
return
|
|
end
|
|
|
|
if snaptrade_item.user_registered?
|
|
snaptrade_item.sync_later unless snaptrade_item.syncing?
|
|
redirect_to setup_accounts_snaptrade_item_path(snaptrade_item)
|
|
else
|
|
redirect_to connect_snaptrade_item_path(snaptrade_item)
|
|
end
|
|
end
|
|
|
|
def select_accounts
|
|
@accountable_type = params[:accountable_type]
|
|
@return_to = params[:return_to]
|
|
snaptrade_item = current_snaptrade_item
|
|
|
|
unless snaptrade_item
|
|
redirect_to settings_providers_path, alert: t(".not_configured", default: "SnapTrade is not configured.")
|
|
return
|
|
end
|
|
|
|
if snaptrade_item.user_registered?
|
|
redirect_to setup_accounts_snaptrade_item_path(snaptrade_item, accountable_type: @accountable_type, return_to: @return_to)
|
|
else
|
|
store_snaptrade_resume_context(return_to: @return_to, accountable_type: @accountable_type)
|
|
redirect_to connect_snaptrade_item_path(snaptrade_item)
|
|
end
|
|
end
|
|
|
|
def link_accounts
|
|
redirect_to settings_providers_path, alert: t(".use_setup_flow")
|
|
end
|
|
|
|
def select_existing_account
|
|
@account_id = params[:account_id]
|
|
@account = Current.family.accounts.find_by(id: @account_id)
|
|
snaptrade_item = current_snaptrade_item
|
|
|
|
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]
|
|
snaptrade_item_id = params[:snaptrade_item_id]
|
|
|
|
account = Current.family.accounts.find_by(id: account_id)
|
|
snaptrade_item = Current.family.snaptrade_items.find_by(id: snaptrade_item_id)
|
|
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 current_snaptrade_item
|
|
active_items = Current.family.snaptrade_items.active
|
|
|
|
active_items.syncable.ordered.first ||
|
|
active_items.credentials_configured.ordered.first ||
|
|
active_items.ordered.first
|
|
end
|
|
|
|
def store_snaptrade_resume_context(return_to:, accountable_type:)
|
|
session[:snaptrade_resume] = {
|
|
return_to: return_to,
|
|
accountable_type: accountable_type
|
|
}
|
|
end
|
|
|
|
def clear_snaptrade_resume_context
|
|
resume = (session.delete(:snaptrade_resume) || {}).with_indifferent_access
|
|
[ resume[:return_to], resume[:accountable_type] ]
|
|
end
|
|
|
|
def snaptrade_item_params
|
|
params.require(:snaptrade_item).permit(
|
|
:name,
|
|
:sync_start_date,
|
|
:client_id,
|
|
:consumer_key
|
|
)
|
|
end
|
|
|
|
def build_connections_list
|
|
# Fetch connections for current user from API
|
|
api_connections = @snaptrade_item.fetch_connections
|
|
|
|
# Get local accounts grouped by authorization_id
|
|
local_accounts = @snaptrade_item.snaptrade_accounts
|
|
.includes(:account_provider)
|
|
.group_by(&:snaptrade_authorization_id)
|
|
|
|
# Build unified list
|
|
result = { connections: [], orphaned_users: [] }
|
|
|
|
# Add connections from API for current user
|
|
api_connections.each do |api_conn|
|
|
auth_id = api_conn.id
|
|
local_accts = local_accounts[auth_id] || []
|
|
|
|
result[:connections] << {
|
|
authorization_id: auth_id,
|
|
brokerage_name: api_conn.brokerage&.name || I18n.t("snaptrade_items.connections.unknown_brokerage"),
|
|
brokerage_slug: api_conn.brokerage&.slug,
|
|
accounts: local_accts.map { |acct|
|
|
{ id: acct.id, name: acct.name, linked: acct.account_provider.present? }
|
|
},
|
|
orphaned_connection: local_accts.empty?
|
|
}
|
|
end
|
|
|
|
# Add orphaned users (users registered but not current)
|
|
orphaned = @snaptrade_item.orphaned_users
|
|
orphaned.each do |user_id|
|
|
result[:orphaned_users] << {
|
|
user_id: user_id,
|
|
display_name: user_id.truncate(30)
|
|
}
|
|
end
|
|
|
|
result
|
|
rescue Provider::Snaptrade::ApiError => e
|
|
@error = e.message
|
|
{ connections: [], orphaned_users: [] }
|
|
end
|
|
|
|
def oauth_error_payload(error)
|
|
parsed_body = parse_oauth_error_body(error.response_body)
|
|
payload = parsed_body.slice("error", "error_description", "error_uri", "interval")
|
|
payload["error"] ||= error.message
|
|
payload
|
|
end
|
|
|
|
def start_oauth_device_flow_error_message
|
|
t(
|
|
"snaptrade_items.start_oauth_device_flow.failed",
|
|
default: "Unable to start SnapTrade OAuth device authorization. Please try again."
|
|
)
|
|
end
|
|
|
|
def complete_oauth_device_flow_error_message
|
|
t(
|
|
"snaptrade_items.complete_oauth_device_flow.failed",
|
|
default: "Unable to complete SnapTrade OAuth device authorization. Please try again."
|
|
)
|
|
end
|
|
|
|
def parse_oauth_error_body(response_body)
|
|
return {} if response_body.blank?
|
|
|
|
parsed_body = JSON.parse(response_body)
|
|
parsed_body.is_a?(Hash) ? parsed_body : {}
|
|
rescue JSON::ParserError
|
|
{}
|
|
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
|