mirror of
https://github.com/we-promise/sure.git
synced 2026-08-12 11:10:20 +00:00
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:
co-authored by
Juan José Mata
parent
f63630c0fa
commit
a90f9b7317
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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...
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user