diff --git a/app/components/UI/account/chart.html.erb b/app/components/UI/account/chart.html.erb index ac935938d..efcdca7d6 100644 --- a/app/components/UI/account/chart.html.erb +++ b/app/components/UI/account/chart.html.erb @@ -38,7 +38,7 @@ <%= turbo_frame_tag dom_id(@account, :chart_details) do %>
- <%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: period.comparison_label } %> + <%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: comparison_label } %>
diff --git a/app/components/UI/account/chart.rb b/app/components/UI/account/chart.rb index 1e58529aa..61ae4d6fc 100644 --- a/app/components/UI/account/chart.rb +++ b/app/components/UI/account/chart.rb @@ -69,4 +69,15 @@ class UI::Account::Chart < ApplicationComponent def trend series.trend end + + def comparison_label + start_date = series.start_date + return period.comparison_label if start_date.blank? + + if start_date > period.start_date + "vs. available history" + else + period.comparison_label + end + end end diff --git a/app/controllers/accountable_sparklines_controller.rb b/app/controllers/accountable_sparklines_controller.rb index 45f01a2cb..9ba69ff56 100644 --- a/app/controllers/accountable_sparklines_controller.rb +++ b/app/controllers/accountable_sparklines_controller.rb @@ -7,15 +7,7 @@ class AccountableSparklinesController < ApplicationController # Use HTTP conditional GET so the client receives 304 Not Modified when possible. if stale?(etag: etag_key, last_modified: family.latest_sync_completed_at) @series = Rails.cache.fetch(etag_key, expires_in: 24.hours) do - builder = Balance::ChartSeriesBuilder.new( - account_ids: account_ids, - currency: family.currency, - period: Period.last_30_days, - favorable_direction: @accountable.favorable_direction, - interval: "1 day" - ) - - builder.balance_series + build_series end render layout: false @@ -35,7 +27,37 @@ class AccountableSparklinesController < ApplicationController family.accounts.visible.where(accountable_type: accountable.name).pluck(:id) end + def accounts + @accounts ||= family.accounts.visible.where(accountable_type: accountable.name) + end + + def build_series + return aggregate_normalized_series if requires_normalized_aggregation? + + Balance::ChartSeriesBuilder.new( + account_ids: account_ids, + currency: family.currency, + period: Period.last_30_days, + favorable_direction: @accountable.favorable_direction, + interval: "1 day" + ).balance_series + end + + def requires_normalized_aggregation? + accounts.any? { |account| account.linked? && account.balance_type == :investment } + end + + def aggregate_normalized_series + Balance::LinkedInvestmentSeriesNormalizer.aggregate_accounts( + accounts: accounts, + currency: family.currency, + period: Period.last_30_days, + favorable_direction: @accountable.favorable_direction, + interval: "1 day" + ) + end + def cache_key - family.build_cache_key("#{@accountable.name}_sparkline", invalidate_on_data_updates: true) + family.build_cache_key("#{@accountable.name}_sparkline_#{Account::Chartable::SPARKLINE_CACHE_VERSION}", invalidate_on_data_updates: true) end end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 614683b0f..af46e970a 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -75,7 +75,7 @@ class AccountsController < ApplicationController end def sparkline - etag_key = @account.family.build_cache_key("#{@account.id}_sparkline", invalidate_on_data_updates: true) + 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. diff --git a/app/controllers/coinstats_items_controller.rb b/app/controllers/coinstats_items_controller.rb index 8459d458b..37c28860a 100644 --- a/app/controllers/coinstats_items_controller.rb +++ b/app/controllers/coinstats_items_controller.rb @@ -1,6 +1,6 @@ class CoinstatsItemsController < ApplicationController before_action :set_coinstats_item, only: [ :show, :edit, :update, :destroy, :sync ] - before_action :require_admin!, only: [ :new, :create, :edit, :update, :destroy, :sync, :link_wallet ] + before_action :require_admin!, only: [ :new, :create, :edit, :update, :destroy, :sync, :link_wallet, :link_exchange ] def index @coinstats_items = Current.family.coinstats_items.ordered @@ -13,6 +13,7 @@ class CoinstatsItemsController < ApplicationController @coinstats_item = Current.family.coinstats_items.build @coinstats_items = Current.family.coinstats_items.where.not(api_key: nil) @blockchains = fetch_blockchain_options(@coinstats_items.first) + @exchanges = fetch_exchange_options(@coinstats_items.first) end def create @@ -89,6 +90,52 @@ class CoinstatsItemsController < ApplicationController render_link_wallet_error(t(".error", message: e.message)) end + def link_exchange + coinstats_item_id = params[:coinstats_item_id].presence + @exchange_connection_id = params[:exchange_connection_id]&.to_s&.strip.presence + @exchange_connection_name = params[:exchange_connection_name]&.to_s&.strip.presence + + unless coinstats_item_id && @exchange_connection_id + return render_link_exchange_error(t(".missing_params")) + end + + @coinstats_item = Current.family.coinstats_items.find(coinstats_item_id) + exchange = find_exchange_option(@coinstats_item, @exchange_connection_id) + return render_link_exchange_error(t(".invalid_exchange")) unless exchange + + allowed_field_keys = Array(exchange[:connection_fields]).filter_map { |field| field[:key].presence&.to_s } + connection_fields_hash = extract_connection_fields_hash(params[:connection_fields]) + @exchange_connection_fields = connection_fields_hash + .slice(*allowed_field_keys) + .transform_values { |value| value.to_s.strip } + .compact_blank + @exchange_connection_name ||= exchange[:name].presence || @exchange_connection_id.to_s.titleize + + unless @exchange_connection_fields.present? + return render_link_exchange_error(t(".missing_params")) + end + + result = CoinstatsItem::ExchangeLinker.new( + @coinstats_item, + connection_id: @exchange_connection_id, + connection_fields: @exchange_connection_fields, + name: @exchange_connection_name + ).link + + if result.success? + redirect_to accounts_path, + notice: t(".success", name: @exchange_connection_name.presence || @exchange_connection_id.to_s.titleize), + status: :see_other + else + render_link_exchange_error(result.errors.join("; ").presence || t(".failed")) + end + rescue Provider::Coinstats::Error => e + render_link_exchange_error(t(".error", message: e.message)) + rescue => e + Rails.logger.error("CoinStats link exchange error: #{e.class} - #{e.message}") + render_link_exchange_error(t(".failed")) + end + private def set_coinstats_item @@ -149,11 +196,23 @@ class CoinstatsItemsController < ApplicationController def render_link_wallet_error(error_message) @error_message = error_message - @coinstats_items = Current.family.coinstats_items.where.not(api_key: nil) - @blockchains = fetch_blockchain_options(@coinstats_items.first) + prepare_link_form_state render :new, status: :unprocessable_entity end + def render_link_exchange_error(error_message) + @error_message = error_message + prepare_link_form_state + render :new, status: :unprocessable_entity + end + + def prepare_link_form_state + @coinstats_items = Current.family.coinstats_items.where.not(api_key: nil) + selected_item = @coinstats_items.first + @blockchains = fetch_blockchain_options(selected_item) + @exchanges = fetch_exchange_options(selected_item) + end + def fetch_blockchain_options(coinstats_item) return [] unless coinstats_item&.api_key.present? @@ -167,4 +226,31 @@ class CoinstatsItemsController < ApplicationController flash.now[:alert] = t("coinstats_items.new.blockchain_fetch_error") [] end + + def fetch_exchange_options(coinstats_item) + return [] unless coinstats_item&.api_key.present? + + @exchange_options_by_item ||= {} + @exchange_options_by_item[coinstats_item.id] ||= Provider::Coinstats.new(coinstats_item.api_key).exchange_options + rescue Provider::Coinstats::Error => e + Rails.logger.error("CoinStats exchange fetch failed: item_id=#{coinstats_item.id} error=#{e.class} message=#{e.message}") + [] + rescue StandardError => e + Rails.logger.error("CoinStats exchange fetch failed: item_id=#{coinstats_item.id} error=#{e.class} message=#{e.message}") + [] + end + + def extract_connection_fields_hash(connection_fields_param) + if connection_fields_param.respond_to?(:to_unsafe_h) + connection_fields_param.to_unsafe_h + elsif connection_fields_param.respond_to?(:to_h) + connection_fields_param.to_h + else + {} + end + end + + def find_exchange_option(coinstats_item, connection_id) + fetch_exchange_options(coinstats_item).find { |exchange| exchange[:connection_id] == connection_id } + end end diff --git a/app/javascript/controllers/coinstats_exchange_fields_controller.js b/app/javascript/controllers/coinstats_exchange_fields_controller.js new file mode 100644 index 000000000..bdfe45193 --- /dev/null +++ b/app/javascript/controllers/coinstats_exchange_fields_controller.js @@ -0,0 +1,63 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["select", "fields", "connectionName"] + static values = { + exchanges: Array, + initialConnectionId: String, + initialFields: Object + } + + connect() { + if (this.hasSelectTarget && this.initialConnectionIdValue && !this.selectTarget.value) { + this.selectTarget.value = this.initialConnectionIdValue + } + + this.render() + } + + render() { + if (!this.hasFieldsTarget || !this.hasSelectTarget) return + + const exchange = this.exchangesValue.find((entry) => entry.connection_id === this.selectTarget.value) + this.fieldsTarget.innerHTML = "" + + if (!exchange) { + if (this.hasConnectionNameTarget) this.connectionNameTarget.value = "" + return + } + + if (this.hasConnectionNameTarget) { + this.connectionNameTarget.value = exchange.name || "" + } + + const connectionFields = Array.isArray(exchange.connection_fields) ? exchange.connection_fields : [] + + connectionFields.forEach((field) => { + const wrapper = document.createElement("div") + wrapper.className = "space-y-1" + + const label = document.createElement("label") + label.className = "block text-sm font-medium text-primary" + label.setAttribute("for", `coinstats_exchange_${field.key}`) + label.textContent = field.name + + const input = document.createElement("input") + input.id = `coinstats_exchange_${field.key}` + input.name = `connection_fields[${field.key}]` + input.type = this.inputTypeFor(field.key) + input.autocomplete = "off" + input.className = "block w-full rounded-md border border-primary px-3 py-2 text-sm bg-container-inset text-primary placeholder:text-secondary focus:border-primary focus:ring-0" + input.placeholder = field.name + input.value = this.initialFieldsValue?.[field.key] || "" + + wrapper.appendChild(label) + wrapper.appendChild(input) + this.fieldsTarget.appendChild(wrapper) + }) + } + + inputTypeFor(key) { + return /secret|password|token|passphrase|private/i.test(key) ? "password" : "text" + } +} diff --git a/app/models/account.rb b/app/models/account.rb index 4a98b6f87..2713ad89d 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -318,6 +318,10 @@ class Account < ApplicationRecord .order(amount: :desc) end + def latest_provider_holdings_snapshot_date + holdings.where.not(account_provider_id: nil).maximum(:date) + end + def start_date first_entry_date = entries.minimum(:date) || Date.current first_entry_date - 1.day diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index b4f2645e5..59bc85c2a 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -1,5 +1,6 @@ module Account::Chartable extend ActiveSupport::Concern + SPARKLINE_CACHE_VERSION = "v4" def favorable_direction classification == "asset" ? "up" : "down" @@ -20,14 +21,19 @@ module Account::Chartable interval: interval )) - builder.send("#{view}_series") + normalize_linked_investment_series(builder.send("#{view}_series")) end def sparkline_series - cache_key = family.build_cache_key("#{id}_sparkline", invalidate_on_data_updates: true) + cache_key = family.build_cache_key("#{id}_sparkline_#{SPARKLINE_CACHE_VERSION}", invalidate_on_data_updates: true) Rails.cache.fetch(cache_key, expires_in: 24.hours) do balance_series end end + + private + def normalize_linked_investment_series(series) + Balance::LinkedInvestmentSeriesNormalizer.new(account: self, series: series).normalize + end end diff --git a/app/models/account/market_data_importer.rb b/app/models/account/market_data_importer.rb index d00c22347..169435b34 100644 --- a/app/models/account/market_data_importer.rb +++ b/app/models/account/market_data_importer.rb @@ -51,7 +51,7 @@ class Account::MarketDataImporter def import_security_prices return unless Security.provider - account_securities = account.trades.map(&:security).uniq + account_securities = (account.trades.map(&:security) + account.current_holdings.map(&:security)).uniq return if account_securities.empty? @@ -68,10 +68,17 @@ class Account::MarketDataImporter private # Calculates the first date we require a price for the given security scoped to this account def first_required_price_date(security) - account.trades.with_entry - .where(security: security) - .where(entries: { account_id: account.id }) - .minimum("entries.date") + trade_start_date = account.trades.with_entry + .where(security: security) + .where(entries: { account_id: account.id }) + .minimum("entries.date") + + holding_start_date = + if account.holdings.where(security: security).where.not(account_provider_id: nil).exists? + account.start_date + end + + [ trade_start_date, holding_start_date ].compact.min end def needs_exchange_rates? diff --git a/app/models/balance/linked_investment_series_normalizer.rb b/app/models/balance/linked_investment_series_normalizer.rb new file mode 100644 index 000000000..10b7aee36 --- /dev/null +++ b/app/models/balance/linked_investment_series_normalizer.rb @@ -0,0 +1,126 @@ +class Balance::LinkedInvestmentSeriesNormalizer + attr_reader :account, :series + + class << self + def aggregate_accounts(accounts:, currency:, period:, favorable_direction:, interval: "1 day") + accounts = Array(accounts) + account_ids = accounts.map(&:id) + + series = Balance::ChartSeriesBuilder.new( + account_ids: account_ids, + currency: currency, + period: period, + favorable_direction: favorable_direction, + interval: interval + ).balance_series + + common_start_date = common_supported_history_start_date(account_ids) + return series unless common_start_date.present? + + trimmed_values = series.values.select { |value| value.date >= common_start_date } + return series if trimmed_values.blank? || trimmed_values.length == series.values.length + + Series.new( + start_date: trimmed_values.first.date, + end_date: series.end_date, + interval: series.interval, + values: trimmed_values, + favorable_direction: series.favorable_direction + ) + end + + private + def common_supported_history_start_date(account_ids) + account_ids = Array(account_ids).compact + return if account_ids.empty? + + activity_dates = Entry.where(account_id: account_ids) + .where.not(source: nil) + .where.not(entryable_type: "Valuation") + .group(:account_id) + .minimum(:date) + + stable_holding_dates = stable_provider_holding_start_dates(account_ids) + + account_ids.filter_map do |account_id| + [ activity_dates[account_id], stable_holding_dates[account_id] ].compact.min + end.max + end + + def stable_provider_holding_start_dates(account_ids) + rows = Holding.where(account_id: account_ids) + .where.not(account_provider_id: nil) + .group(:account_id, :date) + .order(account_id: :asc, date: :desc) + .pluck(:account_id, :date, Arel.sql("array_agg(security_id ORDER BY security_id)")) + + rows.group_by(&:first).transform_values do |account_rows| + _account_id, latest_snapshot_date, latest_security_ids = account_rows.first + next unless latest_snapshot_date.present? + next latest_snapshot_date if latest_security_ids.blank? + + stable_dates = account_rows + .take_while { |_id, _date, security_ids| security_ids == latest_security_ids } + .map { |_id, date, _security_ids| date } + + stable_dates.last || latest_snapshot_date + end + end + end + + def initialize(account:, series:) + @account = account + @series = series + end + + def normalize + return series unless account.linked? && account.balance_type == :investment + + first_supported_history_date = supported_history_start_date + return series unless first_supported_history_date.present? + + trimmed_values = series.values.select { |value| value.date >= first_supported_history_date } + return series if trimmed_values.blank? || trimmed_values.length == series.values.length + + Series.new( + start_date: trimmed_values.first.date, + end_date: series.end_date, + interval: series.interval, + values: trimmed_values, + favorable_direction: series.favorable_direction + ) + end + + private + + def supported_history_start_date + [ first_provider_activity_date, stable_provider_holding_start_date ].compact.min + end + + def first_provider_activity_date + @first_provider_activity_date ||= account.entries + .where.not(source: nil) + .where.not(entryable_type: "Valuation") + .minimum(:date) + end + + def provider_holdings_scope + @provider_holdings_scope ||= account.holdings.where.not(account_provider_id: nil) + end + + def stable_provider_holding_start_date + date_security_pairs = provider_holdings_scope + .group(:date) + .order(date: :desc) + .pluck(:date, Arel.sql("array_agg(security_id ORDER BY security_id)")) + latest_snapshot_date, latest_security_ids = date_security_pairs.first + return unless latest_snapshot_date.present? + return latest_snapshot_date if latest_security_ids.blank? + + stable_dates = date_security_pairs + .take_while { |_date, security_ids| security_ids == latest_security_ids } + .map(&:first) + + stable_dates.last || latest_snapshot_date + end +end diff --git a/app/models/balance/series_aggregator.rb b/app/models/balance/series_aggregator.rb new file mode 100644 index 000000000..fdf4d9dad --- /dev/null +++ b/app/models/balance/series_aggregator.rb @@ -0,0 +1,86 @@ +class Balance::SeriesAggregator + attr_reader :series_list, :favorable_direction, :currency, :align_to_common_start + + def initialize(series_list:, currency:, favorable_direction:, align_to_common_start: false) + @series_list = Array(series_list).compact + @currency = currency + @favorable_direction = favorable_direction + @align_to_common_start = align_to_common_start + end + + def aggregate + return empty_series if normalized_series_list.empty? + + values_by_date = normalized_series_list.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |series, hash| + series.values.each do |value| + hash[value.date] << value + end + end + + dates = values_by_date.keys.sort + return empty_series if dates.empty? + + previous_value = nil + values = dates.map do |date| + current_value = Money.new( + values_by_date[date].sum { |value| value.value.amount }, + currency + ) + + series_value = Series::Value.new( + date: date, + date_formatted: I18n.l(date, format: :long), + value: current_value, + trend: Trend.new( + current: current_value, + previous: previous_value, + favorable_direction: favorable_direction + ) + ) + + previous_value = current_value + series_value + end + + Series.new( + start_date: values.first.date, + end_date: values.last.date, + interval: normalized_series_list.first.interval, + values: values, + favorable_direction: favorable_direction + ) + end + + private + def normalized_series_list + @normalized_series_list ||= begin + return series_list unless align_to_common_start + + common_start_date = series_list.map(&:start_date).compact.max + return series_list if common_start_date.blank? + + series_list.filter_map do |series| + trimmed_values = series.values.select { |value| value.date >= common_start_date } + next if trimmed_values.blank? + + Series.new( + start_date: trimmed_values.first.date, + end_date: trimmed_values.last.date, + interval: series.interval, + values: trimmed_values, + favorable_direction: series.favorable_direction + ) + end + end + end + + def empty_series + Series.new( + start_date: Date.current, + end_date: Date.current, + interval: "1 day", + values: [], + favorable_direction: favorable_direction + ) + end +end diff --git a/app/models/coinstats_account.rb b/app/models/coinstats_account.rb index 86231321f..4868df014 100644 --- a/app/models/coinstats_account.rb +++ b/app/models/coinstats_account.rb @@ -1,7 +1,10 @@ -# Represents a single crypto token/coin within a CoinStats wallet. -# Each wallet address may have multiple CoinstatsAccounts (one per token). +# Represents a CoinStats-backed synced account. +# This may be a wallet-scoped asset row or a consolidated exchange portfolio. class CoinstatsAccount < ApplicationRecord include CurrencyNormalizable, Encryptable + include CoinstatsAccount::ValueHelpers + include CoinstatsAccount::SourceClassification + include CoinstatsAccount::BalanceInference # Encrypt raw payloads if ActiveRecord encryption is configured if encryption_ready? @@ -24,13 +27,11 @@ class CoinstatsAccount < ApplicationRecord # Updates account with latest balance data from CoinStats API. # @param account_snapshot [Hash] Normalized balance data from API def upsert_coinstats_snapshot!(account_snapshot) - # Convert to symbol keys or handle both string and symbol keys snapshot = account_snapshot.with_indifferent_access - # Build attributes to update attrs = { - current_balance: snapshot[:balance] || snapshot[:current_balance], - currency: parse_currency(snapshot[:currency]) || "USD", + current_balance: snapshot[:balance] || snapshot[:current_balance] || inferred_current_balance(snapshot), + currency: inferred_currency(snapshot) || parse_currency(snapshot[:currency]) || "USD", name: snapshot[:name], account_status: snapshot[:status], provider: snapshot[:provider], @@ -40,10 +41,7 @@ class CoinstatsAccount < ApplicationRecord raw_payload: account_snapshot } - # Only set account_id if provided and not already set (preserves ID from initial creation) - if snapshot[:id].present? && account_id.blank? - attrs[:account_id] = snapshot[:id].to_s - end + attrs[:account_id] = snapshot[:id].to_s if snapshot[:id].present? && account_id.blank? update!(attrs) end @@ -51,8 +49,6 @@ class CoinstatsAccount < ApplicationRecord # Stores transaction data from CoinStats API for later processing. # @param transactions_snapshot [Hash, Array] Raw transactions response or array def upsert_coinstats_transactions_snapshot!(transactions_snapshot) - # CoinStats API returns: { meta: { page, limit }, result: [...] } - # Extract just the result array for storage, or use directly if already an array transactions_array = if transactions_snapshot.is_a?(Hash) snapshot = transactions_snapshot.with_indifferent_access snapshot[:result] || [] @@ -62,16 +58,7 @@ class CoinstatsAccount < ApplicationRecord [] end - assign_attributes( - raw_transactions_payload: transactions_array - ) - + assign_attributes(raw_transactions_payload: transactions_array) save! end - - private - - def log_invalid_currency(currency_value) - Rails.logger.warn("Invalid currency code '#{currency_value}' for CoinstatsAccount #{id}, defaulting to USD") - end end diff --git a/app/models/coinstats_account/balance_inference.rb b/app/models/coinstats_account/balance_inference.rb new file mode 100644 index 000000000..ab9ccf523 --- /dev/null +++ b/app/models/coinstats_account/balance_inference.rb @@ -0,0 +1,138 @@ +module CoinstatsAccount::BalanceInference + extend ActiveSupport::Concern + + def inferred_currency(payload = raw_payload) + payload = payload.to_h.with_indifferent_access + + if exchange_portfolio_source_for?(payload) + preferred_exchange_currency + elsif exchange_source_for?(payload) + if fiat_asset?(payload) + parse_currency(asset_metadata(payload)[:symbol]) || + parse_currency(payload[:currency]) || + family_currency || + "USD" + else + preferred_exchange_currency + end + elsif fiat_asset?(payload) + parse_currency(asset_metadata(payload)[:symbol]) || parse_currency(payload[:currency]) || "USD" + else + parse_currency(payload[:currency]) || "USD" + end + end + + def inferred_current_balance(payload = raw_payload) + payload = payload.to_h.with_indifferent_access + + if exchange_portfolio_source_for?(payload) + portfolio_total_value(payload) + elsif fiat_asset?(payload) + asset_quantity(payload).abs + elsif exchange_source_for?(payload) + asset_quantity(payload).abs * asset_price(payload) + else + explicit_balance = payload[:balance] || payload[:current_balance] + return parse_decimal(explicit_balance) if explicit_balance.present? + + asset_quantity(payload).abs * asset_price(payload) + end + end + + def inferred_cash_balance + return portfolio_cash_value if exchange_portfolio_account? + + fiat_asset? ? inferred_current_balance : 0.to_d + end + + def asset_symbol(payload = raw_payload) + asset_metadata(payload)[:symbol].presence || account_id.to_s.upcase + end + + def asset_name(payload = raw_payload) + asset_metadata(payload)[:name].presence || name + end + + def asset_quantity(payload = raw_payload) + payload = payload.to_h.with_indifferent_access + raw_quantity = payload[:count] || payload[:amount] || payload[:balance] || payload[:current_balance] + parse_decimal(raw_quantity) + end + + def asset_price(payload = raw_payload, currency: inferred_currency(payload)) + payload = payload.to_h.with_indifferent_access + price_data = payload[:price] + target_currency = parse_currency(currency) || currency || "USD" + + raw_price = + if price_data.is_a?(Hash) + prices = price_data.with_indifferent_access + prices[target_currency] || + prices[target_currency.to_s] || + converted_usd_amount(prices[:USD] || prices["USD"], target_currency) + else + price_data || payload[:priceUsd] + end + + parse_decimal(raw_price) + end + + def average_buy_price(payload = raw_payload, currency: inferred_currency(payload)) + payload = payload.to_h.with_indifferent_access + average_buy = payload[:averageBuy] + return nil if average_buy.blank? + + average_buy_hash = average_buy.to_h.with_indifferent_access + nested_all_time = average_buy_hash[:allTime].to_h.with_indifferent_access + target_currency = parse_currency(currency) || currency || "USD" + + raw_cost_basis = + average_buy_hash[target_currency] || + average_buy_hash[target_currency.to_s] || + nested_all_time[target_currency] || + nested_all_time[target_currency.to_s] || + converted_usd_amount( + average_buy_hash[:USD] || average_buy_hash["USD"] || + nested_all_time[:USD] || nested_all_time["USD"], + target_currency + ) + return nil if raw_cost_basis.blank? + + parse_decimal(raw_cost_basis) + end + + def portfolio_coins(payload = raw_payload) + payload = payload.to_h.with_indifferent_access + Array(payload[:coins]).map { |coin| coin.with_indifferent_access } + end + + def portfolio_fiat_coins(payload = raw_payload) + portfolio_coins(payload).select { |coin| fiat_asset?(coin) } + end + + def portfolio_non_fiat_coins(payload = raw_payload) + portfolio_coins(payload).reject { |coin| fiat_asset?(coin) } + end + + def portfolio_total_value(payload = raw_payload, currency: inferred_currency(payload)) + portfolio_coins(payload).sum { |coin| current_value_for_coin(coin, currency: currency) } + end + + def portfolio_cash_value(payload = raw_payload, currency: inferred_currency(payload)) + portfolio_fiat_coins(payload).sum { |coin| current_value_for_coin(coin, currency: currency) } + end + + def current_value_for_coin(coin_payload, currency: inferred_currency(coin_payload)) + coin_payload = coin_payload.to_h.with_indifferent_access + + explicit_value = coin_payload[:currentValue] || coin_payload[:current_value] || coin_payload[:totalWorth] + if explicit_value.present? + return extract_currency_amount(explicit_value, currency) if explicit_value.is_a?(Hash) + return exchange_scalar_value(explicit_value, coin_payload, currency: currency) if exchange_value_payload?(coin_payload) + + return parse_decimal(explicit_value) + end + + asset_quantity(coin_payload).abs * asset_price(coin_payload, currency: currency) + end +end diff --git a/app/models/coinstats_account/holdings_processor.rb b/app/models/coinstats_account/holdings_processor.rb new file mode 100644 index 000000000..c5e281765 --- /dev/null +++ b/app/models/coinstats_account/holdings_processor.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +class CoinstatsAccount::HoldingsProcessor + def initialize(coinstats_account) + @coinstats_account = coinstats_account + end + + def process + return unless account&.crypto? + + coinstats_account.exchange_portfolio_account? ? process_exchange_portfolio_holdings : process_single_asset_holding + end + + private + attr_reader :coinstats_account + + def account + coinstats_account.current_account + end + + def account_provider + coinstats_account.account_provider + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def process_single_asset_holding + return if coinstats_account.fiat_asset? + + quantity = coinstats_account.asset_quantity + return if quantity.zero? + + security = resolve_security(coinstats_account.asset_symbol, coinstats_account.asset_name) + return unless security + + import_adapter.import_holding( + security: security, + quantity: quantity.abs, + amount: coinstats_account.inferred_current_balance, + currency: coinstats_account.inferred_currency, + date: holding_date, + price: coinstats_account.asset_price, + cost_basis: coinstats_account.average_buy_price, + external_id: single_asset_external_id, + account_provider_id: account_provider&.id, + source: "coinstats", + delete_future_holdings: false + ) + end + + def process_exchange_portfolio_holdings + return if account_provider.blank? + + active_coins = coinstats_account.portfolio_non_fiat_coins.reject { |coin| coinstats_account.asset_quantity(coin).zero? } + target_currency = coinstats_account.inferred_currency + cleanup_stale_holdings!(active_coins.map { |coin| portfolio_external_id(coin) }) + + active_coins.each do |coin| + security = resolve_security(asset_symbol(coin), asset_name(coin)) + next unless security + + quantity = coinstats_account.asset_quantity(coin).abs + next if quantity.zero? + + import_adapter.import_holding( + security: security, + quantity: quantity, + amount: coinstats_account.current_value_for_coin(coin, currency: target_currency), + currency: target_currency, + date: holding_date, + price: coinstats_account.asset_price(coin, currency: target_currency), + cost_basis: coinstats_account.average_buy_price(coin, currency: target_currency), + external_id: portfolio_external_id(coin), + account_provider_id: account_provider.id, + source: "coinstats", + delete_future_holdings: false + ) + end + end + + def cleanup_stale_holdings!(external_ids) + scope = account.holdings.where(account_provider_id: account_provider.id, date: holding_date) + + if external_ids.any? + scope.where.not(external_id: external_ids).delete_all + else + scope.delete_all + end + end + + def resolve_security(symbol, name) + return if symbol.blank? + + ticker = symbol.start_with?("CRYPTO:") ? symbol : "CRYPTO:#{symbol}" + security = Security::Resolver.new(ticker).resolve + return unless security + + updates = {} + updates[:name] = name if security.name.blank? && name.present? + updates[:offline] = true if security.respond_to?(:offline=) && security.offline != true + security.update!(updates) if updates.any? + security + rescue => e + Rails.logger.warn("CoinstatsAccount::HoldingsProcessor - Failed to resolve #{symbol}: #{e.class} - #{e.message}") + nil + end + + def asset_symbol(payload) + coinstats_account.asset_symbol(payload) + end + + def asset_name(payload) + coinstats_account.asset_name(payload) + end + + def single_asset_external_id + "coinstats_holding_#{coinstats_account.account_id}_#{holding_date}" + end + + def portfolio_external_id(coin_payload) + coin_payload = coin_payload.to_h.with_indifferent_access + identifier = coin_payload.dig(:coin, :identifier).presence || + coin_payload.dig(:coin, :symbol).presence || + coin_payload[:coinId].presence || + coin_payload[:symbol].presence + + "coinstats_holding_#{coinstats_account.account_id}_#{identifier}_#{holding_date}" + end + + def holding_date + Date.current + end +end diff --git a/app/models/coinstats_account/processor.rb b/app/models/coinstats_account/processor.rb index 70844d67f..c60716315 100644 --- a/app/models/coinstats_account/processor.rb +++ b/app/models/coinstats_account/processor.rb @@ -20,6 +20,13 @@ class CoinstatsAccount::Processor Rails.logger.info "CoinstatsAccount::Processor - Processing coinstats_account #{coinstats_account.id}" + begin + process_holdings + rescue StandardError => e + Rails.logger.error "CoinstatsAccount::Processor - Failed to process holdings for #{coinstats_account.id}: #{e.message}" + report_exception(e, "holdings") + end + begin process_account! rescue StandardError => e @@ -34,17 +41,24 @@ class CoinstatsAccount::Processor private + def process_holdings + CoinstatsAccount::HoldingsProcessor.new(coinstats_account).process + end + # Updates the linked Account with current balance from CoinStats. def process_account! account = coinstats_account.current_account balance = coinstats_account.current_balance || 0 currency = parse_currency(coinstats_account.currency) || account.currency || "USD" + cash_balance = coinstats_account.inferred_cash_balance account.update!( balance: balance, - cash_balance: balance, + cash_balance: cash_balance, currency: currency ) + + account.set_current_balance(balance) end # Delegates transaction processing to the specialized processor. diff --git a/app/models/coinstats_account/source_classification.rb b/app/models/coinstats_account/source_classification.rb new file mode 100644 index 000000000..97f867d4a --- /dev/null +++ b/app/models/coinstats_account/source_classification.rb @@ -0,0 +1,55 @@ +module CoinstatsAccount::SourceClassification + extend ActiveSupport::Concern + + def wallet_source? + payload = raw_payload.to_h.with_indifferent_access + payload[:source] == "wallet" || (payload[:address].present? && payload[:blockchain].present?) + end + + def exchange_source? + exchange_source_for?(raw_payload) + end + + def exchange_portfolio_account? + payload = raw_payload.to_h.with_indifferent_access + exchange_source_for?(payload) && ( + ActiveModel::Type::Boolean.new.cast(payload[:portfolio_account]) || + payload[:coins].is_a?(Array) + ) + end + + def legacy_exchange_asset_account? + exchange_source? && !exchange_portfolio_account? + end + + def fiat_asset?(payload = raw_payload) + payload = payload.to_h.with_indifferent_access + return false if exchange_portfolio_source_for?(payload) + + metadata = asset_metadata(payload) + + ActiveModel::Type::Boolean.new.cast(metadata[:isFiat]) || + ActiveModel::Type::Boolean.new.cast(payload[:isFiat]) || + fiat_identifier?(metadata[:identifier]) || + fiat_identifier?(payload[:coinId]) || + fiat_identifier?(account_id) + end + + def crypto_asset? + !fiat_asset? + end + + private + def exchange_source_for?(payload) + payload = payload.to_h.with_indifferent_access + payload[:source] == "exchange" || payload[:portfolio_id].present? + end + + def exchange_portfolio_source_for?(payload) + payload = payload.to_h.with_indifferent_access + exchange_source_for?(payload) && ( + ActiveModel::Type::Boolean.new.cast(payload[:portfolio_account]) || + payload[:coins].is_a?(Array) + ) + end +end diff --git a/app/models/coinstats_account/transactions/processor.rb b/app/models/coinstats_account/transactions/processor.rb index f5ee468f6..8cd603536 100644 --- a/app/models/coinstats_account/transactions/processor.rb +++ b/app/models/coinstats_account/transactions/processor.rb @@ -87,6 +87,7 @@ class CoinstatsAccount::Transactions::Processor # @return [Array] Transactions matching this account's token def filter_transactions_for_account(transactions) return [] unless transactions.present? + return transactions if coinstats_account.exchange_portfolio_account? return transactions unless coinstats_account.account_id.present? account_id = coinstats_account.account_id.to_s.downcase @@ -94,22 +95,33 @@ class CoinstatsAccount::Transactions::Processor transactions.select do |tx| tx = tx.with_indifferent_access - # Check coin ID in transactions[0].items[0].coin.id (most common location) - coin_id = tx.dig(:transactions, 0, :items, 0, :coin, :id)&.to_s&.downcase - - # Also check coinData for symbol match as fallback + coin_identifier = tx.dig(:coinData, :identifier)&.to_s&.downcase coin_symbol = tx.dig(:coinData, :symbol)&.to_s&.downcase + nested_coin_matches = transaction_items(tx).any? do |item| + coin = item[:coin].to_h.with_indifferent_access + coin[:id]&.to_s&.downcase == account_id || + coin[:identifier]&.to_s&.downcase == account_id || + coin[:symbol]&.to_s&.downcase == account_id + end # Match if coin ID equals account_id, or if symbol matches account name precisely. # We use strict matching to avoid false positives (e.g., "ETH" should not match # "Ethereum Classic" which has symbol "ETC"). The symbol must appear as: # - A whole word (bounded by word boundaries), OR # - Inside parentheses like "(ETH)" which is common in wallet naming conventions - coin_id == account_id || + nested_coin_matches || + coin_identifier == account_id || (coin_symbol.present? && symbol_matches_name?(coin_symbol, coinstats_account.name)) end end + def transaction_items(transaction) + tx = transaction.with_indifferent_access + + Array(tx[:transactions]).flat_map { |entry| Array(entry.with_indifferent_access[:items]) } + + Array(tx[:transfers]).flat_map { |entry| Array(entry.with_indifferent_access[:items]) } + end + # Checks if a coin symbol matches the account name using strict matching. # Avoids false positives from partial substring matches (e.g., "ETH" matching # "Ethereum Classic (0x123...)" which should only match "ETC"). diff --git a/app/models/coinstats_account/value_helpers.rb b/app/models/coinstats_account/value_helpers.rb new file mode 100644 index 000000000..5e8f7164e --- /dev/null +++ b/app/models/coinstats_account/value_helpers.rb @@ -0,0 +1,88 @@ +module CoinstatsAccount::ValueHelpers + extend ActiveSupport::Concern + + private + def family_currency + parse_currency(coinstats_item&.family&.currency) + end + + def preferred_exchange_currency + family_currency.presence || "USD" + end + + def exchange_rate_available?(from:, to:) + return true if from == to + + ExchangeRate.find_or_fetch_rate(from: from, to: to, date: Date.current).present? + rescue StandardError => e + Rails.logger.warn("CoinstatsAccount #{id} - Failed to load FX #{from}/#{to}: #{e.class} - #{e.message}") + false + end + + def converted_usd_amount(raw_usd_amount, target_currency) + return raw_usd_amount if raw_usd_amount.blank? + return raw_usd_amount if target_currency == "USD" + + usd_amount = parse_decimal(raw_usd_amount) + return if usd_amount.zero? && raw_usd_amount.to_s != "0" + + return unless exchange_rate_available?(from: "USD", to: target_currency) + + Money.new(usd_amount, "USD").exchange_to(target_currency).amount + rescue StandardError => e + Rails.logger.warn("CoinstatsAccount #{id} - Failed to convert USD -> #{target_currency}: #{e.class} - #{e.message}") + nil + end + + def asset_metadata(payload) + payload = payload.to_h.with_indifferent_access + metadata = payload[:coin] + metadata.is_a?(Hash) ? metadata.with_indifferent_access : payload + end + + def extract_currency_amount(value, currency) + return parse_decimal(value) unless value.is_a?(Hash) + + values = value.with_indifferent_access + target_currency = parse_currency(currency) || currency || "USD" + + parse_decimal( + values[target_currency] || + values[target_currency.to_s] || + converted_usd_amount(values[:USD] || values["USD"], target_currency) + ) + end + + def exchange_value_payload?(payload) + exchange_source_for?(payload) || exchange_portfolio_source_for?(payload) + end + + def exchange_scalar_value(explicit_value, coin_payload, currency:) + target_currency = parse_currency(currency) || currency || "USD" + return parse_decimal(explicit_value) if target_currency == "USD" + + price_based_value = asset_quantity(coin_payload).abs * asset_price(coin_payload, currency: target_currency) + return price_based_value if price_based_value.positive? + + converted_value = converted_usd_amount(explicit_value, target_currency) + return parse_decimal(converted_value) if converted_value.present? + + parse_decimal(explicit_value) + end + + def fiat_identifier?(value) + value.to_s.start_with?("FiatCoin") + end + + def parse_decimal(value) + return 0.to_d if value.blank? + + BigDecimal(value.to_s) + rescue ArgumentError + 0.to_d + end + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for CoinstatsAccount #{id}, defaulting to USD") + end +end diff --git a/app/models/coinstats_entry/processor.rb b/app/models/coinstats_entry/processor.rb index 6b966f60b..ce1731b4c 100644 --- a/app/models/coinstats_entry/processor.rb +++ b/app/models/coinstats_entry/processor.rb @@ -14,6 +14,8 @@ class CoinstatsEntry::Processor include CoinstatsTransactionIdentifiable + EXCHANGE_TRADE_TYPES = %w[buy sell swap trade convert fill].freeze + # @param coinstats_transaction [Hash] Raw transaction data from API # @param coinstats_account [CoinstatsAccount] Parent account for context def initialize(coinstats_transaction, coinstats_account:) @@ -31,17 +33,39 @@ class CoinstatsEntry::Processor return nil end - import_adapter.import_transaction( - external_id: external_id, - amount: amount, - currency: currency, - date: date, - name: name, - source: "coinstats", - merchant: merchant, - notes: notes, - extra: extra_metadata - ) + if exchange_trade? && trade_security.present? + return legacy_transaction_entry if skip_legacy_transaction_migration? + + Account.transaction do + remove_legacy_transaction_entry! + + import_adapter.import_trade( + external_id: external_id, + security: trade_security, + quantity: trade_quantity, + price: trade_price, + amount: trade_amount, + currency: currency, + date: date, + name: name, + source: "coinstats", + activity_label: trade_activity_label + ) + end + else + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: name, + source: "coinstats", + merchant: merchant, + notes: notes, + extra: extra_metadata, + investment_activity_label: transaction_activity_label + ) + end rescue ArgumentError => e Rails.logger.error "CoinstatsEntry::Processor - Validation error for transaction #{external_id rescue 'unknown'}: #{e.message}" raise @@ -76,6 +100,15 @@ class CoinstatsEntry::Processor cs["count"] = coin_data[:count] if coin_data[:count].present? end + if matched_item.present? + cs["matched_item"] = { + "count" => matched_item[:count], + "total_worth" => matched_item[:totalWorth], + "coin_id" => matched_item.dig(:coin, :id), + "coin_symbol" => matched_item.dig(:coin, :symbol) + }.compact + end + # Store profit/loss info if profit_loss.present? cs["profit"] = profit_loss[:profit] if profit_loss[:profit].present? @@ -86,7 +119,10 @@ class CoinstatsEntry::Processor if fee_data.present? cs["fee_amount"] = fee_data[:count] if fee_data[:count].present? cs["fee_symbol"] = fee_data.dig(:coin, :symbol) if fee_data.dig(:coin, :symbol).present? - cs["fee_usd"] = fee_data[:totalWorth] if fee_data[:totalWorth].present? + if fee_data[:totalWorth].present? + cs["fee_value"] = fee_data[:totalWorth] + cs["fee_usd"] = fee_data[:totalWorth] + end end return nil if cs.empty? @@ -103,7 +139,7 @@ class CoinstatsEntry::Processor def data @data ||= coinstats_transaction.with_indifferent_access - end + end # Helper accessors for nested data structures def hash_data @@ -127,7 +163,7 @@ class CoinstatsEntry::Processor end def transaction_type - data[:type] + data[:type] || data[:transactionType] end def external_id @@ -138,7 +174,7 @@ class CoinstatsEntry::Processor def name tx_type = transaction_type || "Transaction" - symbol = coin_data[:symbol] + symbol = matched_symbol || coin_data[:symbol] # Get coin name from nested transaction items if available (used as fallback) coin_name = transactions_data.dig(0, :items, 0, :coin, :name) @@ -153,14 +189,33 @@ class CoinstatsEntry::Processor end def amount - # Use currentValue from coinData (USD value) or profitLoss - usd_value = coin_data[:currentValue] || profit_loss[:currentValue] || 0 + if portfolio_exchange_account? + absolute_amount = matched_item_total_worth.abs.nonzero? || + coin_data[:currentValue]&.to_d&.abs&.nonzero? || + profit_loss[:currentValue]&.to_d&.abs&.nonzero? || + 0.to_d - parsed_amount = case usd_value + return portfolio_outflow? ? absolute_amount : -absolute_amount + end + + if coinstats_account.exchange_source? && coinstats_account.fiat_asset? + fiat_value = matched_item_total_worth.abs + absolute_amount = fiat_value.positive? ? fiat_value : coin_data[:count].to_d.abs + return outgoing_transaction_type? ? absolute_amount : -absolute_amount + end + + raw_value = + if coinstats_account.exchange_source? + matched_item_total_worth.nonzero? || coin_data[:currentValue] || profit_loss[:currentValue] || 0 + else + coin_data[:currentValue] || profit_loss[:currentValue] || 0 + end + + parsed_amount = case raw_value when String - BigDecimal(usd_value) + BigDecimal(raw_value) when Numeric - BigDecimal(usd_value.to_s) + BigDecimal(raw_value.to_s) else BigDecimal("0") end @@ -179,7 +234,7 @@ class CoinstatsEntry::Processor -absolute_amount end rescue ArgumentError => e - Rails.logger.error "Failed to parse CoinStats transaction amount: #{usd_value.inspect} - #{e.message}" + Rails.logger.error "Failed to parse CoinStats transaction amount: #{data.inspect} - #{e.message}" raise end @@ -189,8 +244,7 @@ class CoinstatsEntry::Processor end def currency - # CoinStats values are always in USD - "USD" + account.currency || coinstats_account.currency || "USD" end def date @@ -242,8 +296,8 @@ class CoinstatsEntry::Processor parts = [] # Include coin/token details with count - symbol = coin_data[:symbol] - count = coin_data[:count] + symbol = matched_symbol || coin_data[:symbol] + count = trade_item_count.nonzero? || coin_data[:count] if count.present? && symbol.present? parts << "#{count} #{symbol}" end @@ -257,7 +311,7 @@ class CoinstatsEntry::Processor if profit_loss[:profit].present? profit_formatted = profit_loss[:profit].to_f.round(2) percent_formatted = profit_loss[:profitPercent].to_f.round(2) - parts << "P/L: $#{profit_formatted} (#{percent_formatted}%)" + parts << "P/L: #{formatted_currency_amount(profit_formatted)} (#{percent_formatted}%)" end # Include explorer URL for reference @@ -267,4 +321,170 @@ class CoinstatsEntry::Processor parts.presence&.join(" | ") end + + def exchange_trade? + return false unless coinstats_account.exchange_source? + return false if coinstats_account.fiat_asset? + return false if trade_quantity.zero? || trade_price.zero? + + EXCHANGE_TRADE_TYPES.include?(normalized_transaction_type) + end + + def trade_security + symbol = trade_item&.dig(:coin, :symbol) || matched_symbol || coinstats_account.asset_symbol + return if symbol.blank? + + Security::Resolver.new(symbol.start_with?("CRYPTO:") ? symbol : "CRYPTO:#{symbol}").resolve + end + + def trade_quantity + trade_item_count.nonzero? || matched_item_count.nonzero? || coin_data[:count].to_d + end + + def trade_price + @trade_price ||= begin + quantity = trade_quantity.abs + return 0.to_d if quantity.zero? + + value = trade_item_total_worth.nonzero? || matched_item_total_worth.nonzero? || coin_data[:currentValue] || coin_data[:totalWorth] || profit_loss[:currentValue] || 0 + BigDecimal(value.to_s).abs / quantity + rescue ArgumentError + 0.to_d + end + end + + def trade_amount + trade_quantity * trade_price + end + + def trade_activity_label + normalized_transaction_type == "sell" || trade_quantity.negative? ? "Sell" : "Buy" + end + + def transaction_activity_label + case normalized_transaction_type + when "buy" then "Buy" + when "sell" then "Sell" + when "swap", "trade", "convert" then "Other" + when "received", "receive", "deposit", "transfer_in", "roll_in" then "Transfer" + when "sent", "send", "withdraw", "transfer_out", "roll_out" then "Transfer" + when "reward", "interest" then "Interest" + when "dividend" then "Dividend" + when "fee" then "Fee" + else + "Other" + end + end + + def normalized_transaction_type + @normalized_transaction_type ||= transaction_type.to_s.downcase.parameterize(separator: "_") + end + + def remove_legacy_transaction_entry! + legacy_transaction_entry&.destroy! + end + + def legacy_transaction_entry + @legacy_transaction_entry ||= account.entries.find_by( + external_id: external_id, + source: "coinstats", + entryable_type: "Transaction" + ) + end + + def skip_legacy_transaction_migration? + return false unless legacy_transaction_entry.present? + + skip_reason = import_adapter.send(:determine_skip_reason, legacy_transaction_entry) + return false if skip_reason.blank? + + import_adapter.send(:record_skip, legacy_transaction_entry, skip_reason) + true + end + + def matched_symbol + matched_item&.dig(:coin, :symbol) + end + + def matched_item + @matched_item ||= begin + return primary_portfolio_item if portfolio_exchange_account? + + items = transaction_items + account_id = coinstats_account.account_id.to_s.downcase + account_symbol = coinstats_account.asset_symbol.to_s.downcase + + items.find do |item| + coin = item[:coin].to_h.with_indifferent_access + coin[:id]&.to_s&.downcase == account_id || + coin[:identifier]&.to_s&.downcase == account_id || + coin[:symbol]&.to_s&.downcase == account_symbol + end + end + end + + def trade_item + @trade_item ||= portfolio_exchange_account? ? portfolio_trade_item : matched_item + end + + def trade_item_count + trade_item&.[](:count).to_d + end + + def trade_item_total_worth + trade_item&.[](:totalWorth).to_d + end + + def matched_item_count + matched_item&.[](:count).to_d + end + + def matched_item_total_worth + matched_item&.[](:totalWorth).to_d + end + + def portfolio_exchange_account? + coinstats_account.exchange_portfolio_account? + end + + def portfolio_trade_item + crypto_items = transaction_items.reject { |item| portfolio_fiat_item?(item) || item[:count].to_d.zero? } + crypto_items.find { |item| item[:count].to_d.negative? } || + crypto_items.find { |item| item[:count].to_d.positive? } || + crypto_items.first + end + + def primary_portfolio_item + portfolio_trade_item || + transaction_items.find { |item| item[:count].to_d.nonzero? } || + transaction_items.first + end + + def portfolio_fiat_item?(item) + coinstats_account.fiat_asset?(item[:coin] || item) + end + + def transaction_items + @transaction_items ||= begin + Array(transactions_data).flat_map do |entry| + Array(entry.with_indifferent_access[:items]).map(&:with_indifferent_access) + end + + Array(data[:transfers]).flat_map do |entry| + Array(entry.with_indifferent_access[:items]).map(&:with_indifferent_access) + end + end + end + + def portfolio_outflow? + outgoing_transaction_type? || + trade_item_count.negative? || + matched_item_count.negative? || + coin_data[:count].to_d.negative? + end + + def formatted_currency_amount(amount) + return "$#{amount}" if currency == "USD" + + "#{amount} #{currency}" + end end diff --git a/app/models/coinstats_item.rb b/app/models/coinstats_item.rb index 6df76bf70..6a1bf8291 100644 --- a/app/models/coinstats_item.rb +++ b/app/models/coinstats_item.rb @@ -1,5 +1,5 @@ # Represents a CoinStats API connection for a family. -# Stores credentials and manages associated crypto wallet accounts. +# Stores credentials and manages associated wallet and exchange portfolio accounts. class CoinstatsItem < ApplicationRecord include Syncable, Provided, Unlinking @@ -20,6 +20,7 @@ class CoinstatsItem < ApplicationRecord validates :name, presence: true validates :api_key, presence: true + validates :exchange_portfolio_id, uniqueness: { scope: :family_id, allow_nil: true } belongs_to :family has_one_attached :logo, dependent: :purge_later @@ -141,11 +142,15 @@ class CoinstatsItem < ApplicationRecord # @return [String] Display name for the CoinStats connection def institution_display_name - name.presence || "CoinStats" + institution_name.presence || name.presence || "CoinStats" end # @return [Boolean] true if API key is set def credentials_configured? api_key.present? end + + def exchange_configured? + exchange_portfolio_id.present? && exchange_connection_id.present? + end end diff --git a/app/models/coinstats_item/exchange_linker.rb b/app/models/coinstats_item/exchange_linker.rb new file mode 100644 index 000000000..c8cb1d4bf --- /dev/null +++ b/app/models/coinstats_item/exchange_linker.rb @@ -0,0 +1,98 @@ +# 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 diff --git a/app/models/coinstats_item/exchange_portfolio_account_manager.rb b/app/models/coinstats_item/exchange_portfolio_account_manager.rb new file mode 100644 index 000000000..80ac16a6e --- /dev/null +++ b/app/models/coinstats_item/exchange_portfolio_account_manager.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class CoinstatsItem::ExchangePortfolioAccountManager + attr_reader :coinstats_item + + def initialize(coinstats_item) + @coinstats_item = coinstats_item + end + + def upsert_account!(coins_data:, portfolio_id:, connection_id:, exchange_name:, account_name:, institution_logo: nil) + coinstats_account = coinstats_item.coinstats_accounts.find_or_initialize_by( + account_id: portfolio_account_id(portfolio_id), + wallet_address: portfolio_id + ) + + coinstats_account.name = account_name + coinstats_account.provider = exchange_name + coinstats_account.account_status = "active" + coinstats_account.wallet_address = portfolio_id + coinstats_account.institution_metadata = { + logo: institution_logo, + exchange_logo: institution_logo + }.compact + coinstats_account.raw_payload = build_snapshot( + coins_data: coins_data, + portfolio_id: portfolio_id, + connection_id: connection_id, + exchange_name: exchange_name, + account_name: account_name, + institution_logo: institution_logo + ) + coinstats_account.currency = coinstats_account.inferred_currency + coinstats_account.current_balance = coinstats_account.inferred_current_balance + coinstats_account.save! + coinstats_account + end + + def ensure_local_account!(coinstats_account) + return false if coinstats_account.account.present? + + attributes = { + family: coinstats_item.family, + name: coinstats_account.name, + balance: coinstats_account.current_balance || 0, + cash_balance: coinstats_account.inferred_cash_balance, + currency: coinstats_account.currency || coinstats_item.family.currency || "USD", + accountable_type: "Crypto", + accountable_attributes: { + subtype: "exchange", + tax_treatment: "taxable" + } + } + + account = Account.create_and_sync(attributes, skip_initial_sync: true) + AccountProvider.create!(account: account, provider: coinstats_account) + true + end + + def portfolio_account_id(portfolio_id) + "exchange_portfolio:#{portfolio_id}" + end + + private + def build_snapshot(coins_data:, portfolio_id:, connection_id:, exchange_name:, account_name:, institution_logo:) + { + source: "exchange", + portfolio_account: true, + portfolio_id: portfolio_id, + connection_id: connection_id, + exchange_name: exchange_name, + id: portfolio_account_id(portfolio_id), + name: account_name, + institution_logo: institution_logo, + coins: Array(coins_data).map(&:to_h) + } + end +end diff --git a/app/models/coinstats_item/importer.rb b/app/models/coinstats_item/importer.rb index 8cf4e21f3..9f48dd71a 100644 --- a/app/models/coinstats_item/importer.rb +++ b/app/models/coinstats_item/importer.rb @@ -19,6 +19,7 @@ class CoinstatsItem::Importer # CoinStats works differently from bank providers - wallets are added manually # via the setup_accounts flow. During sync, we just update existing linked accounts. + sync_exchange_accounts! # Get all linked coinstats accounts (ones with account_provider associations) linked_accounts = coinstats_item.coinstats_accounts @@ -34,15 +35,30 @@ class CoinstatsItem::Importer accounts_failed = 0 transactions_imported = 0 - # Fetch balance data using bulk endpoint - bulk_balance_data = fetch_balances_for_accounts(linked_accounts) + wallet_accounts = linked_accounts.select(&:wallet_source?) + exchange_accounts = linked_accounts.select(&:exchange_source?) - # Fetch transaction data using bulk endpoint - bulk_transactions_data = fetch_transactions_for_accounts(linked_accounts) + bulk_balance_data = fetch_balances_for_accounts(wallet_accounts) + bulk_transactions_data = fetch_transactions_for_accounts(wallet_accounts) + portfolio_coins_data = fetch_portfolio_coins_for_exchange(exchange_accounts) + portfolio_transactions_data = fetch_portfolio_transactions_for_exchange(exchange_accounts) linked_accounts.each do |coinstats_account| begin - result = update_account(coinstats_account, bulk_balance_data: bulk_balance_data, bulk_transactions_data: bulk_transactions_data) + result = + if coinstats_account.exchange_source? + update_exchange_account( + coinstats_account, + portfolio_coins_data: portfolio_coins_data, + portfolio_transactions_data: portfolio_transactions_data + ) + else + update_wallet_account( + coinstats_account, + bulk_balance_data: bulk_balance_data, + bulk_transactions_data: bulk_transactions_data + ) + end accounts_updated += 1 if result[:success] transactions_imported += result[:transactions_count] || 0 rescue => e @@ -63,6 +79,26 @@ class CoinstatsItem::Importer private + def sync_exchange_accounts! + return unless coinstats_item.exchange_configured? + return if coinstats_item.coinstats_accounts.any?(&:exchange_source?) + + exchange_portfolio_configurations.each do |config| + portfolio_coins = coinstats_provider.list_portfolio_coins(portfolio_id: config[:portfolio_id]) + coinstats_account = exchange_portfolio_account_manager.upsert_account!( + coins_data: portfolio_coins, + portfolio_id: config[:portfolio_id], + connection_id: config[:connection_id], + exchange_name: config[:exchange_name], + account_name: config[:exchange_name], + institution_logo: coinstats_item.raw_institution_payload.to_h.with_indifferent_access[:icon] + ) + exchange_portfolio_account_manager.ensure_local_account!(coinstats_account) + end + rescue => e + Rails.logger.warn "CoinstatsItem::Importer - Exchange account discovery failed: #{e.message}" + end + # Fetch balance data for all linked accounts using the bulk endpoint # @param linked_accounts [Array] Accounts to fetch balances for # @return [Array, nil] Bulk balance data, or nil on error @@ -89,6 +125,18 @@ class CoinstatsItem::Importer nil end + def fetch_portfolio_coins_for_exchange(linked_accounts) + return {} if linked_accounts.empty? && !coinstats_item.exchange_configured? + + exchange_portfolio_configurations(linked_accounts).each_with_object({}) do |config, results| + Rails.logger.info "CoinstatsItem::Importer - Fetching portfolio coins for CoinStats exchange #{config[:portfolio_id]}" + results[config[:portfolio_id]] = coinstats_provider.list_portfolio_coins(portfolio_id: config[:portfolio_id]) + end + rescue => e + Rails.logger.warn "CoinstatsItem::Importer - Portfolio coins fetch failed: #{e.message}" + {} + end + # Fetch transaction data for all linked accounts using the bulk endpoint # @param linked_accounts [Array] Accounts to fetch transactions for # @return [Array, nil] Bulk transaction data, or nil on error @@ -115,12 +163,49 @@ class CoinstatsItem::Importer nil end + def fetch_portfolio_transactions_for_exchange(linked_accounts) + return {} if linked_accounts.empty? && !coinstats_item.exchange_configured? + + from = coinstats_item.sync_start_date&.iso8601 + + exchange_portfolio_configurations(linked_accounts).each_with_object({}) do |config, results| + Rails.logger.info "CoinstatsItem::Importer - Fetching exchange transactions for CoinStats exchange #{config[:portfolio_id]} in #{family_currency}" + + begin + coinstats_provider.sync_exchange(portfolio_id: config[:portfolio_id]) + + results[config[:portfolio_id]] = coinstats_provider.list_exchange_transactions( + portfolio_id: config[:portfolio_id], + currency: family_currency, + from: from + ) + rescue => e + Rails.logger.warn "CoinstatsItem::Importer - Exchange transactions fetch failed for #{config[:portfolio_id]}: #{e.message}; falling back to portfolio transactions" + + begin + coinstats_provider.sync_portfolio(portfolio_id: config[:portfolio_id]) + results[config[:portfolio_id]] = coinstats_provider.list_portfolio_transactions( + portfolio_id: config[:portfolio_id], + currency: family_currency, + from: from + ) + rescue => fallback_error + Rails.logger.warn "CoinstatsItem::Importer - Portfolio transaction fallback failed for #{config[:portfolio_id]}: #{fallback_error.message}" + results[config[:portfolio_id]] = [] + end + end + end + rescue => e + Rails.logger.warn "CoinstatsItem::Importer - Exchange transactions fetch failed: #{e.message}" + {} + end + # Updates a single account with balance and transaction data. # @param coinstats_account [CoinstatsAccount] Account to update # @param bulk_balance_data [Array, nil] Pre-fetched balance data # @param bulk_transactions_data [Array, nil] Pre-fetched transaction data # @return [Hash] Result with :success and :transactions_count - def update_account(coinstats_account, bulk_balance_data:, bulk_transactions_data:) + def update_wallet_account(coinstats_account, bulk_balance_data:, bulk_transactions_data:) # Get the wallet address and blockchain from the raw payload raw = coinstats_account.raw_payload || {} address = raw["address"] || raw[:address] @@ -147,6 +232,35 @@ class CoinstatsItem::Importer { success: true, transactions_count: transactions_count } end + def update_exchange_account(coinstats_account, portfolio_coins_data:, portfolio_transactions_data:) + portfolio_id = exchange_portfolio_id_for(coinstats_account) + balance_data = portfolio_coins_data[portfolio_id] + + if coinstats_account.exchange_portfolio_account? + if !balance_data.nil? + coinstats_account.upsert_coinstats_snapshot!( + normalize_exchange_portfolio_data(balance_data, coinstats_account, portfolio_id: portfolio_id) + ) + else + Rails.logger.warn "CoinstatsItem::Importer - Missing exchange portfolio coin data for account #{coinstats_account.id} (portfolio #{portfolio_id}); preserving previous snapshot" + end + else + matching_coin = find_matching_portfolio_coin(balance_data, coinstats_account) + + if matching_coin.present? + coinstats_account.upsert_coinstats_snapshot!( + normalize_portfolio_coin_data(matching_coin, coinstats_account, portfolio_id: portfolio_id) + ) + else + Rails.logger.warn "CoinstatsItem::Importer - No matching exchange coin found for account #{coinstats_account.id} (#{coinstats_account.account_id}) in portfolio #{portfolio_id}; preserving previous snapshot" + end + end + + transactions_count = fetch_and_merge_portfolio_transactions(coinstats_account, portfolio_transactions_data[portfolio_id]) + + { success: true, transactions_count: transactions_count } + end + # Extracts and merges new transactions for an account. # Deduplicates by transaction ID to avoid duplicate imports. # @param coinstats_account [CoinstatsAccount] Account to update @@ -192,6 +306,34 @@ class CoinstatsItem::Importer relevant_transactions.count end + def fetch_and_merge_portfolio_transactions(coinstats_account, portfolio_transactions_data) + return 0 if portfolio_transactions_data.blank? + + relevant_transactions = + if coinstats_account.exchange_portfolio_account? + Array(portfolio_transactions_data) + else + filter_transactions_by_coin(portfolio_transactions_data, coinstats_account.account_id) + end + return 0 if relevant_transactions.empty? + + existing_transactions = coinstats_account.raw_transactions_payload.to_a + existing_ids = existing_transactions.map { |tx| extract_coinstats_transaction_id(tx) }.compact.to_set + + transactions_to_add = relevant_transactions.select do |tx| + tx_id = extract_coinstats_transaction_id(tx) + tx_id.present? && !existing_ids.include?(tx_id) + end + + if transactions_to_add.any? + merged_transactions = existing_transactions + transactions_to_add + coinstats_account.upsert_coinstats_transactions_snapshot!(merged_transactions) + Rails.logger.info "CoinstatsItem::Importer - Added #{transactions_to_add.count} new exchange transactions for account #{coinstats_account.id}" + end + + relevant_transactions.count + end + # Filter transactions to only include those relevant to a specific coin # Transactions can be matched by: # - coinData.symbol matching the coin (case-insensitive) @@ -206,10 +348,11 @@ class CoinstatsItem::Importer transactions.select do |tx| tx = tx.with_indifferent_access + coin_identifier = tx.dig(:coinData, :identifier)&.to_s&.downcase # Check nested transactions items for coin match inner_transactions = tx[:transactions] || [] - inner_transactions.any? do |inner_tx| + matches_nested_item = inner_transactions.any? do |inner_tx| inner_tx = inner_tx.with_indifferent_access items = inner_tx[:items] || [] items.any? do |item| @@ -218,9 +361,29 @@ class CoinstatsItem::Importer next false unless coin.present? coin = coin.with_indifferent_access - coin[:id]&.downcase == coin_id_downcase + coin[:id]&.downcase == coin_id_downcase || + coin[:identifier]&.downcase == coin_id_downcase || + coin[:symbol]&.downcase == coin_id_downcase end end + + transfer_transactions = tx[:transfers] || [] + matches_transfer_item = transfer_transactions.any? do |transfer_tx| + transfer_tx = transfer_tx.with_indifferent_access + items = transfer_tx[:items] || [] + items.any? do |item| + item = item.with_indifferent_access + coin = item[:coin] + next false unless coin.present? + + coin = coin.with_indifferent_access + coin[:id]&.downcase == coin_id_downcase || + coin[:identifier]&.downcase == coin_id_downcase || + coin[:symbol]&.downcase == coin_id_downcase + end + end + + coin_identifier == coin_id_downcase || matches_nested_item || matches_transfer_item end end @@ -237,23 +400,21 @@ class CoinstatsItem::Importer # Find the matching token for this account to extract id, logo, and balance matching_token = find_matching_token(balance_data, coinstats_account) - # Calculate balance from the matching token only, not all tokens - # Each coinstats_account represents a single token/coin in the wallet - token_balance = calculate_token_balance(matching_token) + source_snapshot = (matching_token || {}).to_h.with_indifferent_access { # Use existing account_id if set, otherwise extract from matching token id: coinstats_account.account_id.presence || matching_token&.dig(:coinId) || matching_token&.dig(:id), name: coinstats_account.name, - balance: token_balance, - currency: "USD", # CoinStats returns values in USD + balance: coinstats_account.inferred_current_balance(source_snapshot), + currency: coinstats_account.inferred_currency(source_snapshot), address: existing_raw["address"] || existing_raw[:address], blockchain: existing_raw["blockchain"] || existing_raw[:blockchain], # Extract logo from the matching token institution_logo: matching_token&.dig(:imgUrl), # Preserve original data raw_balance_data: balance_data - } + }.merge(source_snapshot.slice(:amount, :count, :price, :priceUsd, :symbol, :coinId, :isFiat, :imgUrl)) end # Finds the token in balance_data that matches this account. @@ -302,14 +463,117 @@ class CoinstatsItem::Importer end end - # Calculates USD balance from token amount and price. - # @param token [Hash, nil] Token with :amount/:balance and :price/:priceUsd - # @return [Float] Balance in USD (0 if token is nil) - def calculate_token_balance(token) - return 0 if token.blank? + def find_matching_portfolio_coin(balance_data, coinstats_account) + Array(balance_data).map(&:with_indifferent_access).find do |coin_data| + coin = coin_data[:coin].to_h.with_indifferent_access + identifier = coin[:identifier].presence || coin_data[:coinId].presence + symbol = coin[:symbol].presence || coin_data[:symbol].presence + base_name = coinstats_account.name.to_s.sub(/\s+\([^)]*\)\z/, "").downcase - amount = token[:amount] || token[:balance] || 0 - price = token[:price] || token[:priceUsd] || 0 - (amount.to_f * price.to_f) + identifier.to_s.casecmp?(coinstats_account.account_id.to_s) || + symbol.to_s.casecmp?(coinstats_account.account_id.to_s) || + symbol.to_s.casecmp?(coinstats_account.asset_symbol.to_s) || + coin[:name].to_s.downcase == base_name + end + end + + def normalize_portfolio_coin_data(balance_data, coinstats_account, portfolio_id:) + existing_raw = coinstats_account.raw_payload.to_h.with_indifferent_access + portfolio_coin = balance_data.to_h.with_indifferent_access + coin = portfolio_coin[:coin].to_h.with_indifferent_access + source_snapshot = { + source: existing_raw[:source] || "exchange", + portfolio_id: portfolio_id, + connection_id: existing_raw[:connection_id] || coinstats_item.exchange_connection_id, + exchange_name: existing_raw[:exchange_name] || exchange_display_name, + coin: coin + }.merge(portfolio_coin) + + { + id: coin[:identifier].presence || coinstats_account.account_id, + name: coinstats_account.name, + balance: coinstats_account.inferred_current_balance(source_snapshot), + currency: coinstats_account.inferred_currency(source_snapshot), + provider: existing_raw[:exchange_name].presence || exchange_display_name, + account_status: "active", + portfolio_id: portfolio_id, + connection_id: existing_raw[:connection_id] || coinstats_item.exchange_connection_id, + institution_logo: coin[:icon], + raw_balance_data: portfolio_coin + }.merge(existing_raw.slice(:source, :exchange_name)) + .merge(portfolio_coin.slice(:coin, :count, :price, :averageBuy, :averageSell, :profit, :profitPercent, :coinId, :isFiat)) + end + + def normalize_exchange_portfolio_data(balance_data, coinstats_account, portfolio_id:) + existing_raw = coinstats_account.raw_payload.to_h.with_indifferent_access + coins = Array(balance_data).map { |coin| coin.with_indifferent_access.to_h } + + snapshot = existing_raw.merge( + source: "exchange", + portfolio_account: true, + portfolio_id: portfolio_id, + connection_id: existing_raw[:connection_id] || coinstats_item.exchange_connection_id, + exchange_name: existing_raw[:exchange_name] || exchange_display_name, + name: coinstats_account.name, + institution_logo: existing_raw[:institution_logo].presence || coinstats_item.raw_institution_payload.to_h.with_indifferent_access[:icon], + coins: coins + ) + + { + id: coinstats_account.account_id.presence || exchange_portfolio_account_manager.portfolio_account_id(portfolio_id), + name: coinstats_account.name, + balance: coinstats_account.inferred_current_balance(snapshot), + currency: coinstats_account.inferred_currency(snapshot), + provider: snapshot[:exchange_name], + account_status: "active", + portfolio_id: portfolio_id, + connection_id: snapshot[:connection_id], + institution_logo: snapshot[:institution_logo], + portfolio_account: true, + coins: coins + }.merge(snapshot.slice(:source, :exchange_name)) + end + + def exchange_display_name + coinstats_item.institution_name.presence || coinstats_item.exchange_connection_id.to_s.titleize + end + + def exchange_portfolio_account_manager + @exchange_portfolio_account_manager ||= CoinstatsItem::ExchangePortfolioAccountManager.new(coinstats_item) + end + + def exchange_portfolio_configurations(linked_accounts = []) + configurations = [] + + if coinstats_item.exchange_configured? + configurations << { + portfolio_id: coinstats_item.exchange_portfolio_id, + connection_id: coinstats_item.exchange_connection_id, + exchange_name: exchange_display_name + } + end + + Array(linked_accounts).select(&:exchange_source?).each do |account| + raw = account.raw_payload.to_h.with_indifferent_access + portfolio_id = raw[:portfolio_id].presence || account.wallet_address.presence + next if portfolio_id.blank? + + configurations << { + portfolio_id: portfolio_id, + connection_id: raw[:connection_id].presence || coinstats_item.exchange_connection_id, + exchange_name: raw[:exchange_name].presence || exchange_display_name + } + end + + configurations.uniq { |config| config[:portfolio_id] } + end + + def exchange_portfolio_id_for(coinstats_account) + raw = coinstats_account.raw_payload.to_h.with_indifferent_access + raw[:portfolio_id].presence || coinstats_account.wallet_address.presence || coinstats_item.exchange_portfolio_id + end + + def family_currency + coinstats_item.family.currency.presence || "USD" end end diff --git a/app/models/coinstats_item/wallet_linker.rb b/app/models/coinstats_item/wallet_linker.rb index 2840fa35b..0d2c372c6 100644 --- a/app/models/coinstats_item/wallet_linker.rb +++ b/app/models/coinstats_item/wallet_linker.rb @@ -77,27 +77,26 @@ class CoinstatsItem::WalletLinker def create_account_from_token(token_data) token = token_data.with_indifferent_access account_name = build_account_name(token) - current_balance = calculate_balance(token) token_id = (token[:coinId] || token[:id])&.to_s ActiveRecord::Base.transaction do coinstats_account = coinstats_item.coinstats_accounts.create!( name: account_name, currency: "USD", - current_balance: current_balance, + current_balance: 0, account_id: token_id, wallet_address: address ) # Store wallet metadata for future syncs - snapshot = build_snapshot(token, current_balance) + snapshot = build_snapshot(token) coinstats_account.upsert_coinstats_snapshot!(snapshot) account = coinstats_item.family.accounts.create!( accountable: Crypto.new, name: account_name, - balance: current_balance, - cash_balance: current_balance, + balance: coinstats_account.current_balance, + cash_balance: coinstats_account.inferred_cash_balance, currency: coinstats_account.currency, status: "active" ) @@ -132,23 +131,12 @@ class CoinstatsItem::WalletLinker end end - # Calculates USD balance from token amount and price. - # @param token [Hash] Token data with :amount/:balance and :price - # @return [Float] Balance in USD - def calculate_balance(token) - amount = token[:amount] || token[:balance] || token[:current_balance] || 0 - price = token[:price] || 0 - (amount.to_f * price.to_f) - end - # Builds snapshot hash for storing in CoinstatsAccount. # @param token [Hash] Token data from API - # @param current_balance [Float] Calculated USD balance # @return [Hash] Snapshot with balance, address, and metadata - def build_snapshot(token, current_balance) - token.to_h.merge( + def build_snapshot(token) + token.to_h.except("id", :id).merge( id: (token[:coinId] || token[:id])&.to_s, - balance: current_balance, currency: "USD", address: address, blockchain: blockchain, diff --git a/app/models/holding/materializer.rb b/app/models/holding/materializer.rb index 582bc743b..e4ad1737c 100644 --- a/app/models/holding/materializer.rb +++ b/app/models/holding/materializer.rb @@ -17,9 +17,16 @@ class Holding::Materializer purge_stale_holdings end - # Clean up calculated holdings for securities that now have provider-sourced holdings - # This prevents duplicates when a manually-entered account gets linked to a provider - cleanup_calculated_holdings_for_provider_securities + # Clean up only calculated holdings that are directly shadowed by a provider snapshot + # on the same date/security/currency. Historical calculated rows for provider-linked + # securities are still needed to derive sane balance charts between sync snapshots. + cleanup_shadowed_calculated_holdings + + # Also remove calculated rows on the provider's latest snapshot date when those + # securities are no longer present in the provider payload. This keeps "current" + # holdings/balance composition aligned with the provider snapshot while preserving + # older calculated history. + cleanup_stale_calculated_rows_on_latest_provider_snapshot # Reload holdings association to clear any cached stale data # This ensures subsequent Balance calculations see the fresh holdings @@ -48,9 +55,6 @@ class Holding::Materializer holdings_to_upsert_without_cost = [] @holdings.each do |holding| - # Skip securities that have provider-sourced holdings - don't overwrite provider data - next if provider_sourced_security_ids.include?(holding.security_id) - key = holding_key(holding) existing = existing_holdings_map[key] @@ -118,27 +122,48 @@ class Holding::Materializer .index_by { |h| holding_key(h) } end - # Get security IDs that have provider-sourced holdings (any date) - # These should be preserved and not overwritten by calculated holdings - def provider_sourced_security_ids - @provider_sourced_security_ids ||= account.holdings - .where.not(account_provider_id: nil) - .distinct - .pluck(:security_id) - end - - # Remove calculated holdings (account_provider_id IS NULL) for securities - # that now have provider-sourced holdings. This prevents duplicates when - # a manually-entered account gets linked to a provider. - def cleanup_calculated_holdings_for_provider_securities - return if provider_sourced_security_ids.empty? - + # Remove only calculated holdings that collide with an authoritative provider snapshot + # on the exact same key. This preserves reverse-calculated history for linked accounts. + def cleanup_shadowed_calculated_holdings deleted_count = account.holdings .where(account_provider_id: nil) - .where(security_id: provider_sourced_security_ids) + .where(<<~SQL) + EXISTS ( + SELECT 1 + FROM holdings provider_holdings + WHERE provider_holdings.account_id = holdings.account_id + AND provider_holdings.security_id = holdings.security_id + AND provider_holdings.date = holdings.date + AND provider_holdings.currency = holdings.currency + AND provider_holdings.account_provider_id IS NOT NULL + ) + SQL .delete_all - Rails.logger.info("Cleaned up #{deleted_count} calculated holdings for provider-sourced securities") if deleted_count > 0 + Rails.logger.info("Cleaned up #{deleted_count} calculated holdings shadowed by provider snapshots") if deleted_count > 0 + end + + def cleanup_stale_calculated_rows_on_latest_provider_snapshot + provider_snapshot_date = account.latest_provider_holdings_snapshot_date + return unless provider_snapshot_date + + provider_security_ids = account.holdings + .where.not(account_provider_id: nil) + .where(date: provider_snapshot_date) + .distinct + .pluck(:security_id) + + scope = account.holdings + .where(account_provider_id: nil, date: provider_snapshot_date) + + scope = if provider_security_ids.any? + scope.where.not(security_id: provider_security_ids) + else + scope + end + + deleted_count = scope.delete_all + Rails.logger.info("Cleaned up #{deleted_count} stale calculated holdings on latest provider snapshot date") if deleted_count > 0 end def holding_key(holding) diff --git a/app/models/holding/portfolio_snapshot.rb b/app/models/holding/portfolio_snapshot.rb index 0c512873c..2bf140efa 100644 --- a/app/models/holding/portfolio_snapshot.rb +++ b/app/models/holding/portfolio_snapshot.rb @@ -21,12 +21,22 @@ class Holding::PortfolioSnapshot .uniq .each_with_object({}) { |security_id, hash| hash[security_id] = 0 } - # Get the most recent holding for each security and update quantities - account.holdings - .select("DISTINCT ON (security_id) security_id, qty") - .order(:security_id, date: :desc) - .each { |holding| portfolio[holding.security_id] = holding.qty } + latest_holdings_scope.each do |holding| + portfolio[holding.security_id] = holding.qty + end portfolio end + + def latest_holdings_scope + if (provider_snapshot_date = account.latest_provider_holdings_snapshot_date) + account.holdings + .where.not(account_provider_id: nil) + .where(date: provider_snapshot_date) + else + account.holdings + .select("DISTINCT ON (security_id) holdings.*") + .order(:security_id, date: :desc) + end + end end diff --git a/app/models/provider/coinstats.rb b/app/models/provider/coinstats.rb index 3ffa814b4..1689c6189 100644 --- a/app/models/provider/coinstats.rb +++ b/app/models/provider/coinstats.rb @@ -63,6 +63,258 @@ class Provider::Coinstats < Provider [] end + # Get the list of exchange connections supported by CoinStats + # https://coinstats.app/api-docs/openapi/get-exchanges + def get_exchanges + with_provider_response do + res = self.class.get("#{BASE_URL}/exchange/support", headers: auth_headers) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /exchange/support failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + def exchange_options + response = get_exchanges + + unless response.success? + Rails.logger.warn("CoinStats: failed to fetch exchanges: #{response.error&.message}") + return [] + end + + Array(response.data).filter_map do |exchange| + exchange = exchange.with_indifferent_access + connection_id = exchange[:connectionId] + next unless connection_id.present? + + { + connection_id: connection_id.to_s, + name: exchange[:name].presence || connection_id.to_s.titleize, + icon: exchange[:icon], + connection_fields: Array(exchange[:connectionFields]).map do |field| + field = field.with_indifferent_access + { + key: field[:key].to_s, + name: field[:name].presence || field[:key].to_s.humanize + } + end + } + end.sort_by { |exchange| exchange[:name].to_s.downcase } + rescue StandardError => e + Rails.logger.warn("CoinStats: failed to fetch exchanges: #{e.class} - #{e.message}") + [] + end + + # Connect an exchange portfolio and return its portfolio id + # https://coinstats.app/api-docs/openapi/connect-portfolio-exchange + def connect_portfolio_exchange(connection_id:, connection_fields:, name: nil) + with_provider_response do + res = self.class.post( + "#{BASE_URL}/portfolio/exchange", + headers: auth_headers.merge("Content-Type" => "application/json"), + body: { + connectionId: connection_id, + connectionFields: connection_fields, + name: name + }.compact.to_json + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: POST /portfolio/exchange failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + # Get all holdings for a CoinStats portfolio. + # https://coinstats.app/api-docs/openapi/get-portfolio-coins + def get_portfolio_coins(portfolio_id:, page: 1, limit: 100) + with_provider_response do + res = self.class.get( + "#{BASE_URL}/portfolio/coins", + headers: auth_headers, + query: { + portfolioId: portfolio_id, + page: page, + limit: limit + } + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /portfolio/coins failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + def list_portfolio_coins(portfolio_id:, limit: 100) + page = 1 + results = [] + + loop do + response = get_portfolio_coins(portfolio_id: portfolio_id, page: page, limit: limit) + raise response.error unless response.success? + + payload = response.data.with_indifferent_access + page_results = Array(payload[:result]) + results.concat(page_results) + + break if page_results.size < limit + + page += 1 + end + + results + end + + # Get all transactions for a CoinStats portfolio. + # https://coinstats.app/api-docs/openapi/get-portfolio-transactions + def get_portfolio_transactions(portfolio_id:, currency: "USD", page: 1, limit: 100, from: nil, to: nil, coin_id: nil) + with_provider_response do + res = self.class.get( + "#{BASE_URL}/portfolio/transactions", + headers: auth_headers, + query: { + portfolioId: portfolio_id, + currency: currency, + page: page, + limit: limit, + from: from, + to: to, + coinId: coin_id + }.compact + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /portfolio/transactions failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + def list_portfolio_transactions(portfolio_id:, currency: "USD", limit: 100, from: nil, to: nil) + page = 1 + results = [] + + loop do + response = get_portfolio_transactions( + portfolio_id: portfolio_id, + currency: currency, + page: page, + limit: limit, + from: from, + to: to + ) + raise response.error unless response.success? + + payload = response.data.with_indifferent_access + page_results = Array(payload[:data] || payload[:result]) + results.concat(page_results) + + break if page_results.size < limit + + page += 1 + end + + results + end + + # Get transaction data for a specific exchange portfolio. + # https://coinstats.app/api-docs/openapi/get-exchange-transactions + def get_exchange_transactions(portfolio_id:, currency: "USD", page: 1, limit: 100, from: nil, to: nil) + with_provider_response do + res = self.class.get( + "#{BASE_URL}/exchange/transactions", + headers: auth_headers, + query: { + portfolioId: portfolio_id, + currency: currency, + page: page, + limit: limit, + from: from, + to: to + }.compact + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /exchange/transactions failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + def list_exchange_transactions(portfolio_id:, currency: "USD", limit: 100, from: nil, to: nil) + page = 1 + results = [] + + loop do + response = get_exchange_transactions( + portfolio_id: portfolio_id, + currency: currency, + page: page, + limit: limit, + from: from, + to: to + ) + raise response.error unless response.success? + + payload = response.data.with_indifferent_access + page_results = Array(payload[:result] || payload[:data]) + results.concat(page_results) + + break if page_results.size < limit + + page += 1 + end + + results + end + + # Trigger a fresh CoinStats sync for the portfolio. + # https://coinstats.app/api-docs/openapi/sync-portfolio + def sync_portfolio(portfolio_id:) + with_provider_response do + res = self.class.patch( + "#{BASE_URL}/portfolio/sync", + headers: auth_headers, + query: { portfolioId: portfolio_id } + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: PATCH /portfolio/sync failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + # Trigger a fresh CoinStats exchange sync for the portfolio. + # https://coinstats.app/api-docs/openapi/exchange-sync-status + def sync_exchange(portfolio_id:) + with_provider_response do + res = self.class.patch( + "#{BASE_URL}/exchange/sync", + headers: auth_headers, + query: { portfolioId: portfolio_id } + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: PATCH /exchange/sync failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + # Get current sync status for the portfolio. + # https://coinstats.app/api-docs/openapi/get-portfolio-sync-status + def get_portfolio_sync_status(portfolio_id:) + with_provider_response do + res = self.class.get( + "#{BASE_URL}/portfolio/status", + headers: auth_headers, + query: { portfolioId: portfolio_id } + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /portfolio/status failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + # Get cryptocurrency balances for multiple wallets in a single request # https://coinstats.app/api-docs/openapi/get-wallet-balances # @param wallets [String] Comma-separated list of wallet addresses in format "blockchain:address" @@ -173,35 +425,58 @@ class Provider::Coinstats < Provider JSON.parse(response.body, symbolize_names: true) when 400 log_api_error(response, "Bad Request") - raise Error, "CoinStats: Invalid request parameters" + raise_api_error(response, fallback: "CoinStats: Invalid request parameters") when 401 log_api_error(response, "Unauthorized") - raise Error, "CoinStats: Invalid or missing API key" + raise_api_error(response, fallback: "CoinStats: Invalid or missing API key") when 403 log_api_error(response, "Forbidden") - raise Error, "CoinStats: Access denied" + raise_api_error(response, fallback: "CoinStats: Access denied") when 404 log_api_error(response, "Not Found") - raise Error, "CoinStats: Resource not found" + raise_api_error(response, fallback: "CoinStats: Resource not found") when 409 log_api_error(response, "Conflict") - raise Error, "CoinStats: Resource conflict" + raise_api_error(response, fallback: "CoinStats: Resource conflict") when 429 log_api_error(response, "Too Many Requests") - raise Error, "CoinStats: Rate limit exceeded, try again later" + raise_api_error(response, fallback: "CoinStats: Rate limit exceeded, try again later") when 500 log_api_error(response, "Internal Server Error") - raise Error, "CoinStats: Server error, try again later" + raise_api_error(response, fallback: "CoinStats: Server error, try again later") when 503 log_api_error(response, "Service Unavailable") - raise Error, "CoinStats: Service temporarily unavailable" + raise_api_error(response, fallback: "CoinStats: Service temporarily unavailable") else log_api_error(response, "Unexpected Error") - raise Error, "CoinStats: An unexpected error occurred" + raise_api_error(response, fallback: "CoinStats: An unexpected error occurred") end end def log_api_error(response, error_type) Rails.logger.error "CoinStats API: #{response.code} #{error_type} - #{response.body}" end + + def raise_api_error(response, fallback:) + error_payload = parse_error_payload(response.body) + message = error_payload[:message].presence || fallback + request_id = error_payload[:request_id].presence + + message = "#{message} (requestId: #{request_id})" if request_id.present? + + raise Error.new(message, details: error_payload.compact.presence) + end + + def parse_error_payload(body) + payload = JSON.parse(body.presence || "{}", symbolize_names: true) + + { + status_code: payload[:statusCode] || payload[:status_code], + message: payload[:message], + request_id: payload[:requestId] || payload[:request_id], + path: payload[:path] + } + rescue JSON::ParserError + {} + end end diff --git a/app/views/coinstats_items/new.html.erb b/app/views/coinstats_items/new.html.erb index c49890232..955c7a2cb 100644 --- a/app/views/coinstats_items/new.html.erb +++ b/app/views/coinstats_items/new.html.erb @@ -3,75 +3,126 @@ <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %> - <% error_msg = local_assigns[:error_message] || @error_message %> - <% if error_msg.present? %> -
-

<%= error_msg %>

-
- <% end %> + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
+

<%= error_msg %>

+
+ <% end %> - <% items = local_assigns[:coinstats_items] || @coinstats_items || Current.family.coinstats_items.where.not(api_key: nil) %> - <% if items&.any? %> - <% selected_item = items.first %> - <% blockchains = local_assigns[:blockchains] || @blockchains || [] %> - <% address_value = local_assigns[:address] || @address %> - <% blockchain_value = local_assigns[:blockchain] || @blockchain %> - <%= styled_form_with url: link_wallet_coinstats_items_path, - method: :post, - data: { turbo: true }, - class: "space-y-3" do |form| %> - <%= form.hidden_field :coinstats_item_id, value: selected_item.id %> + <% items = local_assigns[:coinstats_items] || @coinstats_items || Current.family.coinstats_items.where.not(api_key: nil) %> + <% selected_item = items&.first %> - <%= form.text_field :address, - label: t(".address_label"), - placeholder: t(".address_placeholder"), - value: address_value %> + <% if selected_item.present? %> + <% blockchains = local_assigns[:blockchains] || @blockchains || [] %> + <% exchanges = local_assigns[:exchanges] || @exchanges || [] %> + <% address_value = local_assigns[:address] || @address %> + <% blockchain_value = local_assigns[:blockchain] || @blockchain %> + <% exchange_connection_id = local_assigns[:exchange_connection_id] || @exchange_connection_id %> + <% exchange_connection_fields = local_assigns[:exchange_connection_fields] || @exchange_connection_fields || {} %> - <% if blockchains.present? %> - <%= form.select :blockchain, - options_for_select(blockchains, blockchain_value), - { include_blank: t(".blockchain_select_blank") }, - label: t(".blockchain_label"), - class: "w-full rounded-md border border-primary px-3 py-2 text-sm bg-container-inset text-primary" %> - <% else %> - <%= form.text_field :blockchain, - label: t(".blockchain_label"), - placeholder: t(".blockchain_placeholder"), - value: blockchain_value %> +
+
+
+

<%= t(".link_wallet_title") %>

+

<%= t(".link_wallet_description") %>

+
+ + <%= styled_form_with url: link_wallet_coinstats_items_path, + method: :post, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.hidden_field :coinstats_item_id, value: selected_item.id %> + + <%= form.text_field :address, + label: t(".address_label"), + placeholder: t(".address_placeholder"), + value: address_value %> + + <% if blockchains.present? %> + <%= form.select :blockchain, + options_for_select(blockchains, blockchain_value), + { include_blank: t(".blockchain_select_blank") }, + label: t(".blockchain_label"), + class: "w-full rounded-md border border-primary px-3 py-2 text-sm bg-container-inset text-primary" %> + <% else %> + <%= form.text_field :blockchain, + label: t(".blockchain_label"), + placeholder: t(".blockchain_placeholder"), + value: blockchain_value %> + <% end %> + +
+ <%= form.submit t(".link_wallet_submit"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %> +
<% end %> +
-
- <%= form.submit t(".link"), - class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %> +
+
+

<%= t(".link_exchange_title") %>

+

<%= t(".link_exchange_description") %>

+

<%= t(".link_exchange_note") %>

- <% end %> - <% else %> -
-
- <%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %> -
-

<%= t(".not_configured_title") %>

-

<%= t(".not_configured_message") %>

-
-
-
-
    -
  1. <%= t(".not_configured_step1_html").html_safe %>
  2. -
  3. <%= t(".not_configured_step2_html").html_safe %>
  4. -
  5. <%= t(".not_configured_step3_html").html_safe %>
  6. -
-
+ <%= styled_form_with url: link_exchange_coinstats_items_path, + method: :post, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.hidden_field :coinstats_item_id, value: selected_item.id %> + <%= form.hidden_field :exchange_connection_name, value: "", data: { coinstats_exchange_fields_target: "connectionName" } %> -
- <%= link_to settings_providers_path, - class: "w-full inline-flex items-center justify-center rounded-lg font-medium whitespace-nowrap rounded-lg hidden md:inline-flex px-3 py-2 text-sm text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400", - data: { turbo: false } do %> - <%= t(".go_to_settings") %> - <% end %> -
+ <%= form.select :exchange_connection_id, + options_for_select(exchanges.map { |exchange| [ exchange[:name], exchange[:connection_id] ] }, exchange_connection_id), + { include_blank: t(".exchange_select_blank") }, + label: t(".exchange_label"), + class: "w-full rounded-md border border-primary px-3 py-2 text-sm bg-container-inset text-primary", + data: { + coinstats_exchange_fields_target: "select", + action: "change->coinstats-exchange-fields#render" + } %> + +
+ +
+ <%= form.submit t(".link_exchange_submit"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %> +
+ <% end %> +
+
+ <% else %> +
+
+ <%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %> +
+

<%= t(".not_configured_title") %>

+

<%= t(".not_configured_message") %>

- <% end %> +
+ +
+
    +
  1. <%= t(".not_configured_step1_html").html_safe %>
  2. +
  3. <%= t(".not_configured_step2_html").html_safe %>
  4. +
  5. <%= t(".not_configured_step3_html").html_safe %>
  6. +
+
+ +
+ <%= link_to settings_providers_path, + class: "w-full inline-flex items-center justify-center rounded-lg font-medium whitespace-nowrap rounded-lg hidden md:inline-flex px-3 py-2 text-sm text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400", + data: { turbo: false } do %> + <%= t(".go_to_settings") %> + <% end %> +
+
+ <% end %> <% end %> <% end %> <% end %> diff --git a/config/locales/models/coinstats_item/en.yml b/config/locales/models/coinstats_item/en.yml index e02b561cf..b5feb8217 100644 --- a/config/locales/models/coinstats_item/en.yml +++ b/config/locales/models/coinstats_item/en.yml @@ -3,8 +3,8 @@ en: models: coinstats_item: syncer: - importing_wallets: Importing wallets from CoinStats... - checking_configuration: Checking wallet configuration... - wallets_need_setup: "%{count} wallets need setup..." + importing_wallets: Importing crypto accounts from CoinStats... + checking_configuration: Checking CoinStats account configuration... + wallets_need_setup: "%{count} crypto accounts need setup..." processing_holdings: Processing holdings... calculating_balances: Calculating balances... diff --git a/config/locales/views/coinstats_items/en.yml b/config/locales/views/coinstats_items/en.yml index 00add222d..ecd41109b 100644 --- a/config/locales/views/coinstats_items/en.yml +++ b/config/locales/views/coinstats_items/en.yml @@ -17,17 +17,31 @@ en: missing_params: "Missing required parameters: address and blockchain." failed: Crypto wallet linking failed. error: "Crypto wallet linking failed: %{message}." + link_exchange: + success: "%{name} exchange linked." + missing_params: Exchange and credentials are required. + invalid_exchange: Selected exchange is no longer supported. + failed: Failed to link exchange. + error: "Failed to link exchange: %{message}." new: - title: Link a Crypto Wallet with CoinStats + title: Link Crypto with CoinStats blockchain_fetch_error: Failed load Blockchains. Please try again later. + link_wallet_title: Link Wallet Address + link_wallet_description: Track a self-custody wallet or a single on-chain address through CoinStats. address_label: Address address_placeholder: Required blockchain_label: Blockchain blockchain_placeholder: Required blockchain_select_blank: Select a Blockchain - link: Link Crypto Wallet + link_wallet_submit: Link Crypto Wallet + link_exchange_title: Link Exchange API + link_exchange_description: Use a read-only exchange API key so CoinStats can sync balances and transactions from Bitvavo, Binance, and other supported exchanges. + link_exchange_note: If your exchange requires API-key activation or email confirmation, complete that step before linking here. + exchange_select_blank: Select an exchange + exchange_label: Exchange + link_exchange_submit: Link Exchange not_configured_title: CoinStats provider connection not configured - not_configured_message: To link a crypto wallet, you must first configure the CoinStats provider connection. + not_configured_message: To link a crypto wallet or exchange, you must first configure the CoinStats provider connection. not_configured_step1_html: Go to Settings → Providers not_configured_step2_html: Locate the CoinStats provider not_configured_step3_html: Follow the provided setup Instructions to complete provider configuration @@ -35,7 +49,7 @@ en: setup_instructions: "Setup Instructions:" step1_html: Visit the CoinStats Public API Dashboard to obtain an API key. step2: Enter your API key below and click Configure. - step3_html: After a successful connection, visit the Accounts tab to set up crypto wallets. + step3_html: After a successful connection, visit the Accounts tab to set up your crypto accounts. api_key_label: API Key api_key_placeholder: Required configure: Configure diff --git a/config/routes.rb b/config/routes.rb index 8514a9407..4aa815976 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -74,6 +74,7 @@ Rails.application.routes.draw do resources :coinstats_items, only: [ :index, :new, :create, :update, :destroy ] do collection do post :link_wallet + post :link_exchange end member do post :sync diff --git a/db/migrate/20260327103000_add_exchange_portfolio_fields_to_coinstats_items.rb b/db/migrate/20260327103000_add_exchange_portfolio_fields_to_coinstats_items.rb new file mode 100644 index 000000000..5b493ae31 --- /dev/null +++ b/db/migrate/20260327103000_add_exchange_portfolio_fields_to_coinstats_items.rb @@ -0,0 +1,11 @@ +class AddExchangePortfolioFieldsToCoinstatsItems < ActiveRecord::Migration[7.2] + def change + add_column :coinstats_items, :exchange_portfolio_id, :string + add_column :coinstats_items, :exchange_connection_id, :string + + add_index :coinstats_items, [ :family_id, :exchange_portfolio_id ], + unique: true, + where: "exchange_portfolio_id IS NOT NULL" + add_index :coinstats_items, :exchange_connection_id + end +end diff --git a/db/migrate/20260327130000_increase_crypto_quantity_precision.rb b/db/migrate/20260327130000_increase_crypto_quantity_precision.rb new file mode 100644 index 000000000..1288f9442 --- /dev/null +++ b/db/migrate/20260327130000_increase_crypto_quantity_precision.rb @@ -0,0 +1,11 @@ +class IncreaseCryptoQuantityPrecision < ActiveRecord::Migration[7.2] + def up + change_column :holdings, :qty, :decimal, precision: 24, scale: 8, null: false + change_column :trades, :qty, :decimal, precision: 24, scale: 8 + end + + def down + change_column :holdings, :qty, :decimal, precision: 19, scale: 4, null: false + change_column :trades, :qty, :decimal, precision: 19, scale: 4 + end +end diff --git a/db/schema.rb b/db/schema.rb index 74d060072..4a082ca1d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -304,6 +304,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do t.string "api_key", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "exchange_portfolio_id" + t.string "exchange_connection_id" + t.index ["exchange_connection_id"], name: "index_coinstats_items_on_exchange_connection_id" + t.index ["family_id", "exchange_portfolio_id"], name: "index_coinstats_items_on_family_id_and_exchange_portfolio_id", unique: true, where: "(exchange_portfolio_id IS NOT NULL)" t.index ["family_id"], name: "index_coinstats_items_on_family_id" t.index ["status"], name: "index_coinstats_items_on_status" end @@ -577,7 +581,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do t.uuid "account_id", null: false t.uuid "security_id", null: false t.date "date", null: false - t.decimal "qty", precision: 19, scale: 4, null: false + t.decimal "qty", precision: 24, scale: 8, null: false t.decimal "price", precision: 19, scale: 4, null: false t.decimal "amount", precision: 19, scale: 4, null: false t.string "currency", null: false @@ -1420,7 +1424,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do create_table "trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "security_id", null: false - t.decimal "qty", precision: 19, scale: 4 + t.decimal "qty", precision: 24, scale: 8 t.decimal "price", precision: 19, scale: 10 t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/test/controllers/coinstats_items_controller_test.rb b/test/controllers/coinstats_items_controller_test.rb index 9b29ec7a9..a3bfc24e0 100644 --- a/test/controllers/coinstats_items_controller_test.rb +++ b/test/controllers/coinstats_items_controller_test.rb @@ -9,6 +9,9 @@ class CoinstatsItemsControllerTest < ActionDispatch::IntegrationTest name: "Test CoinStats Connection", api_key: "test_api_key_123" ) + tailwind_build = Rails.root.join("app/assets/builds/tailwind.css") + FileUtils.mkdir_p(tailwind_build.dirname) + File.write(tailwind_build, "/* test */") unless tailwind_build.exist? end # Helper to wrap data in Provider::Response @@ -175,4 +178,38 @@ class CoinstatsItemsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity assert_match(/No tokens found/, response.body) end + + test "link_exchange filters unexpected connection fields" do + Provider::Coinstats.any_instance.expects(:get_exchanges).returns(success_response([ + { + connectionId: "bitvavo", + name: "Bitvavo", + connectionFields: [ + { key: "apiKey", name: "API Key" }, + { key: "apiSecret", name: "API Secret" } + ] + } + ])).once + + linker_result = CoinstatsItem::ExchangeLinker::Result.new(success?: true, created_count: 0, errors: []) + CoinstatsItem::ExchangeLinker.expects(:new).with( + @coinstats_item, + connection_id: "bitvavo", + connection_fields: { "apiKey" => "key", "apiSecret" => "secret" }, + name: "Bitvavo" + ).returns(stub(link: linker_result)) + + post link_exchange_coinstats_items_url, params: { + coinstats_item_id: @coinstats_item.id, + exchange_connection_id: "bitvavo", + exchange_connection_name: "Bitvavo", + connection_fields: { + apiKey: " key ", + apiSecret: " secret ", + unexpected: "should_not_be_forwarded" + } + } + + assert_redirected_to accounts_path + end end diff --git a/test/models/account/chartable_test.rb b/test/models/account/chartable_test.rb index 302c603b3..103b244b7 100644 --- a/test/models/account/chartable_test.rb +++ b/test/models/account/chartable_test.rb @@ -43,4 +43,106 @@ class Account::ChartableTest < ActiveSupport::TestCase memoized_series2_cash_view = account.balance_series(period: Period.last_90_days, view: :cash_balance) memoized_series2_holdings_view = account.balance_series(period: Period.last_90_days, view: :holdings_balance) end + + test "trims placeholder history for linked investment accounts without trades" do + account = accounts(:investment) + account.entries.destroy_all + account.holdings.destroy_all + + coinstats_item = account.family.coinstats_items.create!(name: "CoinStats", api_key: "test-key") + coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD") + account.account_providers.create!(provider: coinstats_account) + + account.holdings.create!( + security: securities(:aapl), + date: 5.days.ago.to_date, + qty: 1, + price: 100, + amount: 100, + currency: "USD", + account_provider: account.account_providers.last + ) + + raw_series = Series.new( + start_date: 10.days.ago.to_date, + end_date: Date.current, + interval: "1 day", + values: [ + Series::Value.new(date: 10.days.ago.to_date, date_formatted: "", value: Money.new(0, "USD")), + Series::Value.new(date: 9.days.ago.to_date, date_formatted: "", value: Money.new(0, "USD")), + Series::Value.new(date: 8.days.ago.to_date, date_formatted: "", value: Money.new(0, "USD")), + Series::Value.new(date: 5.days.ago.to_date, date_formatted: "", value: Money.new(100, "USD")), + Series::Value.new(date: Date.current, date_formatted: "", value: Money.new(110, "USD")) + ], + favorable_direction: account.favorable_direction + ) + + builder = mock + Balance::ChartSeriesBuilder.expects(:new).returns(builder) + builder.expects(:balance_series).returns(raw_series) + + series = account.balance_series + + assert_equal 5.days.ago.to_date, series.start_date + assert_equal [ 5.days.ago.to_date, Date.current ], series.values.map(&:date) + end + + test "trims unstable provider snapshot history for linked investment accounts without trades" do + account = accounts(:investment) + account.entries.destroy_all + account.holdings.destroy_all + + coinstats_item = account.family.coinstats_items.create!(name: "CoinStats", api_key: "test-key") + coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD") + account.account_providers.create!(provider: coinstats_account) + + account.holdings.create!( + security: securities(:aapl), + date: 5.days.ago.to_date, + qty: 1, + price: 100, + amount: 100, + currency: "USD", + account_provider: account.account_providers.last + ) + account.holdings.create!( + security: securities(:aapl), + date: 4.days.ago.to_date, + qty: 1, + price: 100, + amount: 100, + currency: "USD", + account_provider: account.account_providers.last + ) + account.holdings.create!( + security: securities(:msft), + date: Date.current, + qty: 1, + price: 120, + amount: 120, + currency: "USD", + account_provider: account.account_providers.last + ) + + raw_series = Series.new( + start_date: 5.days.ago.to_date, + end_date: Date.current, + interval: "1 day", + values: [ + Series::Value.new(date: 5.days.ago.to_date, date_formatted: "", value: Money.new(100, "USD")), + Series::Value.new(date: 4.days.ago.to_date, date_formatted: "", value: Money.new(101, "USD")), + Series::Value.new(date: Date.current, date_formatted: "", value: Money.new(120, "USD")) + ], + favorable_direction: account.favorable_direction + ) + + builder = mock + Balance::ChartSeriesBuilder.expects(:new).returns(builder) + builder.expects(:balance_series).returns(raw_series) + + series = account.balance_series + + assert_equal Date.current, series.start_date + assert_equal [ Date.current ], series.values.map(&:date) + end end diff --git a/test/models/account/market_data_importer_test.rb b/test/models/account/market_data_importer_test.rb index eead24bd5..0b3f7066d 100644 --- a/test/models/account/market_data_importer_test.rb +++ b/test/models/account/market_data_importer_test.rb @@ -167,6 +167,58 @@ class Account::MarketDataImporterTest < ActiveSupport::TestCase assert_equal 0, Security::Price.where(security: security, date: trade_date).count end + test "syncs security prices for provider-held securities without trades" do + family = Family.create!(name: "Smith", currency: "USD") + + account = family.accounts.create!( + name: "Brokerage", + currency: "USD", + balance: 0, + accountable: Investment.new + ) + + security = Security.create!(ticker: "PE500", exchange_operating_mic: "XPAR") + + coinstats_item = family.coinstats_items.create!(name: "CoinStats", api_key: "test-key") + coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD") + account_provider = AccountProvider.create!(account: account, provider: coinstats_account) + + account.holdings.create!( + security: security, + date: Date.current, + qty: 1, + price: 100, + amount: 100, + currency: "USD", + account_provider: account_provider + ) + + expected_start_date = account.start_date - SECURITY_PRICE_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date + + @provider.expects(:fetch_security_prices) + .with(symbol: security.ticker, + exchange_operating_mic: security.exchange_operating_mic, + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(security: security, + date: account.start_date, + price: 100, + currency: "USD") + ])) + + @provider.stubs(:fetch_security_info) + .with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic) + .returns(provider_success_response(OpenStruct.new(name: "PE500", logo_url: "logo"))) + + @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([])) + + Account::MarketDataImporter.new(account).import_all + + assert_equal 1, Security::Price.where(security: security, date: account.start_date).count + end + test "handles provider error response gracefully for exchange rates" do family = Family.create!(name: "Smith", currency: "USD") diff --git a/test/models/coinstats_account/processor_test.rb b/test/models/coinstats_account/processor_test.rb index e0e556f96..c2c9f82f2 100644 --- a/test/models/coinstats_account/processor_test.rb +++ b/test/models/coinstats_account/processor_test.rb @@ -47,7 +47,7 @@ class CoinstatsAccount::ProcessorTest < ActiveSupport::TestCase @account.reload assert_equal BigDecimal("5000.50"), @account.balance - assert_equal BigDecimal("5000.50"), @account.cash_balance + assert_equal BigDecimal("0"), @account.cash_balance end test "updates account currency from coinstats account" do diff --git a/test/models/coinstats_account_test.rb b/test/models/coinstats_account_test.rb index a4a024d3c..804f79e33 100644 --- a/test/models/coinstats_account_test.rb +++ b/test/models/coinstats_account_test.rb @@ -290,4 +290,46 @@ class CoinstatsAccountTest < ActiveSupport::TestCase # Verify wallet A no longer exists assert_nil CoinstatsAccount.find_by(id: wallet_a.id) end + + test "portfolio exchange account derives total and cash balances from embedded coins" do + @family.update!(currency: "EUR") + + portfolio_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "EUR", + account_id: "exchange_portfolio:test", + wallet_address: "portfolio-test", + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio-test", + exchange_name: "Bitvavo", + coins: [ + { + coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" }, + count: "0.00335845", + price: { EUR: "57950.0491" } + }, + { + coin: { identifier: "ethereum", symbol: "ETH", name: "Ethereum" }, + count: "0.05580825", + price: { EUR: "1728.952252246" } + }, + { + coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true }, + count: "2.58", + price: { EUR: "1" } + } + ] + } + ) + + assert portfolio_account.exchange_portfolio_account? + refute portfolio_account.fiat_asset? + assert_equal "EUR", portfolio_account.inferred_currency + assert_in_delta 293.69214193130284, portfolio_account.inferred_current_balance.to_f, 0.0001 + assert_in_delta 2.58, portfolio_account.inferred_cash_balance.to_f, 0.0001 + assert_equal 2, portfolio_account.portfolio_non_fiat_coins.size + assert_equal 1, portfolio_account.portfolio_fiat_coins.size + end end diff --git a/test/models/coinstats_entry/processor_test.rb b/test/models/coinstats_entry/processor_test.rb index 26ae19e0a..ab08c36e7 100644 --- a/test/models/coinstats_entry/processor_test.rb +++ b/test/models/coinstats_entry/processor_test.rb @@ -264,4 +264,202 @@ class CoinstatsEntry::ProcessorTest < ActiveSupport::TestCase processor.process end end + + test "restores legacy transaction entry if trade import fails" do + exchange_crypto = Crypto.create! + exchange_account_record = @family.accounts.create!( + accountable: exchange_crypto, + name: "Bitvavo", + balance: 1000, + currency: "USD" + ) + exchange_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "USD", + account_id: "exchange_portfolio:portfolio_123", + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio_123", + coins: [] + } + ) + AccountProvider.create!(account: exchange_account_record, provider: exchange_account) + + legacy_entry = exchange_account_record.entries.create!( + entryable: Transaction.new, + external_id: "coinstats_trade_legacy", + source: "coinstats", + amount: 100, + currency: "USD", + date: Date.new(2025, 1, 15), + name: "Trade BTC" + ) + + transaction_data = { + type: "Trade", + date: "2025-01-15T10:00:00.000Z", + hash: { id: "trade_legacy" }, + transactions: [ + { + items: [ + { coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.1", totalWorth: "100" }, + { coin: { id: "ethereum", symbol: "ETH" }, count: "1.5", totalWorth: "100" } + ] + } + ] + } + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + Account::ProviderImportAdapter.any_instance.expects(:import_trade).raises(StandardError, "boom") + + processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account) + + assert_raises(StandardError) { processor.process } + assert exchange_account_record.entries.exists?(id: legacy_entry.id) + end + + test "exchange trades prefer the disposed asset leg" do + exchange_crypto = Crypto.create! + exchange_account_record = @family.accounts.create!( + accountable: exchange_crypto, + name: "Bitvavo", + balance: 1000, + currency: "USD" + ) + exchange_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "USD", + account_id: "exchange_portfolio:portfolio_123", + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio_123", + coins: [] + } + ) + AccountProvider.create!(account: exchange_account_record, provider: exchange_account) + + transaction_data = { + type: "Trade", + date: "2025-01-15T10:00:00.000Z", + hash: { id: "trade_disposed_asset" }, + transactions: [ + { + items: [ + { coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.00335845", totalWorth: "100" }, + { coin: { id: "ethereum", symbol: "ETH" }, count: "0.05580825", totalWorth: "100" } + ] + } + ] + } + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + + processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account) + processor.process + + entry = exchange_account_record.entries.order(created_at: :desc).first + assert_equal "Trade BTC", entry.name + assert_equal "Sell", entry.trade.investment_activity_label + end + + test "portfolio exchange fallback keeps disposed asset sign when trade import is skipped" do + exchange_crypto = Crypto.create! + exchange_account_record = @family.accounts.create!( + accountable: exchange_crypto, + name: "Bitvavo", + balance: 1000, + currency: "USD" + ) + exchange_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "USD", + account_id: "exchange_portfolio:portfolio_123", + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio_123", + coins: [] + } + ) + AccountProvider.create!(account: exchange_account_record, provider: exchange_account) + + transaction_data = { + type: "Trade", + date: "2025-01-15T10:00:00.000Z", + hash: { id: "trade_fallback_sign" }, + transactions: [ + { + items: [ + { coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.00335845", totalWorth: "100" }, + { coin: { id: "ethereum", symbol: "ETH" }, count: "0.05580825", totalWorth: "100" } + ] + } + ] + } + + Security::Resolver.any_instance.stubs(:resolve).returns(nil) + + processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account) + processor.process + + entry = exchange_account_record.entries.order(created_at: :desc).first + assert_equal BigDecimal("100"), entry.amount + assert_equal "Trade BTC", entry.name + end + + test "preserves protected legacy transaction when migrating exchange trade" do + exchange_crypto = Crypto.create! + exchange_account_record = @family.accounts.create!( + accountable: exchange_crypto, + name: "Bitvavo", + balance: 1000, + currency: "USD" + ) + exchange_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "USD", + account_id: "exchange_portfolio:portfolio_123", + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio_123", + coins: [] + } + ) + AccountProvider.create!(account: exchange_account_record, provider: exchange_account) + + legacy_entry = exchange_account_record.entries.create!( + entryable: Transaction.new, + external_id: "coinstats_trade_protected", + source: "coinstats", + amount: 100, + currency: "USD", + date: Date.new(2025, 1, 15), + name: "Trade BTC" + ) + legacy_entry.mark_user_modified! + + transaction_data = { + type: "Trade", + date: "2025-01-15T10:00:00.000Z", + hash: { id: "trade_protected" }, + transactions: [ + { + items: [ + { coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.00335845", totalWorth: "100" }, + { coin: { id: "ethereum", symbol: "ETH" }, count: "0.05580825", totalWorth: "100" } + ] + } + ] + } + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + + processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account) + + assert_no_difference("Trade.count") { assert_equal legacy_entry, processor.process } + assert_equal "Transaction", legacy_entry.reload.entryable_type + end end diff --git a/test/models/coinstats_item/exchange_linker_test.rb b/test/models/coinstats_item/exchange_linker_test.rb new file mode 100644 index 000000000..bd1e39e0d --- /dev/null +++ b/test/models/coinstats_item/exchange_linker_test.rb @@ -0,0 +1,125 @@ +require "test_helper" + +class CoinstatsItem::ExchangeLinkerTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @family.update!(currency: "EUR") + @coinstats_item = CoinstatsItem.create!( + family: @family, + name: "Test CoinStats Connection", + api_key: "test_api_key_123" + ) + end + + def success_response(data) + Provider::Response.new(success?: true, data: data, error: nil) + end + + test "link creates one exchange portfolio account with embedded coins" do + Provider::Coinstats.any_instance.expects(:exchange_options).returns([ + { + connection_id: "bitvavo", + name: "Bitvavo", + icon: "https://example.com/bitvavo.png", + connection_fields: [ + { key: "apiKey", name: "API Key" }, + { key: "apiSecret", name: "API Secret" } + ] + } + ]) + + Provider::Coinstats.any_instance.expects(:connect_portfolio_exchange) + .with( + connection_id: "bitvavo", + connection_fields: { "apiKey" => "key", "apiSecret" => "secret" }, + name: "Bitvavo Portfolio" + ) + .returns(success_response({ portfolioId: "portfolio_123" })) + + Provider::Coinstats.any_instance.expects(:list_portfolio_coins) + .with(portfolio_id: "portfolio_123") + .returns([ + { + coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" }, + count: "0.00335845", + price: { EUR: "57950.0491" } + }, + { + coin: { identifier: "ethereum", symbol: "ETH", name: "Ethereum" }, + count: "0.05580825", + price: { EUR: "1728.952252246" } + }, + { + coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true }, + count: "2.58", + price: { EUR: "1" } + } + ]) + + @coinstats_item.expects(:sync_later).once + + assert_difference [ "CoinstatsAccount.count", "Account.count", "AccountProvider.count" ], 1 do + result = CoinstatsItem::ExchangeLinker.new( + @coinstats_item, + connection_id: "bitvavo", + connection_fields: { "apiKey" => "key", "apiSecret" => "secret" } + ).link + + assert result.success? + assert_equal 1, result.created_count + end + + @coinstats_item.reload + assert_equal "portfolio_123", @coinstats_item.exchange_portfolio_id + + coinstats_account = @coinstats_item.coinstats_accounts.last + assert coinstats_account.exchange_portfolio_account? + assert_equal "Bitvavo", coinstats_account.name + assert_equal "exchange_portfolio:portfolio_123", coinstats_account.account_id + assert_equal 3, coinstats_account.raw_payload["coins"].size + + account = coinstats_account.account + assert_equal "Bitvavo", account.name + assert_equal "EUR", account.currency + assert_in_delta 293.69214193130284, account.balance.to_f, 0.0001 + assert_in_delta 2.58, account.cash_balance.to_f, 0.0001 + end + + test "link defers local account creation when initial portfolio coin fetch is missing" do + Provider::Coinstats.any_instance.expects(:exchange_options).returns([ + { + connection_id: "bitvavo", + name: "Bitvavo", + icon: "https://example.com/bitvavo.png", + connection_fields: [ + { key: "apiKey", name: "API Key" } + ] + } + ]) + + Provider::Coinstats.any_instance.expects(:connect_portfolio_exchange) + .returns(success_response({ portfolioId: "portfolio_456" })) + + Provider::Coinstats.any_instance.expects(:list_portfolio_coins) + .with(portfolio_id: "portfolio_456") + .returns(nil) + + @coinstats_item.expects(:sync_later).once + + assert_no_difference [ "CoinstatsAccount.count", "Account.count", "AccountProvider.count" ] do + result = CoinstatsItem::ExchangeLinker.new( + @coinstats_item, + connection_id: "bitvavo", + connection_fields: { "apiKey" => "key" } + ).link + + assert result.success? + assert_equal 0, result.created_count + end + + @coinstats_item.reload + assert_equal "portfolio_456", @coinstats_item.exchange_portfolio_id + assert_equal "bitvavo", @coinstats_item.exchange_connection_id + assert_empty @coinstats_item.coinstats_accounts + end +end diff --git a/test/models/coinstats_item/importer_test.rb b/test/models/coinstats_item/importer_test.rb index 3f1de5c37..96ccafc0d 100644 --- a/test/models/coinstats_item/importer_test.rb +++ b/test/models/coinstats_item/importer_test.rb @@ -220,6 +220,121 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase assert_equal 0, result[:transactions_imported] end + test "preserves exchange portfolio snapshot when portfolio coin fetch is missing" do + crypto = Crypto.create! + account = @family.accounts.create!( + accountable: crypto, + name: "Bitvavo", + balance: 250, + cash_balance: 10, + currency: "EUR" + ) + + coinstats_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "EUR", + account_id: "exchange_portfolio:portfolio_123", + wallet_address: "portfolio_123", + current_balance: 250, + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio_123", + connection_id: "bitvavo", + exchange_name: "Bitvavo", + coins: [ + { + coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" }, + count: "0.003", + price: { EUR: "80000" } + }, + { + coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true }, + count: "10", + price: { EUR: "1" } + } + ] + }, + raw_transactions_payload: [] + ) + AccountProvider.create!(account: account, provider: coinstats_account) + + @mock_provider.expects(:sync_exchange).with(portfolio_id: "portfolio_123").returns(success_response({})) + @mock_provider.expects(:list_exchange_transactions) + .with(portfolio_id: "portfolio_123", currency: "USD", from: nil) + .returns([]) + @mock_provider.expects(:list_portfolio_coins) + .with(portfolio_id: "portfolio_123") + .returns(nil) + + importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider) + + assert_no_changes -> { coinstats_account.reload.current_balance.to_f } do + result = importer.import + assert result[:success] + assert_equal 1, result[:accounts_updated] + assert_equal 0, result[:transactions_imported] + end + + reloaded = coinstats_account.reload + assert_equal "portfolio_123", reloaded.raw_payload["portfolio_id"] + assert_equal 2, reloaded.raw_payload["coins"].size + assert_equal 250.0, reloaded.current_balance.to_f + end + + test "writes an empty exchange portfolio snapshot when CoinStats returns an empty portfolio" do + crypto = Crypto.create! + account = @family.accounts.create!( + accountable: crypto, + name: "Bitvavo", + balance: 250, + cash_balance: 10, + currency: "EUR" + ) + + coinstats_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "EUR", + account_id: "exchange_portfolio:portfolio_123", + wallet_address: "portfolio_123", + current_balance: 250, + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio_123", + connection_id: "bitvavo", + exchange_name: "Bitvavo", + coins: [ + { + coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" }, + count: "0.003", + price: { EUR: "80000" } + } + ] + }, + raw_transactions_payload: [] + ) + AccountProvider.create!(account: account, provider: coinstats_account) + + @mock_provider.expects(:sync_exchange).with(portfolio_id: "portfolio_123").returns(success_response({})) + @mock_provider.expects(:list_exchange_transactions) + .with(portfolio_id: "portfolio_123", currency: "USD", from: nil) + .returns([]) + @mock_provider.expects(:list_portfolio_coins) + .with(portfolio_id: "portfolio_123") + .returns([]) + + importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider) + result = importer.import + + assert result[:success] + assert_equal 1, result[:accounts_updated] + + reloaded = coinstats_account.reload + assert_equal 0.0, reloaded.current_balance.to_f + assert_equal [], reloaded.raw_payload["coins"] + end + test "calculates balance from matching token only, not all tokens" do # Create two accounts for different tokens in the same wallet crypto1 = Crypto.create! diff --git a/test/models/holding/materializer_test.rb b/test/models/holding/materializer_test.rb index 4ab68e10f..4d14a26c8 100644 --- a/test/models/holding/materializer_test.rb +++ b/test/models/holding/materializer_test.rb @@ -93,4 +93,34 @@ class Holding::MaterializerTest < ActiveSupport::TestCase assert_equal "calculated", holding.cost_basis_source assert_equal BigDecimal("2750.0"), holding.cost_basis end + + test "preserves calculated history for provider-sourced holdings on reverse materialization" do + coinstats_item = @family.coinstats_items.create!(name: "CoinStats", api_key: "test-key") + coinstats_account = coinstats_item.coinstats_accounts.create!( + name: "Brokerage", + currency: "USD" + ) + account_provider = AccountProvider.create!(account: @account, provider: coinstats_account) + + Holding.create!( + account: @account, + security: @aapl, + qty: 10, + price: 200, + amount: 2000, + currency: "USD", + date: Date.current, + account_provider: account_provider + ) + + Holding::Materializer.new(@account, strategy: :reverse).materialize_holdings + + today_holding = @account.holdings.find_by!(security: @aapl, date: Date.current, currency: "USD") + yesterday_holding = @account.holdings.find_by!(security: @aapl, date: Date.yesterday, currency: "USD") + + assert_equal account_provider.id, today_holding.account_provider_id + assert_nil yesterday_holding.account_provider_id + assert_equal BigDecimal("10"), yesterday_holding.qty + assert_equal yesterday_holding.qty * yesterday_holding.price, yesterday_holding.amount + end end diff --git a/test/models/holding/portfolio_snapshot_test.rb b/test/models/holding/portfolio_snapshot_test.rb index 624e6086e..341f7eaa3 100644 --- a/test/models/holding/portfolio_snapshot_test.rb +++ b/test/models/holding/portfolio_snapshot_test.rb @@ -47,4 +47,41 @@ class Holding::PortfolioSnapshotTest < ActiveSupport::TestCase assert_equal 1, portfolio.size assert_equal 0, portfolio[@aapl.id] end + + test "prefers the latest provider snapshot over newer calculated holdings" do + @account.holdings.destroy_all + @account.entries.destroy_all + + create_trade(@aapl, account: @account, qty: 10, price: 100, date: 5.days.ago) + create_trade(@msft, account: @account, qty: 5, price: 200, date: 5.days.ago) + + coinstats_item = @account.family.coinstats_items.create!(name: "CoinStats", api_key: "test-key") + coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD") + account_provider = AccountProvider.create!(account: @account, provider: coinstats_account) + + @account.holdings.create!( + security: @aapl, + date: 1.day.ago, + qty: 10, + price: 100, + amount: 1000, + currency: "USD", + account_provider: account_provider + ) + + @account.holdings.create!( + security: @msft, + date: Date.current, + qty: 5, + price: 200, + amount: 1000, + currency: "USD" + ) + + portfolio = Holding::PortfolioSnapshot.new(@account).to_h + + assert_equal 2, portfolio.size + assert_equal 10, portfolio[@aapl.id] + assert_equal 0, portfolio[@msft.id] + end end