Files
sure/app/models/coinstats_item/exchange_linker.rb
Anas Limouri a90f9b7317 Add CoinStats exchange portfolio sync and normalize linked investment charts (#1308)
* [FEATURE] Add CoinStats exchange portfolios and normalize linked investment charts

* [BUGFIX] Fix CoinStats PR regressions

* [BUGFIX] Fix CoinStats PR review findings

* [BUGFIX] Address follow-up CoinStats PR feedback

* [REFACTO] Extract CoinStats exchange account helpers

* [BUGFIX] Batch linked CoinStats chart normalization

* [BUGFIX] Fix CoinStats processor lint

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-04-01 20:25:06 +02:00

99 lines
3.6 KiB
Ruby

# frozen_string_literal: true
class CoinstatsItem::ExchangeLinker
Result = Struct.new(:success?, :created_count, :errors, keyword_init: true)
attr_reader :coinstats_item, :connection_id, :connection_fields, :name
def initialize(coinstats_item, connection_id:, connection_fields:, name: nil)
@coinstats_item = coinstats_item
@connection_id = connection_id
@connection_fields = connection_fields.to_h.compact_blank
@name = name
end
def link
return Result.new(success?: false, created_count: 0, errors: [ "Exchange is required" ]) if connection_id.blank?
return Result.new(success?: false, created_count: 0, errors: [ "Exchange credentials are required" ]) if connection_fields.blank?
created_count = 0
exchange = fetch_exchange_definition
validate_required_fields!(exchange)
response = provider.connect_portfolio_exchange(
connection_id: connection_id,
connection_fields: connection_fields,
name: name.presence || default_portfolio_name(exchange)
)
return Result.new(success?: false, created_count: 0, errors: [ response.error.message ]) unless response.success?
payload = response.data.with_indifferent_access
portfolio_id = payload[:portfolioId]
raise Provider::Coinstats::Error, "CoinStats did not return a portfolioId" if portfolio_id.blank?
coins = provider.list_portfolio_coins(portfolio_id: portfolio_id)
ActiveRecord::Base.transaction do
coinstats_item.update!(
exchange_connection_id: connection_id,
exchange_portfolio_id: portfolio_id,
institution_id: connection_id,
institution_name: exchange[:name],
raw_institution_payload: exchange
)
if coins.nil?
Rails.logger.warn "CoinstatsItem::ExchangeLinker - Initial portfolio coin fetch missing for item #{coinstats_item.id} portfolio #{portfolio_id}; deferring local account creation to background sync"
else
coinstats_account = exchange_portfolio_account_manager.upsert_account!(
coins_data: coins,
portfolio_id: portfolio_id,
connection_id: exchange[:connection_id],
exchange_name: exchange[:name],
account_name: name.presence || exchange[:name],
institution_logo: exchange[:icon]
)
created_count = exchange_portfolio_account_manager.ensure_local_account!(coinstats_account) ? 1 : 0
end
end
coinstats_item.sync_later
Result.new(success?: true, created_count: created_count, errors: [])
rescue Provider::Coinstats::Error, ArgumentError => e
Result.new(success?: false, created_count: 0, errors: [ e.message ])
end
private
def provider
@provider ||= Provider::Coinstats.new(coinstats_item.api_key)
end
def exchange_portfolio_account_manager
@exchange_portfolio_account_manager ||= CoinstatsItem::ExchangePortfolioAccountManager.new(coinstats_item)
end
def fetch_exchange_definition
exchange = provider.exchange_options.find { |option| option[:connection_id] == connection_id }
raise ArgumentError, "Unsupported exchange connection: #{connection_id}" unless exchange
exchange
end
def validate_required_fields!(exchange)
missing_fields = Array(exchange[:connection_fields]).filter_map do |field|
key = field[:key].to_s
field[:name] if key.blank? || connection_fields[key].blank?
end
return if missing_fields.empty?
raise ArgumentError, "Missing required exchange fields: #{missing_fields.join(', ')}"
end
def default_portfolio_name(exchange)
"#{exchange[:name]} Portfolio"
end
end