Files
sure/app/models/snaptrade_item.rb
William Wei Ming f210d1ca4c Perf/accounts controller index optimization (#1926)
* fix Prism issue - assigned but unused variable plaid_item

* measurement to improve accounts_controller__index performance based on skylight

* fix FEEDBACK - Stop eager-loading full sync histories on accounts index

* fix FEEDBACK - Remove sticky memoization from SimplefinItem#accounts

* fix lint error in PR review

* fix FEEDBACK - Avoid returning map misses as authoritative results

* fix test error

* fix FEEDBACK - Avoid redundant query by using already-loaded @manual_accounts

* Address maintainer review on accounts index sync preloading
Memoize SimplefinItem#accounts, align syncing? with Sync#visible?,
and add fallback regression tests.

* Add composite index for sync DISTINCT ON queries
Support latest_by_syncable ordering with syncable_type, syncable_id,
created_at DESC, and id DESC.

* fix error in dockerfile.preview file

* Suppress Pipelock false positives on CI database fixtures.
Pipelock 2.7.0 full-repo audit flags ephemeral postgres:// URLs in
workflow env blocks. Re-apply inline suppressions lost in the main merge.

* Address review: guard partial Current sync maps and drop unrelated diff
Add key? checks so partially populated Current sync maps fall back to DB
queries. Revert Dockerfile.preview and pipelock.yml changes unrelated to
the accounts index N+1 optimization.

* fix(sync): fully populate Current sync maps for all preloaded syncables

* fix(ci): skip scheduled preview cleanup on forks
Only run the hourly Cloudflare preview cleanup on we-promise/sure,
where the required secrets exist.

* Remove schema.rb Postgres-version churn, keep only new syncs index

Reset db/schema.rb to upstream/main and re-add only the
index_syncs_on_syncable_and_created_at_and_id index. The prior diff
included ~30 lines of noise (check-constraint reformatting, virtual
column reformat, and column reorderings) caused by dumping under a
different PostgreSQL version, which obscured the single intended
schema change and invited merge conflicts.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 03:35:35 +02:00

234 lines
7.8 KiB
Ruby

class SnaptradeItem < ApplicationRecord
include Syncable, Provided, Unlinking
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
# Helper to detect if ActiveRecord Encryption is configured for this app
def self.encryption_ready?
creds_ready = Rails.application.credentials.active_record_encryption.present?
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
creds_ready || env_ready
end
# Encrypt sensitive credentials if ActiveRecord encryption is configured
# client_id/consumer_key use deterministic encryption (may need querying)
# snaptrade_user_secret uses non-deterministic (more secure for pure secrets)
# Note: snaptrade_user_id is not encrypted as it's just an identifier, not a secret
if encryption_ready?
encrypts :client_id, deterministic: true
encrypts :consumer_key, deterministic: true
encrypts :snaptrade_user_secret
encrypts :oauth_access_token
encrypts :oauth_refresh_token
end
validates :name, presence: true
belongs_to :family
has_one_attached :logo, dependent: :purge_later
has_many :snaptrade_accounts, dependent: :destroy
has_many :linked_accounts, through: :snaptrade_accounts
scope :active, -> { where(scheduled_for_deletion: false) }
# Syncable = active + authorized via OAuth (has an access token).
# oauth_access_token is non-deterministically encrypted, so it can only be
# queried with an IS NULL check (never persisted as "" -- see apply_oauth_tokens!).
scope :syncable, -> { active.where.not(oauth_access_token: nil) }
scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) }
def destroy_later
update!(scheduled_for_deletion: true)
DestroyJob.perform_later(self)
end
def import_latest_snaptrade_data(sync: nil)
provider = snaptrade_provider
unless provider
Rails.logger.error "SnaptradeItem #{id} - Cannot import: OAuth not authorized"
raise StandardError, "SnapTrade is not authorized"
end
SnaptradeItem::Importer.new(self, snaptrade_provider: provider, sync: sync).import
rescue => e
Rails.logger.error "SnaptradeItem #{id} - Failed to import data: #{e.message}"
raise
end
def process_accounts
return [] if snaptrade_accounts.empty?
results = []
# Process only accounts that are linked to a Sure account
linked_snaptrade_accounts.includes(account_provider: :account).each do |snaptrade_account|
account = snaptrade_account.current_account
next unless account
next if account.pending_deletion? || account.disabled?
begin
result = SnaptradeAccount::Processor.new(snaptrade_account).process
results << { snaptrade_account_id: snaptrade_account.id, success: true, result: result }
rescue => e
Rails.logger.error "SnaptradeItem #{id} - Failed to process account #{snaptrade_account.id}: #{e.message}"
results << { snaptrade_account_id: snaptrade_account.id, success: false, error: e.message }
end
end
results
end
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
linked_accounts = accounts.reject { |a| a.pending_deletion? || a.disabled? }
return [] if linked_accounts.empty?
results = []
linked_accounts.each do |account|
begin
account.sync_later(
parent_sync: parent_sync,
window_start_date: window_start_date,
window_end_date: window_end_date
)
results << { account_id: account.id, success: true }
rescue => e
Rails.logger.error "SnaptradeItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}"
results << { account_id: account.id, success: false, error: e.message }
end
end
results
end
def upsert_snaptrade_snapshot!(accounts_snapshot)
assign_attributes(
raw_payload: accounts_snapshot
)
save!
end
# Persist an OAuth token payload (string keys, as returned by the token endpoint).
# A rotation response may omit refresh_token; keep the existing one in that case.
def apply_oauth_tokens!(payload)
raise ArgumentError, "OAuth token payload missing access_token" if payload["access_token"].blank?
update!(
oauth_access_token: payload["access_token"],
oauth_refresh_token: payload["refresh_token"].presence || oauth_refresh_token,
oauth_token_type: payload["token_type"].presence || oauth_token_type,
oauth_scope: payload["scope"].presence || oauth_scope,
oauth_token_expires_at: payload["expires_in"].present? ? Time.current + payload["expires_in"].to_i.seconds : oauth_token_expires_at
)
end
def has_completed_initial_setup?
# Setup is complete if we have any linked accounts
accounts.any?
end
def sync_status_summary
total_accounts = total_accounts_count
linked_count = linked_accounts_count
unlinked_count = unlinked_accounts_count
if total_accounts == 0
I18n.t("snaptrade_item.sync_status.no_accounts")
elsif unlinked_count == 0
I18n.t("snaptrade_item.sync_status.synced", count: linked_count)
else
I18n.t("snaptrade_item.sync_status.synced_with_setup", linked: linked_count, unlinked: unlinked_count)
end
end
def linked_accounts_count
snaptrade_accounts.joins(:account_provider).count
end
def unlinked_accounts_count
snaptrade_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count
end
def total_accounts_count
snaptrade_accounts.count
end
def institution_display_name
institution_name.presence || institution_domain.presence || name
end
def connected_institutions
snaptrade_accounts
.where.not(institution_metadata: nil)
.map { |acc| acc.institution_metadata }
.uniq { |inst| inst["name"] || inst["institution_name"] }
end
def oauth_token_active?
oauth_access_token.present? && (oauth_token_expires_at.blank? || oauth_token_expires_at.future?)
end
def institution_summary
institutions = connected_institutions
case institutions.count
when 0
I18n.t("snaptrade_item.institution_summary.none")
when 1
institutions.first["name"] || institutions.first["institution_name"] || I18n.t("snaptrade_item.institution_summary.count", count: 1)
else
I18n.t("snaptrade_item.institution_summary.count", count: institutions.count)
end
end
def oauth_configured?
oauth_access_token.present?
end
alias_method :credentials_configured?, :oauth_configured?
def fully_configured?
oauth_configured?
end
# Override Syncable#syncing? to also show syncing state when activities are being
# fetched in the background. This ensures the UI shows the spinner until all data
# is truly imported, not just when the main sync job completes.
def syncing?
super || snaptrade_accounts.where(activities_fetch_pending: true).exists?
end
# Get accounts linked via AccountProvider
def linked_snaptrade_accounts
snaptrade_accounts.joins(:account_provider)
end
# Get all Sure accounts linked to this SnapTrade item
def accounts
@accounts ||= snaptrade_accounts
.includes(account_provider: :account)
.filter_map { |sa| sa.current_account }
.uniq
end
# Get unique brokerages from connected accounts
def connected_brokerages
snaptrade_accounts
.where.not(brokerage_name: nil)
.pluck(:brokerage_name)
.uniq
end
def brokerage_summary
brokerages = connected_brokerages
case brokerages.count
when 0
I18n.t("snaptrade_item.brokerage_summary.none")
when 1
brokerages.first
else
I18n.t("snaptrade_item.brokerage_summary.count", count: brokerages.count)
end
end
end