Add CoinStats exchange portfolio sync and normalize linked investment charts (#1308)

* [FEATURE] Add CoinStats exchange portfolios and normalize linked investment charts

* [BUGFIX] Fix CoinStats PR regressions

* [BUGFIX] Fix CoinStats PR review findings

* [BUGFIX] Address follow-up CoinStats PR feedback

* [REFACTO] Extract CoinStats exchange account helpers

* [BUGFIX] Batch linked CoinStats chart normalization

* [BUGFIX] Fix CoinStats processor lint

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Anas Limouri
2026-04-01 20:25:06 +02:00
committed by GitHub
co-authored by Juan José Mata
parent f63630c0fa
commit a90f9b7317
44 changed files with 2857 additions and 225 deletions
+1 -1
View File
@@ -38,7 +38,7 @@
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
<div class="px-4">
<%= 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 } %>
</div>
<div class="h-64 pb-4">
+11
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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.
+89 -3
View File
@@ -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
@@ -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"
}
}
+4
View File
@@ -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
+8 -2
View File
@@ -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
+12 -5
View File
@@ -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?
@@ -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
+86
View File
@@ -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
+9 -22
View File
@@ -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
@@ -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
@@ -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
+15 -1
View File
@@ -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.
@@ -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
@@ -87,6 +87,7 @@ class CoinstatsAccount::Transactions::Processor
# @return [Array<Hash>] 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").
@@ -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
+246 -26
View File
@@ -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
+7 -2
View File
@@ -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
@@ -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
@@ -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
+286 -22
View File
@@ -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<CoinstatsAccount>] Accounts to fetch balances for
# @return [Array<Hash>, 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<CoinstatsAccount>] Accounts to fetch transactions for
# @return [Array<Hash>, 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
+6 -18
View File
@@ -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,
+48 -23
View File
@@ -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)
+15 -5
View File
@@ -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
+284 -9
View File
@@ -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
+111 -60
View File
@@ -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? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden mb-3">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<% end %>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden mb-3">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<% 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 %>
<div class="space-y-5">
<section class="rounded-xl border border-primary bg-container p-4 space-y-3">
<div class="space-y-1">
<h3 class="text-sm font-medium text-primary"><%= t(".link_wallet_title") %></h3>
<p class="text-sm text-secondary"><%= t(".link_wallet_description") %></p>
</div>
<%= 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 %>
<div class="flex justify-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" %>
</div>
<% end %>
</section>
<div class="flex justify-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" %>
<section class="rounded-xl border border-primary bg-container p-4 space-y-3"
data-controller="coinstats-exchange-fields"
data-coinstats-exchange-fields-exchanges-value="<%= html_escape(exchanges.to_json) %>"
data-coinstats-exchange-fields-initial-connection-id-value="<%= exchange_connection_id %>"
data-coinstats-exchange-fields-initial-fields-value="<%= html_escape(exchange_connection_fields.to_json) %>">
<div class="space-y-1">
<h3 class="text-sm font-medium text-primary"><%= t(".link_exchange_title") %></h3>
<p class="text-sm text-secondary"><%= t(".link_exchange_description") %></p>
<p class="text-xs text-secondary"><%= t(".link_exchange_note") %></p>
</div>
<% end %>
<% else %>
<div class="space-y-4">
<div class="flex items-start gap-3">
<%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %>
<div class="text-sm text-secondary">
<p class="font-medium text-primary mb-2"><%= t(".not_configured_title") %></p>
<p><%= t(".not_configured_message") %></p>
</div>
</div>
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
<ol class="list-decimal list-inside space-y-1 text-secondary">
<li><%= t(".not_configured_step1_html").html_safe %></li>
<li><%= t(".not_configured_step2_html").html_safe %></li>
<li><%= t(".not_configured_step3_html").html_safe %></li>
</ol>
</div>
<%= 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" } %>
<div class="mt-4">
<%= 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 %>
</div>
<%= 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"
} %>
<div data-coinstats-exchange-fields-target="fields" class="space-y-3"></div>
<div class="flex justify-end">
<%= 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" %>
</div>
<% end %>
</section>
</div>
<% else %>
<div class="space-y-4">
<div class="flex items-start gap-3">
<%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %>
<div class="text-sm text-secondary">
<p class="font-medium text-primary mb-2"><%= t(".not_configured_title") %></p>
<p><%= t(".not_configured_message") %></p>
</div>
<% end %>
</div>
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
<ol class="list-decimal list-inside space-y-1 text-secondary">
<li><%= t(".not_configured_step1_html").html_safe %></li>
<li><%= t(".not_configured_step2_html").html_safe %></li>
<li><%= t(".not_configured_step3_html").html_safe %></li>
</ol>
</div>
<div class="mt-4">
<%= 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 %>
</div>
</div>
<% end %>
<% end %>
<% end %>
<% end %>
+3 -3
View File
@@ -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...
+18 -4
View File
@@ -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 <strong>Settings → Providers</strong>
not_configured_step2_html: Locate the <strong>CoinStats</strong> provider
not_configured_step3_html: Follow the provided <strong>setup Instructions</strong> to complete provider configuration
@@ -35,7 +49,7 @@ en:
setup_instructions: "Setup Instructions:"
step1_html: Visit the <a href="https://openapi.coinstats.app/" class="link" target="_blank" rel="noopener noreferrer">CoinStats Public API Dashboard</a> to obtain an API key.
step2: Enter your API key below and click Configure.
step3_html: After a successful connection, visit the <a href="%{accounts_url}" class="link" data-turbo-frame="_top">Accounts</a> tab to set up crypto wallets.
step3_html: After a successful connection, visit the <a href="%{accounts_url}" class="link" data-turbo-frame="_top">Accounts</a> tab to set up your crypto accounts.
api_key_label: API Key
api_key_placeholder: Required
configure: Configure
+1
View File
@@ -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
@@ -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
@@ -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
Generated
+6 -2
View File
@@ -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
@@ -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
+102
View File
@@ -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
@@ -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")
@@ -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
+42
View File
@@ -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
@@ -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
@@ -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
+115
View File
@@ -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!
+30
View File
@@ -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
@@ -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