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? %>
-
- <% end %>
+ <% error_msg = local_assigns[:error_message] || @error_message %>
+ <% if error_msg.present? %>
+
+ <% 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") %>
-
-
-
-
- - <%= t(".not_configured_step1_html").html_safe %>
- - <%= t(".not_configured_step2_html").html_safe %>
- - <%= t(".not_configured_step3_html").html_safe %>
-
-
+ <%= 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 %>
+
+
+
+
+ - <%= t(".not_configured_step1_html").html_safe %>
+ - <%= t(".not_configured_step2_html").html_safe %>
+ - <%= t(".not_configured_step3_html").html_safe %>
+
+
+
+
+ <%= 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