mirror of
https://github.com/we-promise/sure.git
synced 2026-07-13 13:25:22 +00:00
* Add native Questrade brokerage provider integration Adds a per-family Questrade provider so users can sync their Questrade investment accounts (TFSA, FHSA, RRSP, margin, etc.) directly via Questrade's free personal API, with no paid aggregator. - OAuth2 refresh-token flow with single-use token rotation, persisted under a row lock. Tokens self-renew on each sync; the connected panel lets users paste a fresh token if a connection goes stale (no need to disconnect and re-link). - Imports accounts, balances, positions and activities; multi-currency holdings with per-currency cash holdings; Norbert's Gambit journals. - New-account and link-existing-account flows, settings card with desktop-only setup steps, and connect/update/disconnect. - Restricted to Investment account types. Registered in the provider connection-status registry with a syncable scope so it participates in nightly family sync. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: linting error * fix: refresh token encrypted The OAuth token exchange passed the single-use refresh token as a GET query parameter, so it could leak into URL-based logs (Sentry breadcrumbs, APM spans, debug output). Switch to POST with a form-encoded body (RFC 6749 3.2) so the credential stays out of URLs. Verified Questrade's token endpoint accepts POST (returns 400 for a bad token, not 405). Adds a test asserting the token travels in the body. * Address PR review: authz, data integrity, retries, logging Batch of fixes from the automated PR review: - Require admin for all mutating/linking Questrade actions, and gate existing-account linking through accessible_accounts + write permission (was only Current.family scoped). - Clear requires_update when a fresh token is accepted; use a real 302 redirect (not 422) on full-page failures. - Require refresh_token on all saves (not just create) unless the item is scheduled for deletion. - Migrations target Rails 7.2; questrade_items state columns are NOT NULL. - Background activity dedup keys on Questrade fields (matches the importer) so multiple activities no longer collapse to one. - Persist the normalized account payload; date-scope synthetic cash holdings so daily history is not overwritten. - Retry 429/5xx via a RetryableResponseError instead of hard-failing. - Route provider error bodies to DebugLogEntry instead of Rails.logger / exception messages, so payloads do not leak into application logs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address PR review: atomic linking, sync health, retry loop, USD cash - Wrap account creation + provider linking (+ sync_start_date) in a transaction in both link paths so a link failure rolls back the orphan account. - Surface per-account process/schedule failures in the item sync health instead of always reporting healthy. - Always stamp last_activities_sync once the background fetch completes, so legitimately empty accounts stop being re-queued every sync. - Treat only the account-currency (CAD) balance as primary cash; other currencies (e.g. USD) now surface as separate cash holdings instead of being hidden as primary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address PR review: serialize token exchange, real processor tests - Single-use token race: the SDK now wraps every token exchange (initial and 401 re-auth) in a model-supplied lock that reloads and spends the freshest persisted token (provided.rb#synchronize_exchange). Two concurrent syncs/jobs can no longer double-spend the same refresh token. Adds a test asserting the exchange runs inside the lock with the fresh token. - Replace the all-skipped QuestradeAccount processor test stubs with real fixture-backed tests covering balance anchoring, holdings import, and Buy-trade import (plus blank-symbol / blank-type guards). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix indentation of spliced Questrade schema blocks The manually added questrade_accounts/questrade_items create_table blocks sat at column 0 instead of the file 2-space indent, so rubocop flagged them as inconsistent. Re-indent to match the rest of the schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Include currency and type in the Questrade activity merge key Two activities that differ only by currency or type could collapse to a single row in merge_activities. Add both fields to activity_key in the importer and the background fetch job so multi-currency imports dedup correctly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address review: infer account currency, Encryptable, safer flag clear From @jjmata's review: - Currency: QuestradeAccount#upsert_from_questrade! no longer hardcodes CAD for every account. upsert_balances! now infers the home currency from the per-currency balances (the currency holding the cash wins, ties broken by total equity, default CAD) so USD-denominated accounts are labelled USD and match the right combinedBalances anchor. Adds tests for USD and CAD cases. - QuestradeItem now includes the shared Encryptable concern instead of reimplementing encryption_ready? inline. - QuestradeActivitiesFetchJob#clear_pending_flag is now best-effort so it can never mask (and swallow) the original error in perform's rescue. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix review issues: safe_return_to_path, DebugLogEntry, financial reset, turbo_prefetch, N+1 counts - Add safe_return_to_path to QuestradeItemsController (blocks //evil.com protocol-relative open redirect; same 3-check guard as PR #2591 Wise provider) - Pass return_to through select_accounts and complete_account_setup so users land back on the account they were linking from - Replace Rails.logger.error/warn with DebugLogEntry.capture in controller and unlinking concern (surface errors in the app debug log UI) - Add questrade_items to Family::FinancialDataReset::PROVIDER_ITEM_ASSOCIATIONS so Reset Financial Data actually removes Questrade data - Add when "questrade" case to load_provider_items in providers_controller so the settings panel lazy-load refresh works - Fix turbo_prefetch: false on non-lunchflow provider links in _method_selector.html.erb and select_provider.html.erb (prevents prefetch-cache blank-modal bug for all generic sync providers) - Preload questrade_accounts: :account_provider and build @questrade_account_counts_map in AccountsController; read from map in partial instead of calling .count on associations (eliminates N+1) - Localize default connection name via I18n.t(questrade_items.default_name) - Add default_name key to questrade_items locale Patterns and bugs surfaced during review of PR #2591 (Wise provider). * Cross-apply Wise learnings to Questrade provider Encryption (matched convention from Wise/jjmata review): - Add deterministic: true to QuestradeItem#refresh_token - Add encrypts :raw_payload + :raw_institution_payload to QuestradeItem - Add Encryptable + encrypts :raw_payload, :raw_holdings_payload, :raw_activities_payload, :raw_balances_payload to QuestradeAccount (brokerage-specific columns; matches MercuryAccount/UpAccount pattern) Bug fix: - Add missing RetryableResponseError class to Provider::Questrade (used in with_retries rescue clause but never defined — would cause NameError on any rate-limited or 5xx response) Logging: - Replace Rails.logger.error with DebugLogEntry.capture in QuestradeItem#import_latest_questrade_data, #process_accounts, and #schedule_account_syncs to surface errors in the support UI Consistency: - Extract update_sync_status(sync, key, **i18n_options) helper in QuestradeItem::Syncer, replacing 5 inline sync.update! guard calls - Use blank? instead of ||= for default name fallback in create action Tests: - Add QuestradeItemsControllerTest (18 tests: CRUD, sync, account linking/setup flows, admin guard enforcement) - Add questrade fixtures: questrade_items.yml, questrade_accounts.yml - Add retry/backoff tests to Provider::QuestradeTest (network error, 429, 5xx — all verify MAX_RETRIES exhaustion raises Error) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Signed-off-by: Jestin Palamuttam <34907800+jestinjoshi@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
456 lines
18 KiB
Ruby
456 lines
18 KiB
Ruby
class AccountsController < ApplicationController
|
|
include StreamExtensions
|
|
|
|
before_action :set_account, only: %i[show sparkline sync set_default remove_default]
|
|
before_action :set_manageable_account, only: %i[toggle_active toggle_exclude_from_reports destroy unlink confirm_unlink select_provider]
|
|
include Periodable
|
|
|
|
def index
|
|
@accessible_account_ids = Current.user.accessible_accounts.pluck(:id)
|
|
@manual_accounts = family.accounts
|
|
.listable_manual
|
|
.where(id: @accessible_account_ids)
|
|
.includes(:accountable, :account_providers, :plaid_account, :simplefin_account)
|
|
.order(:name)
|
|
@plaid_items = visible_provider_items(family.plaid_items.ordered.includes(:syncs, :plaid_accounts))
|
|
@simplefin_items = visible_provider_items(family.simplefin_items.ordered.includes(:syncs))
|
|
@lunchflow_items = visible_provider_items(family.lunchflow_items.ordered.includes(:syncs, :lunchflow_accounts))
|
|
@akahu_items = visible_provider_items(family.akahu_items.ordered.includes(:syncs, :akahu_accounts))
|
|
@up_items = visible_provider_items(family.up_items.ordered.includes(:syncs, :up_accounts))
|
|
@enable_banking_items = visible_provider_items(family.enable_banking_items.ordered.includes(:syncs))
|
|
@coinstats_items = visible_provider_items(family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs))
|
|
@mercury_items = visible_provider_items(family.mercury_items.ordered.includes(:syncs, :mercury_accounts))
|
|
@brex_items = visible_provider_items(family.brex_items.ordered.includes(:accounts, :syncs, brex_accounts: :account_provider))
|
|
@coinbase_items = visible_provider_items(family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs))
|
|
@snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts))
|
|
@ibkr_items = visible_provider_items(family.ibkr_items.ordered.includes(:syncs, :ibkr_accounts))
|
|
@indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts))
|
|
@sophtron_items = visible_provider_items(family.sophtron_items.ordered.includes(:syncs, :sophtron_accounts))
|
|
@binance_items = visible_provider_items(family.binance_items.ordered.includes(:binance_accounts, :accounts, :syncs))
|
|
@questrade_items = visible_provider_items(family.questrade_items.ordered.includes(:syncs, questrade_accounts: :account_provider))
|
|
|
|
# Build sync stats maps for all providers
|
|
build_sync_stats_maps
|
|
|
|
# Prevent Turbo Drive from caching this page to ensure fresh account lists
|
|
expires_now
|
|
render layout: "settings"
|
|
end
|
|
|
|
def new
|
|
# Get all registered providers with any credentials configured
|
|
@provider_configs = Provider::Factory.registered_adapters.flat_map do |adapter_class|
|
|
adapter_class.connection_configs(family: family)
|
|
end
|
|
end
|
|
|
|
def sync_all
|
|
family.sync_later
|
|
redirect_to accounts_path, notice: t("accounts.sync_all.syncing")
|
|
end
|
|
|
|
def show
|
|
@chart_view = params[:chart_view] || "balance"
|
|
@tab = params[:tab]
|
|
@q = params.fetch(:q, {}).permit(:search, status: [])
|
|
entries = @account.entries.where(excluded: false).search(@q).reverse_chronological.includes(:entryable)
|
|
if statement_tab_active?
|
|
build_statement_tab_data
|
|
return render_statement_tab_frame if statement_tab_frame_request?
|
|
end
|
|
|
|
@pagy, @entries = pagy(
|
|
entries,
|
|
limit: safe_per_page,
|
|
params: request.query_parameters.except("tab").merge("tab" => "activity")
|
|
)
|
|
Transaction::ActivitySecurityPreloader.new(@entries).preload
|
|
|
|
@activity_feed_data = Account::ActivityFeedData.new(@account, @entries)
|
|
end
|
|
|
|
def sync
|
|
unless @account.syncing?
|
|
if @account.linked?
|
|
# Sync all provider items for this account
|
|
# Each provider item will trigger an account sync when complete
|
|
@account.account_providers.each do |account_provider|
|
|
item = account_provider.adapter&.item
|
|
item&.sync_later if item && !item.syncing?
|
|
end
|
|
else
|
|
# Manual accounts just need balance materialization
|
|
@account.sync_later
|
|
end
|
|
end
|
|
|
|
redirect_to account_path(@account)
|
|
end
|
|
|
|
def sparkline
|
|
etag_key = @account.family.build_cache_key("#{@account.id}_sparkline_#{Account::Chartable::SPARKLINE_CACHE_VERSION}", invalidate_on_data_updates: true)
|
|
|
|
# Short-circuit with 304 Not Modified when the client already has the latest version.
|
|
# We defer the expensive series computation until we know the content is stale.
|
|
if stale?(etag: etag_key, last_modified: @account.family.latest_sync_completed_at)
|
|
@sparkline_series = @account.sparkline_series
|
|
render layout: false
|
|
end
|
|
end
|
|
|
|
def toggle_active
|
|
if @account.active?
|
|
@account.disable!
|
|
elsif @account.disabled?
|
|
@account.enable!
|
|
end
|
|
redirect_to accounts_path
|
|
end
|
|
|
|
# Toggles the exclude_from_reports flag on the account and redirects to the
|
|
# account list. The flag controls whether the account's data appears in
|
|
# financial reports, dashboards, and exports.
|
|
def toggle_exclude_from_reports
|
|
@account.update!(exclude_from_reports: !@account.exclude_from_reports?)
|
|
redirect_to accounts_path
|
|
end
|
|
|
|
def set_default
|
|
unless @account.eligible_for_transaction_default?
|
|
redirect_to accounts_path, alert: t("accounts.set_default.depository_only")
|
|
return
|
|
end
|
|
|
|
Current.user.update!(default_account: @account)
|
|
redirect_to accounts_path
|
|
end
|
|
|
|
def remove_default
|
|
Current.user.update!(default_account: nil)
|
|
redirect_to accounts_path
|
|
end
|
|
|
|
def destroy
|
|
if @account.linked?
|
|
redirect_to account_path(@account), alert: t("accounts.destroy.cannot_delete_linked")
|
|
else
|
|
begin
|
|
@account.destroy_later
|
|
redirect_to accounts_path, notice: t("accounts.destroy.success", type: @account.accountable_type)
|
|
rescue => e
|
|
Rails.logger.error "Failed to schedule account #{@account.id} for deletion: #{e.message}"
|
|
redirect_to accounts_path, alert: t("accounts.destroy.failed")
|
|
end
|
|
end
|
|
end
|
|
|
|
def confirm_unlink
|
|
unless @account.linked?
|
|
redirect_to account_path(@account), alert: t("accounts.unlink.not_linked")
|
|
end
|
|
end
|
|
|
|
def unlink
|
|
unless @account.linked?
|
|
redirect_to account_path(@account), alert: t("accounts.unlink.not_linked")
|
|
return
|
|
end
|
|
|
|
begin
|
|
Account.transaction do
|
|
# Detach holdings from provider links before destroying them
|
|
provider_link_ids = @account.account_providers.pluck(:id)
|
|
if provider_link_ids.any?
|
|
Holding.where(account_provider_id: provider_link_ids).update_all(account_provider_id: nil)
|
|
end
|
|
|
|
# Capture provider accounts before clearing links (so we can destroy them)
|
|
simplefin_account_to_destroy = @account.simplefin_account
|
|
|
|
# Remove new system links (account_providers join table)
|
|
# SnaptradeAccount records are preserved (not destroyed) so users can relink later.
|
|
# This follows the Plaid pattern where the provider account survives as "unlinked".
|
|
# SnapTrade has limited connection slots (5 free), so preserving the record avoids
|
|
# wasting a slot on reconnect.
|
|
@account.account_providers.destroy_all
|
|
|
|
# Remove legacy system links (foreign keys)
|
|
@account.update!(plaid_account_id: nil, simplefin_account_id: nil)
|
|
|
|
# Destroy the SimplefinAccount record so it doesn't cause stale account issues
|
|
# This is safe because:
|
|
# - Account data (transactions, holdings, balances) lives on the Account, not SimplefinAccount
|
|
# - SimplefinAccount only caches API data which is regenerated on reconnect
|
|
# - If user reconnects SimpleFin later, a new SimplefinAccount will be created
|
|
simplefin_account_to_destroy&.destroy!
|
|
end
|
|
|
|
redirect_to accounts_path, notice: t("accounts.unlink.success")
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
redirect_to account_path(@account), alert: t("accounts.unlink.error", error: e.message)
|
|
rescue StandardError => e
|
|
Rails.logger.error "Failed to unlink account #{@account.id}: #{e.message}"
|
|
redirect_to account_path(@account), alert: t("accounts.unlink.error", error: t("accounts.unlink.generic_error"))
|
|
end
|
|
end
|
|
|
|
def select_provider
|
|
if @account.linked?
|
|
redirect_to account_path(@account), alert: t("accounts.select_provider.already_linked")
|
|
return
|
|
end
|
|
|
|
account_type_name = @account.accountable_type
|
|
|
|
# Get all available provider configs dynamically for this account type
|
|
provider_configs = Provider::Factory.connection_configs_for_account_type(
|
|
account_type: account_type_name,
|
|
family: family
|
|
)
|
|
|
|
# Build available providers list with paths resolved for this specific account
|
|
# Filter out providers that don't support linking to existing accounts
|
|
@available_providers = provider_configs.filter_map do |config|
|
|
next unless config[:existing_account_path].present?
|
|
{
|
|
name: config[:name],
|
|
key: config[:key],
|
|
description: config[:description],
|
|
path: config[:existing_account_path].call(@account.id)
|
|
}
|
|
end
|
|
|
|
if @available_providers.empty?
|
|
redirect_to account_path(@account), alert: t("accounts.select_provider.no_providers")
|
|
end
|
|
end
|
|
|
|
private
|
|
def family
|
|
Current.family
|
|
end
|
|
|
|
def set_account
|
|
@account = Current.user.accessible_accounts.find(params[:id])
|
|
end
|
|
|
|
def set_manageable_account
|
|
@account = Current.user.accessible_accounts.find(params[:id])
|
|
permission = @account.permission_for(Current.user)
|
|
unless permission.in?([ :owner, :full_control ])
|
|
respond_to do |format|
|
|
format.html { redirect_to account_path(@account), alert: t("accounts.not_authorized") }
|
|
format.turbo_stream { stream_redirect_to(account_path(@account), alert: t("accounts.not_authorized")) }
|
|
end
|
|
nil
|
|
end
|
|
end
|
|
|
|
def visible_provider_items(items)
|
|
items.select do |item|
|
|
Current.user.admin? ||
|
|
(item.respond_to?(:accounts) && (item.accounts.map(&:id) & @accessible_account_ids).any?)
|
|
end
|
|
end
|
|
|
|
def build_statement_tab_data
|
|
return unless statement_tab_active?
|
|
|
|
@statement_coverage = AccountStatement::Coverage.for_year(@account, params[:statement_year])
|
|
@account_statements = @account.account_statements.with_attached_original_file.ordered.to_a
|
|
@statement_reconciliation_statuses = AccountStatement.reconciliation_statuses_for(@account_statements, account: @account)
|
|
permission = @account.permission_for(Current.user)
|
|
@can_manage_statements = AccountStatement.statement_manager?(Current.user) &&
|
|
permission.in?([ :owner, :full_control ])
|
|
end
|
|
|
|
def statement_tab_frame_request?
|
|
turbo_frame_request? && request.headers["Turbo-Frame"] == helpers.dom_id(@account, :statements_tab)
|
|
end
|
|
|
|
def render_statement_tab_frame
|
|
render partial: "accounts/show/statements_frame", locals: statement_tab_locals, layout: false
|
|
end
|
|
|
|
def statement_tab_locals
|
|
{
|
|
account: @account,
|
|
coverage: @statement_coverage,
|
|
statements: @account_statements,
|
|
reconciliation_statuses: @statement_reconciliation_statuses,
|
|
can_manage_statements: @can_manage_statements
|
|
}
|
|
end
|
|
|
|
def statement_tab_active?
|
|
@tab == "statements"
|
|
end
|
|
|
|
# Builds sync stats maps for all provider types to avoid N+1 queries in views
|
|
def build_sync_stats_maps
|
|
# SimpleFIN sync stats
|
|
@simplefin_sync_stats_map = {}
|
|
@simplefin_has_unlinked_map = {}
|
|
@simplefin_unlinked_count_map = {}
|
|
@simplefin_show_relink_map = {}
|
|
@simplefin_duplicate_only_map = {}
|
|
|
|
@simplefin_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
stats = latest_sync&.sync_stats || {}
|
|
@simplefin_sync_stats_map[item.id] = stats
|
|
@simplefin_has_unlinked_map[item.id] = item.family.accounts.listable_manual.exists?
|
|
|
|
# Count unlinked accounts
|
|
count = item.simplefin_accounts
|
|
.left_joins(:account, :account_provider)
|
|
.where(accounts: { id: nil }, account_providers: { id: nil })
|
|
.count
|
|
@simplefin_unlinked_count_map[item.id] = count
|
|
|
|
# CTA visibility
|
|
manuals_exist = @simplefin_has_unlinked_map[item.id]
|
|
sfa_any = item.simplefin_accounts.loaded? ? item.simplefin_accounts.any? : item.simplefin_accounts.exists?
|
|
@simplefin_show_relink_map[item.id] = (count.to_i == 0 && manuals_exist && sfa_any)
|
|
|
|
# Check if all errors are duplicate-skips
|
|
errors = Array(stats["errors"]).map { |e| e.is_a?(Hash) ? e["message"] || e[:message] : e.to_s }
|
|
@simplefin_duplicate_only_map[item.id] = errors.present? && errors.all? { |m| m.to_s.downcase.include?("duplicate upstream account detected") }
|
|
rescue => e
|
|
Rails.logger.warn("SimpleFin stats map build failed for item #{item.id}: #{e.class} - #{e.message}")
|
|
@simplefin_sync_stats_map[item.id] = {}
|
|
@simplefin_show_relink_map[item.id] = false
|
|
@simplefin_duplicate_only_map[item.id] = false
|
|
end
|
|
|
|
# Plaid sync stats
|
|
@plaid_sync_stats_map = {}
|
|
@plaid_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
@plaid_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
|
end
|
|
|
|
# Lunchflow sync stats
|
|
@lunchflow_sync_stats_map = {}
|
|
@lunchflow_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
@lunchflow_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
|
end
|
|
|
|
# Akahu sync stats
|
|
@akahu_sync_stats_map = {}
|
|
@akahu_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
@akahu_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
|
end
|
|
|
|
# Up sync stats
|
|
@up_sync_stats_map = {}
|
|
@up_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
@up_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
|
end
|
|
|
|
# Enable Banking sync stats
|
|
@enable_banking_sync_stats_map = {}
|
|
@enable_banking_latest_sync_error_map = {}
|
|
@enable_banking_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
@enable_banking_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
|
@enable_banking_latest_sync_error_map[item.id] = latest_sync&.error
|
|
end
|
|
|
|
# CoinStats sync stats
|
|
@coinstats_sync_stats_map = {}
|
|
@coinstats_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
@coinstats_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
|
end
|
|
|
|
# Sophtron sync stats
|
|
@sophtron_sync_stats_map = {}
|
|
@sophtron_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
@sophtron_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
|
end
|
|
|
|
# Mercury sync stats
|
|
@mercury_sync_stats_map = {}
|
|
@mercury_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
@mercury_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
|
end
|
|
|
|
# Brex sync stats
|
|
@brex_sync_stats_map = {}
|
|
@brex_account_counts_map = {}
|
|
@brex_institutions_count_map = {}
|
|
@brex_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
@brex_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
|
brex_accounts = item.brex_accounts.to_a
|
|
linked_count = brex_accounts.count { |brex_account| brex_account.account_provider.present? }
|
|
total_count = brex_accounts.count
|
|
@brex_account_counts_map[item.id] = {
|
|
linked: linked_count,
|
|
unlinked: total_count - linked_count,
|
|
total: total_count
|
|
}
|
|
@brex_institutions_count_map[item.id] = brex_accounts
|
|
.filter_map(&:institution_metadata)
|
|
.uniq { |institution| institution["name"] || institution["institution_name"] }
|
|
.count
|
|
end
|
|
|
|
# Coinbase sync stats
|
|
@coinbase_sync_stats_map = {}
|
|
@coinbase_unlinked_count_map = {}
|
|
@coinbase_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
@coinbase_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
|
|
|
# Count unlinked accounts
|
|
count = item.coinbase_accounts
|
|
.left_joins(:account_provider)
|
|
.where(account_providers: { id: nil })
|
|
.count
|
|
@coinbase_unlinked_count_map[item.id] = count
|
|
end
|
|
|
|
# IndexaCapital sync stats
|
|
@indexa_capital_sync_stats_map = {}
|
|
@indexa_capital_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
@indexa_capital_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
|
end
|
|
|
|
# Binance sync stats
|
|
@binance_sync_stats_map = {}
|
|
@binance_unlinked_count_map = {}
|
|
@binance_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
@binance_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
|
|
|
# Count unlinked accounts
|
|
count = item.binance_accounts
|
|
.left_joins(:account_provider)
|
|
.where(account_providers: { id: nil })
|
|
.count
|
|
@binance_unlinked_count_map[item.id] = count
|
|
end
|
|
|
|
# Questrade sync stats and account counts
|
|
@questrade_sync_stats_map = {}
|
|
@questrade_account_counts_map = {}
|
|
@questrade_items.each do |item|
|
|
latest_sync = item.syncs.ordered.first
|
|
@questrade_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
|
accounts = item.questrade_accounts.to_a
|
|
linked = accounts.count { |a| a.account_provider.present? }
|
|
@questrade_account_counts_map[item.id] = {
|
|
linked: linked, unlinked: accounts.size - linked, total: accounts.size
|
|
}
|
|
end
|
|
end
|
|
end
|