Add (beta) CoinStats Crypto Wallet Integration with Balance and Transaction Syncing (#512)

* Feat(CoinStats): Scaffold implementation, not yet functional

* Feat(CoinStats): Implement crypto wallet balance and transactions

* Feat(CoinStats): Add tests, Minor improvements

* Feat(CoinStats): Utilize bulk fetch API endpoints

* Feat(CoinStats): Migrate strings to i8n

* Feat(CoinStats): Fix error handling in wallet link modal

* Feat(CoinStats): Implement hourly provider sync job

* Feat(CoinStats): Generate docstrings

* Fix(CoinStats): Validate API Key on provider update

* Fix(Providers): Safely handle race condition in merchance creation

* Fix(CoinStats): Don't catch system signals in account processor

* Fix(CoinStats): Preload before iterating accounts

* Fix(CoinStats): Add no opener / referrer to API dashboard link

* Fix(CoinStats): Use strict matching for symbols

* Fix(CoinStats): Remove dead code in transactions importer

* Fix(CoinStats): Avoid transaction fallback ID collisions

* Fix(CoinStats): Improve Blockchains fetch error handling

* Fix(CoinStats): Enforce NOT NULL constraint for API Key schema

* Fix(CoinStats): Migrate sync status strings to i8n

* Fix(CoinStats): Use class name rather than hardcoded string

* Fix(CoinStats): Use account currency rather than hardcoded USD

* Fix(CoinStats): Migrate from standalone to Provider class

* Fix(CoinStats): Fix test failures due to string changes
This commit is contained in:
Ethan
2026-01-07 08:59:04 -06:00
committed by GitHub
parent 42b94947bf
commit 3b4ab735b0
54 changed files with 5093 additions and 23 deletions

View File

@@ -10,6 +10,7 @@ class AccountsController < ApplicationController
@simplefin_items = family.simplefin_items.ordered.includes(:syncs)
@lunchflow_items = family.lunchflow_items.ordered
@enable_banking_items = family.enable_banking_items.ordered.includes(:syncs)
@coinstats_items = family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs)
# Precompute per-item maps to avoid queries in the view
@simplefin_sync_stats_map = {}

View File

@@ -0,0 +1,169 @@
class CoinstatsItemsController < ApplicationController
before_action :set_coinstats_item, only: [ :show, :edit, :update, :destroy, :sync ]
def index
@coinstats_items = Current.family.coinstats_items.ordered
end
def show
end
def new
@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)
end
def create
@coinstats_item = Current.family.coinstats_items.build(coinstats_item_params)
@coinstats_item.name ||= t(".default_name")
# Validate API key before saving
unless validate_api_key(@coinstats_item.api_key)
return render_error_response(@coinstats_item.errors.full_messages.join(", "))
end
if @coinstats_item.save
render_success_response(".success")
else
render_error_response(@coinstats_item.errors.full_messages.join(", "))
end
end
def edit
end
def update
# Validate API key if it's being changed
unless validate_api_key(coinstats_item_params[:api_key])
return render_error_response(@coinstats_item.errors.full_messages.join(", "))
end
if @coinstats_item.update(coinstats_item_params)
render_success_response(".success")
else
render_error_response(@coinstats_item.errors.full_messages.join(", "))
end
end
def destroy
@coinstats_item.destroy_later
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
def sync
unless @coinstats_item.syncing?
@coinstats_item.sync_later
end
respond_to do |format|
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end
def link_wallet
coinstats_item_id = params[:coinstats_item_id].presence
@address = params[:address]&.to_s&.strip.presence
@blockchain = params[:blockchain]&.to_s&.strip.presence
unless coinstats_item_id && @address && @blockchain
return render_link_wallet_error(t(".missing_params"))
end
@coinstats_item = Current.family.coinstats_items.find(coinstats_item_id)
result = CoinstatsItem::WalletLinker.new(@coinstats_item, address: @address, blockchain: @blockchain).link
if result.success?
redirect_to accounts_path, notice: t(".success", count: result.created_count), status: :see_other
else
error_msg = result.errors.join("; ").presence || t(".failed")
render_link_wallet_error(error_msg)
end
rescue Provider::Coinstats::Error => e
render_link_wallet_error(t(".error", message: e.message))
rescue => e
Rails.logger.error("CoinStats link wallet error: #{e.class} - #{e.message}")
render_link_wallet_error(t(".error", message: e.message))
end
private
def set_coinstats_item
@coinstats_item = Current.family.coinstats_items.find(params[:id])
end
def coinstats_item_params
params.require(:coinstats_item).permit(
:name,
:sync_start_date,
:api_key
)
end
def validate_api_key(api_key)
return true if api_key.blank?
response = Provider::Coinstats.new(api_key).get_blockchains
if response.success?
true
else
@coinstats_item.errors.add(:api_key, t("coinstats_items.create.errors.validation_failed", message: response.error&.message))
false
end
rescue => e
@coinstats_item.errors.add(:api_key, t("coinstats_items.create.errors.validation_failed", message: e.message))
false
end
def render_error_response(error_message)
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"coinstats-providers-panel",
partial: "settings/providers/coinstats_panel",
locals: { error_message: error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: error_message, status: :unprocessable_entity
end
end
def render_success_response(notice_key)
if turbo_frame_request?
flash.now[:notice] = t(notice_key, default: notice_key.to_s.humanize)
@coinstats_items = Current.family.coinstats_items.ordered
render turbo_stream: [
turbo_stream.replace(
"coinstats-providers-panel",
partial: "settings/providers/coinstats_panel",
locals: { coinstats_items: @coinstats_items }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(notice_key), status: :see_other
end
end
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)
render :new, status: :unprocessable_entity
end
def fetch_blockchain_options(coinstats_item)
return [] unless coinstats_item&.api_key.present?
Provider::Coinstats.new(coinstats_item.api_key).blockchain_options
rescue Provider::Coinstats::Error => e
Rails.logger.error("CoinStats blockchain fetch failed: item_id=#{coinstats_item.id} error=#{e.class} message=#{e.message}")
flash.now[:alert] = t("coinstats_items.new.blockchain_fetch_error")
[]
rescue StandardError => e
Rails.logger.error("CoinStats blockchain fetch failed: item_id=#{coinstats_item.id} error=#{e.class} message=#{e.message}")
flash.now[:alert] = t("coinstats_items.new.blockchain_fetch_error")
[]
end
end

View File

@@ -8,7 +8,7 @@ class Settings::ProvidersController < ApplicationController
def show
@breadcrumbs = [
[ "Home", root_path ],
[ "Bank Sync Providers", nil ]
[ "Sync Providers", nil ]
]
prepare_show_context
@@ -124,13 +124,14 @@ class Settings::ProvidersController < ApplicationController
Provider::Factory.ensure_adapters_loaded
@provider_configurations = Provider::ConfigurationRegistry.all.reject do |config|
config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \
config.provider_key.to_s.casecmp("enable_banking").zero?
config.provider_key.to_s.casecmp("enable_banking").zero? || \
config.provider_key.to_s.casecmp("coinstats").zero?
end
# Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials
@simplefin_items = Current.family.simplefin_items.where.not(access_url: [ nil, "" ]).ordered.select(:id)
@lunchflow_items = Current.family.lunchflow_items.where.not(api_key: [ nil, "" ]).ordered.select(:id)
# Enable Banking panel needs session info for status display
@enable_banking_items = Current.family.enable_banking_items.ordered
@enable_banking_items = Current.family.enable_banking_items.ordered # Enable Banking panel needs session info for status display
@coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display
end
end

View File

@@ -0,0 +1,27 @@
class SyncHourlyJob < ApplicationJob
queue_as :scheduled
sidekiq_options lock: :until_executed, on_conflict: :log
# Provider item classes that opt-in to hourly syncing
HOURLY_SYNCABLES = [
CoinstatsItem # https://coinstats.app/api-docs/rate-limits#plan-limits
].freeze
def perform
Rails.logger.info("Starting hourly sync")
HOURLY_SYNCABLES.each do |syncable_class|
sync_items(syncable_class)
end
Rails.logger.info("Completed hourly sync")
end
private
def sync_items(syncable_class)
syncable_class.active.find_each do |item|
item.sync_later
rescue => e
Rails.logger.error("Failed to sync #{syncable_class.name} #{item.id}: #{e.message}")
end
end
end

View File

@@ -196,6 +196,10 @@ class Account < ApplicationRecord
read_attribute(:institution_domain).presence || provider&.institution_domain
end
def logo_url
provider&.logo_url
end
def destroy_later
mark_for_deletion!
DestroyJob.perform_later(self)

View File

@@ -93,14 +93,30 @@ class Account::ProviderImportAdapter
def find_or_create_merchant(provider_merchant_id:, name:, source:, website_url: nil, logo_url: nil)
return nil unless provider_merchant_id.present? && name.present?
ProviderMerchant.find_or_create_by!(
provider_merchant_id: provider_merchant_id,
source: source
) do |m|
m.name = name
m.website_url = website_url
m.logo_url = logo_url
# ProviderMerchant has a unique index on [source, name], so find by those first
# This handles cases where the provider_merchant_id format changes
merchant = begin
ProviderMerchant.find_or_create_by!(source: source, name: name) do |m|
m.provider_merchant_id = provider_merchant_id
m.website_url = website_url
m.logo_url = logo_url
end
rescue ActiveRecord::RecordNotUnique
# Handle race condition where another process created the record
ProviderMerchant.find_by(source: source, name: name)
end
# Update provider_merchant_id if it changed (e.g., format update)
if merchant.provider_merchant_id != provider_merchant_id
merchant.update!(provider_merchant_id: provider_merchant_id)
end
# Update logo if provided and merchant doesn't have one (or has a different one)
if logo_url.present? && merchant.logo_url != logo_url
merchant.update!(logo_url: logo_url)
end
merchant
end
# Updates account balance from provider data

View File

@@ -7,6 +7,11 @@ class AccountProvider < ApplicationRecord
validates :account_id, uniqueness: { scope: :provider_type }
validates :provider_id, uniqueness: { scope: :provider_type }
# When unlinking a CoinStats account, also destroy the CoinstatsAccount record
# so it doesn't remain orphaned and count as "needs setup".
# Other providers may legitimately enter a "needs setup" state.
after_destroy :destroy_coinstats_provider_account, if: :coinstats_provider?
# Returns the provider adapter for this connection
def adapter
Provider::Factory.create_adapter(provider, account: account)
@@ -17,4 +22,14 @@ class AccountProvider < ApplicationRecord
def provider_name
adapter&.provider_name || provider_type.underscore
end
private
def coinstats_provider?
provider_type == "CoinstatsAccount"
end
def destroy_coinstats_provider_account
provider&.destroy
end
end

View File

@@ -0,0 +1,71 @@
# Represents a single crypto token/coin within a CoinStats wallet.
# Each wallet address may have multiple CoinstatsAccounts (one per token).
class CoinstatsAccount < ApplicationRecord
include CurrencyNormalizable
belongs_to :coinstats_item
# Association through account_providers (standard pattern for all providers)
has_one :account_provider, as: :provider, dependent: :destroy
has_one :account, through: :account_provider, source: :account
validates :name, :currency, presence: true
validates :account_id, uniqueness: { scope: :coinstats_item_id, allow_nil: true }
# Alias for compatibility with provider adapter pattern
alias_method :current_account, :account
# 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",
name: snapshot[:name],
account_status: snapshot[:status],
provider: snapshot[:provider],
institution_metadata: {
logo: snapshot[:institution_logo]
}.compact,
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
update!(attrs)
end
# 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] || []
elsif transactions_snapshot.is_a?(Array)
transactions_snapshot
else
[]
end
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

View File

@@ -0,0 +1,68 @@
# Processes a CoinStats account to update balance and import transactions.
# Updates the linked Account balance and delegates to transaction processor.
class CoinstatsAccount::Processor
include CurrencyNormalizable
attr_reader :coinstats_account
# @param coinstats_account [CoinstatsAccount] Account to process
def initialize(coinstats_account)
@coinstats_account = coinstats_account
end
# Updates account balance and processes transactions.
# Skips processing if no linked account exists.
def process
unless coinstats_account.current_account.present?
Rails.logger.info "CoinstatsAccount::Processor - No linked account for coinstats_account #{coinstats_account.id}, skipping processing"
return
end
Rails.logger.info "CoinstatsAccount::Processor - Processing coinstats_account #{coinstats_account.id}"
begin
process_account!
rescue StandardError => e
Rails.logger.error "CoinstatsAccount::Processor - Failed to process account #{coinstats_account.id}: #{e.message}"
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
report_exception(e, "account")
raise
end
process_transactions
end
private
# 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"
account.update!(
balance: balance,
cash_balance: balance,
currency: currency
)
end
# Delegates transaction processing to the specialized processor.
def process_transactions
CoinstatsAccount::Transactions::Processor.new(coinstats_account).process
rescue StandardError => e
report_exception(e, "transactions")
end
# Reports errors to Sentry with context tags.
# @param error [Exception] The error to report
# @param context [String] Processing context (e.g., "account", "transactions")
def report_exception(error, context)
Sentry.capture_exception(error) do |scope|
scope.set_tags(
coinstats_account_id: coinstats_account.id,
context: context
)
end
end
end

View File

@@ -0,0 +1,138 @@
# Processes stored transactions for a CoinStats account.
# Filters transactions by token and delegates to entry processor.
class CoinstatsAccount::Transactions::Processor
include CoinstatsTransactionIdentifiable
attr_reader :coinstats_account
# @param coinstats_account [CoinstatsAccount] Account with transactions to process
def initialize(coinstats_account)
@coinstats_account = coinstats_account
end
# Processes all stored transactions for this account.
# Filters to relevant token and imports each transaction.
# @return [Hash] Result with :success, :total, :imported, :failed, :errors
def process
unless coinstats_account.raw_transactions_payload.present?
Rails.logger.info "CoinstatsAccount::Transactions::Processor - No transactions in raw_transactions_payload for coinstats_account #{coinstats_account.id}"
return { success: true, total: 0, imported: 0, failed: 0, errors: [] }
end
# Filter transactions to only include ones for this specific token
# Multiple coinstats_accounts can share the same wallet address (one per token)
# but we only want to process transactions relevant to this token
relevant_transactions = filter_transactions_for_account(coinstats_account.raw_transactions_payload)
total_count = relevant_transactions.count
Rails.logger.info "CoinstatsAccount::Transactions::Processor - Processing #{total_count} transactions for coinstats_account #{coinstats_account.id} (#{coinstats_account.name})"
imported_count = 0
failed_count = 0
errors = []
relevant_transactions.each_with_index do |transaction_data, index|
begin
result = CoinstatsEntry::Processor.new(
transaction_data,
coinstats_account: coinstats_account
).process
if result.nil?
failed_count += 1
transaction_id = extract_coinstats_transaction_id(transaction_data)
errors << { index: index, transaction_id: transaction_id, error: "No linked account" }
else
imported_count += 1
end
rescue ArgumentError => e
failed_count += 1
transaction_id = extract_coinstats_transaction_id(transaction_data)
error_message = "Validation error: #{e.message}"
Rails.logger.error "CoinstatsAccount::Transactions::Processor - #{error_message} (transaction #{transaction_id})"
errors << { index: index, transaction_id: transaction_id, error: error_message }
rescue => e
failed_count += 1
transaction_id = extract_coinstats_transaction_id(transaction_data)
error_message = "#{e.class}: #{e.message}"
Rails.logger.error "CoinstatsAccount::Transactions::Processor - Error processing transaction #{transaction_id}: #{error_message}"
Rails.logger.error e.backtrace.join("\n")
errors << { index: index, transaction_id: transaction_id, error: error_message }
end
end
result = {
success: failed_count == 0,
total: total_count,
imported: imported_count,
failed: failed_count,
errors: errors
}
if failed_count > 0
Rails.logger.warn "CoinstatsAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions"
else
Rails.logger.info "CoinstatsAccount::Transactions::Processor - Successfully processed #{imported_count} transactions"
end
result
end
private
# Filters transactions to only include ones for this specific token.
# CoinStats returns all wallet transactions, but each CoinstatsAccount
# represents a single token, so we filter by matching coin ID or symbol.
# @param transactions [Array<Hash>] Raw transactions from storage
# @return [Array<Hash>] Transactions matching this account's token
def filter_transactions_for_account(transactions)
return [] unless transactions.present?
return transactions unless coinstats_account.account_id.present?
account_id = coinstats_account.account_id.to_s.downcase
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_symbol = tx.dig(:coinData, :symbol)&.to_s&.downcase
# 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 ||
(coin_symbol.present? && symbol_matches_name?(coin_symbol, coinstats_account.name))
end
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").
#
# @param symbol [String] The coin symbol to match (already downcased)
# @param name [String, nil] The account name to match against
# @return [Boolean] true if symbol matches name precisely
def symbol_matches_name?(symbol, name)
return false if name.blank?
normalized_name = name.to_s.downcase
# Match symbol as a whole word using word boundaries, or within parentheses.
# Examples that SHOULD match:
# - "ETH" matches "ETH Wallet", "My ETH", "Ethereum (ETH)"
# - "BTC" matches "BTC", "(BTC) Savings", "Bitcoin (BTC)"
# Examples that should NOT match:
# - "ETH" should NOT match "Ethereum Classic" (symbol is "ETC")
# - "ETH" should NOT match "WETH Wrapped" (different token)
# - "BTC" should NOT match "BTCB" (different token)
word_boundary_pattern = /\b#{Regexp.escape(symbol)}\b/
parenthesized_pattern = /\(#{Regexp.escape(symbol)}\)/
word_boundary_pattern.match?(normalized_name) || parenthesized_pattern.match?(normalized_name)
end
end

View File

@@ -0,0 +1,270 @@
# Processes a single CoinStats transaction into a local Transaction record.
# Extracts amount, date, and metadata from the CoinStats API format.
#
# CoinStats API transaction structure (from /wallet/transactions endpoint):
# {
# type: "Sent" | "Received" | "Swap" | ...,
# date: "2025-06-07T11:58:11.000Z",
# coinData: { count: -0.00636637, symbol: "ETH", currentValue: 29.21 },
# profitLoss: { profit: -13.41, profitPercent: -84.44, currentValue: 29.21 },
# hash: { id: "0x...", explorerUrl: "https://etherscan.io/tx/0x..." },
# fee: { coin: { id, name, symbol, icon }, count: 0.00003, totalWorth: 0.08 },
# transactions: [{ action: "Sent", items: [{ id, count, totalWorth, coin: {...} }] }]
# }
class CoinstatsEntry::Processor
include CoinstatsTransactionIdentifiable
# @param coinstats_transaction [Hash] Raw transaction data from API
# @param coinstats_account [CoinstatsAccount] Parent account for context
def initialize(coinstats_transaction, coinstats_account:)
@coinstats_transaction = coinstats_transaction
@coinstats_account = coinstats_account
end
# Imports the transaction into the linked account.
# @return [Transaction, nil] Created transaction or nil if no linked account
# @raise [ArgumentError] If transaction data is invalid
# @raise [StandardError] If import fails
def process
unless account.present?
Rails.logger.warn "CoinstatsEntry::Processor - No linked account for coinstats_account #{coinstats_account.id}, skipping transaction #{external_id}"
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
)
rescue ArgumentError => e
Rails.logger.error "CoinstatsEntry::Processor - Validation error for transaction #{external_id rescue 'unknown'}: #{e.message}"
raise
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
Rails.logger.error "CoinstatsEntry::Processor - Failed to save transaction #{external_id rescue 'unknown'}: #{e.message}"
raise StandardError.new("Failed to import transaction: #{e.message}")
rescue => e
Rails.logger.error "CoinstatsEntry::Processor - Unexpected error processing transaction #{external_id rescue 'unknown'}: #{e.class} - #{e.message}"
Rails.logger.error e.backtrace.join("\n")
raise StandardError.new("Unexpected error importing transaction: #{e.message}")
end
private
attr_reader :coinstats_transaction, :coinstats_account
def extra_metadata
cs = {}
# Store transaction hash and explorer URL
if hash_data.present?
cs["transaction_hash"] = hash_data[:id] if hash_data[:id].present?
cs["explorer_url"] = hash_data[:explorerUrl] if hash_data[:explorerUrl].present?
end
# Store transaction type
cs["transaction_type"] = transaction_type if transaction_type.present?
# Store coin/token info
if coin_data.present?
cs["symbol"] = coin_data[:symbol] if coin_data[:symbol].present?
cs["count"] = coin_data[:count] if coin_data[:count].present?
end
# Store profit/loss info
if profit_loss.present?
cs["profit"] = profit_loss[:profit] if profit_loss[:profit].present?
cs["profit_percent"] = profit_loss[:profitPercent] if profit_loss[:profitPercent].present?
end
# Store fee info
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?
end
return nil if cs.empty?
{ "coinstats" => cs }
end
def import_adapter
@import_adapter ||= Account::ProviderImportAdapter.new(account)
end
def account
coinstats_account.current_account
end
def data
@data ||= coinstats_transaction.with_indifferent_access
end
# Helper accessors for nested data structures
def hash_data
@hash_data ||= (data[:hash] || {}).with_indifferent_access
end
def coin_data
@coin_data ||= (data[:coinData] || {}).with_indifferent_access
end
def profit_loss
@profit_loss ||= (data[:profitLoss] || {}).with_indifferent_access
end
def fee_data
@fee_data ||= (data[:fee] || {}).with_indifferent_access
end
def transactions_data
@transactions_data ||= data[:transactions] || []
end
def transaction_type
data[:type]
end
def external_id
tx_id = extract_coinstats_transaction_id(data)
raise ArgumentError, "CoinStats transaction missing unique identifier: #{data.inspect}" unless tx_id.present?
"coinstats_#{tx_id}"
end
def name
tx_type = transaction_type || "Transaction"
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)
if symbol.present?
"#{tx_type} #{symbol}"
elsif coin_name.present?
"#{tx_type} #{coin_name}"
else
tx_type.to_s
end
end
def amount
# Use currentValue from coinData (USD value) or profitLoss
usd_value = coin_data[:currentValue] || profit_loss[:currentValue] || 0
parsed_amount = case usd_value
when String
BigDecimal(usd_value)
when Numeric
BigDecimal(usd_value.to_s)
else
BigDecimal("0")
end
absolute_amount = parsed_amount.abs
# App convention: negative amount = income (inflow), positive amount = expense (outflow)
# coinData.count is negative for outgoing transactions
coin_count = coin_data[:count] || 0
if coin_count.to_f < 0 || outgoing_transaction_type?
# Outgoing transaction = expense = positive
absolute_amount
else
# Incoming transaction = income = negative
-absolute_amount
end
rescue ArgumentError => e
Rails.logger.error "Failed to parse CoinStats transaction amount: #{usd_value.inspect} - #{e.message}"
raise
end
def outgoing_transaction_type?
tx_type = (transaction_type || "").to_s.downcase
%w[sent send sell withdraw transfer_out swap_out].include?(tx_type)
end
def currency
# CoinStats values are always in USD
"USD"
end
def date
# CoinStats returns date as ISO 8601 string (e.g., "2025-06-07T11:58:11.000Z")
timestamp = data[:date]
raise ArgumentError, "CoinStats transaction missing date" unless timestamp.present?
case timestamp
when Integer, Float
Time.at(timestamp).to_date
when String
Time.parse(timestamp).to_date
when Time, DateTime
timestamp.to_date
when Date
timestamp
else
Rails.logger.error("CoinStats transaction has invalid date format: #{timestamp.inspect}")
raise ArgumentError, "Invalid date format: #{timestamp.inspect}"
end
rescue ArgumentError, TypeError => e
Rails.logger.error("CoinStats transaction date parsing failed: #{e.message}")
raise ArgumentError, "Invalid date format: #{timestamp.inspect}"
end
def merchant
# Use the coinstats_account as the merchant source for consistency
# All transactions from the same account will have the same merchant and logo
merchant_name = coinstats_account.name
return nil unless merchant_name.present?
# Use the account's logo (token icon) for the merchant
logo = coinstats_account.institution_metadata&.dig("logo")
# Use the coinstats_account ID to ensure consistent merchant per account
@merchant ||= import_adapter.find_or_create_merchant(
provider_merchant_id: "coinstats_account_#{coinstats_account.id}",
name: merchant_name,
source: "coinstats",
logo_url: logo
)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "CoinstatsEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}"
nil
end
def notes
parts = []
# Include coin/token details with count
symbol = coin_data[:symbol]
count = coin_data[:count]
if count.present? && symbol.present?
parts << "#{count} #{symbol}"
end
# Include fee info
if fee_data[:count].present? && fee_data.dig(:coin, :symbol).present?
parts << "Fee: #{fee_data[:count]} #{fee_data.dig(:coin, :symbol)}"
end
# Include profit/loss info
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}%)"
end
# Include explorer URL for reference
if hash_data[:explorerUrl].present?
parts << "Explorer: #{hash_data[:explorerUrl]}"
end
parts.presence&.join(" | ")
end
end

View File

@@ -0,0 +1,150 @@
# Represents a CoinStats API connection for a family.
# Stores credentials and manages associated crypto wallet accounts.
class CoinstatsItem < ApplicationRecord
include Syncable, Provided, Unlinking
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
# Checks if ActiveRecord Encryption is properly configured.
# @return [Boolean] true if encryption keys are available
def self.encryption_ready?
creds_ready = Rails.application.credentials.active_record_encryption.present?
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
creds_ready || env_ready
end
# Encrypt sensitive credentials if ActiveRecord encryption is configured
encrypts :api_key, deterministic: true if encryption_ready?
validates :name, presence: true
validates :api_key, presence: true
belongs_to :family
has_one_attached :logo
has_many :coinstats_accounts, dependent: :destroy
has_many :accounts, through: :coinstats_accounts
scope :active, -> { where(scheduled_for_deletion: false) }
scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) }
# Schedules this item for async deletion.
def destroy_later
update!(scheduled_for_deletion: true)
DestroyJob.perform_later(self)
end
# Fetches latest wallet data from CoinStats API and updates local records.
# @raise [StandardError] if provider is not configured or import fails
def import_latest_coinstats_data
provider = coinstats_provider
unless provider
Rails.logger.error "CoinstatsItem #{id} - Cannot import: CoinStats provider is not configured"
raise StandardError.new("CoinStats provider is not configured")
end
CoinstatsItem::Importer.new(self, coinstats_provider: provider).import
rescue => e
Rails.logger.error "CoinstatsItem #{id} - Failed to import data: #{e.message}"
raise
end
# Processes holdings for all linked visible accounts.
# @return [Array<Hash>] Results with success status per account
def process_accounts
return [] if coinstats_accounts.empty?
results = []
coinstats_accounts.includes(:account).joins(:account).merge(Account.visible).each do |coinstats_account|
begin
result = CoinstatsAccount::Processor.new(coinstats_account).process
results << { coinstats_account_id: coinstats_account.id, success: true, result: result }
rescue => e
Rails.logger.error "CoinstatsItem #{id} - Failed to process account #{coinstats_account.id}: #{e.message}"
results << { coinstats_account_id: coinstats_account.id, success: false, error: e.message }
end
end
results
end
# Queues balance sync jobs for all visible accounts.
# @param parent_sync [Sync, nil] Parent sync for tracking
# @param window_start_date [Date, nil] Start of sync window
# @param window_end_date [Date, nil] End of sync window
# @return [Array<Hash>] Results with success status per account
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
return [] if accounts.empty?
results = []
accounts.visible.each do |account|
begin
account.sync_later(
parent_sync: parent_sync,
window_start_date: window_start_date,
window_end_date: window_end_date
)
results << { account_id: account.id, success: true }
rescue => e
Rails.logger.error "CoinstatsItem #{id} - Failed to schedule sync for wallet #{account.id}: #{e.message}"
results << { account_id: account.id, success: false, error: e.message }
end
end
results
end
# Persists raw API response for debugging and reprocessing.
# @param accounts_snapshot [Hash] Raw API response data
def upsert_coinstats_snapshot!(accounts_snapshot)
assign_attributes(raw_payload: accounts_snapshot)
save!
end
# @return [Boolean] true if at least one account has been linked
def has_completed_initial_setup?
accounts.any?
end
# @return [String] Human-readable summary of sync status
def sync_status_summary
total_accounts = total_accounts_count
linked_count = linked_accounts_count
unlinked_count = unlinked_accounts_count
if total_accounts == 0
I18n.t("coinstats_items.coinstats_item.sync_status.no_accounts")
elsif unlinked_count == 0
I18n.t("coinstats_items.coinstats_item.sync_status.all_synced", count: linked_count)
else
I18n.t("coinstats_items.coinstats_item.sync_status.partial_sync", linked_count: linked_count, unlinked_count: unlinked_count)
end
end
# @return [Integer] Number of accounts with provider links
def linked_accounts_count
coinstats_accounts.joins(:account_provider).count
end
# @return [Integer] Number of accounts without provider links
def unlinked_accounts_count
coinstats_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count
end
# @return [Integer] Total number of coinstats accounts
def total_accounts_count
coinstats_accounts.count
end
# @return [String] Display name for the CoinStats connection
def institution_display_name
name.presence || "CoinStats"
end
# @return [Boolean] true if API key is set
def credentials_configured?
api_key.present?
end
end

View File

@@ -0,0 +1,315 @@
# Imports wallet data from CoinStats API for linked accounts.
# Fetches balances and transactions, then updates local records.
class CoinstatsItem::Importer
include CoinstatsTransactionIdentifiable
attr_reader :coinstats_item, :coinstats_provider
# @param coinstats_item [CoinstatsItem] Item containing accounts to import
# @param coinstats_provider [Provider::Coinstats] API client instance
def initialize(coinstats_item, coinstats_provider:)
@coinstats_item = coinstats_item
@coinstats_provider = coinstats_provider
end
# Imports balance and transaction data for all linked accounts.
# @return [Hash] Result with :success, :accounts_updated, :transactions_imported
def import
Rails.logger.info "CoinstatsItem::Importer - Starting import for item #{coinstats_item.id}"
# CoinStats works differently from bank providers - wallets are added manually
# via the setup_accounts flow. During sync, we just update existing linked accounts.
# Get all linked coinstats accounts (ones with account_provider associations)
linked_accounts = coinstats_item.coinstats_accounts
.joins(:account_provider)
.includes(:account)
if linked_accounts.empty?
Rails.logger.info "CoinstatsItem::Importer - No linked accounts to sync for item #{coinstats_item.id}"
return { success: true, accounts_updated: 0, transactions_imported: 0 }
end
accounts_updated = 0
accounts_failed = 0
transactions_imported = 0
# Fetch balance data using bulk endpoint
bulk_balance_data = fetch_balances_for_accounts(linked_accounts)
# Fetch transaction data using bulk endpoint
bulk_transactions_data = fetch_transactions_for_accounts(linked_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)
accounts_updated += 1 if result[:success]
transactions_imported += result[:transactions_count] || 0
rescue => e
accounts_failed += 1
Rails.logger.error "CoinstatsItem::Importer - Failed to update account #{coinstats_account.id}: #{e.message}"
end
end
Rails.logger.info "CoinstatsItem::Importer - Updated #{accounts_updated} accounts (#{accounts_failed} failed), #{transactions_imported} transactions"
{
success: accounts_failed == 0,
accounts_updated: accounts_updated,
accounts_failed: accounts_failed,
transactions_imported: transactions_imported
}
end
private
# 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
def fetch_balances_for_accounts(linked_accounts)
# Extract unique wallet addresses and blockchains
wallets = linked_accounts.filter_map do |account|
raw = account.raw_payload || {}
address = raw["address"] || raw[:address]
blockchain = raw["blockchain"] || raw[:blockchain]
next unless address.present? && blockchain.present?
{ address: address, blockchain: blockchain }
end.uniq { |w| [ w[:address].downcase, w[:blockchain].downcase ] }
return nil if wallets.empty?
Rails.logger.info "CoinstatsItem::Importer - Fetching balances for #{wallets.size} wallet(s) via bulk endpoint"
# Build comma-separated string in format "blockchain:address"
wallets_param = wallets.map { |w| "#{w[:blockchain]}:#{w[:address]}" }.join(",")
response = coinstats_provider.get_wallet_balances(wallets_param)
response.success? ? response.data : nil
rescue => e
Rails.logger.warn "CoinstatsItem::Importer - Bulk balance fetch failed: #{e.message}"
nil
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
def fetch_transactions_for_accounts(linked_accounts)
# Extract unique wallet addresses and blockchains
wallets = linked_accounts.filter_map do |account|
raw = account.raw_payload || {}
address = raw["address"] || raw[:address]
blockchain = raw["blockchain"] || raw[:blockchain]
next unless address.present? && blockchain.present?
{ address: address, blockchain: blockchain }
end.uniq { |w| [ w[:address].downcase, w[:blockchain].downcase ] }
return nil if wallets.empty?
Rails.logger.info "CoinstatsItem::Importer - Fetching transactions for #{wallets.size} wallet(s) via bulk endpoint"
# Build comma-separated string in format "blockchain:address"
wallets_param = wallets.map { |w| "#{w[:blockchain]}:#{w[:address]}" }.join(",")
response = coinstats_provider.get_wallet_transactions(wallets_param)
response.success? ? response.data : nil
rescue => e
Rails.logger.warn "CoinstatsItem::Importer - Bulk transaction fetch failed: #{e.message}"
nil
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:)
# Get the wallet address and blockchain from the raw payload
raw = coinstats_account.raw_payload || {}
address = raw["address"] || raw[:address]
blockchain = raw["blockchain"] || raw[:blockchain]
unless address.present? && blockchain.present?
Rails.logger.warn "CoinstatsItem::Importer - Missing address or blockchain for account #{coinstats_account.id}. Address: #{address.inspect}, Blockchain: #{blockchain.inspect}"
return { success: false, error: "Missing address or blockchain" }
end
# Extract balance data for this specific wallet from the bulk response
balance_data = if bulk_balance_data.present?
coinstats_provider.extract_wallet_balance(bulk_balance_data, address, blockchain)
else
[]
end
# Update the coinstats account with new balance data
coinstats_account.upsert_coinstats_snapshot!(normalize_balance_data(balance_data, coinstats_account))
# Extract and merge transactions from bulk response
transactions_count = fetch_and_merge_transactions(coinstats_account, address, blockchain, bulk_transactions_data)
{ 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
# @param address [String] Wallet address
# @param blockchain [String] Blockchain identifier
# @param bulk_transactions_data [Array, nil] Pre-fetched transaction data
# @return [Integer] Number of relevant transactions found
def fetch_and_merge_transactions(coinstats_account, address, blockchain, bulk_transactions_data)
# Extract transactions for this specific wallet from the bulk response
transactions_data = if bulk_transactions_data.present?
coinstats_provider.extract_wallet_transactions(bulk_transactions_data, address, blockchain)
else
[]
end
new_transactions = transactions_data.is_a?(Array) ? transactions_data : (transactions_data[:result] || [])
return 0 if new_transactions.empty?
# Filter transactions to only include those relevant to this coin/token
coin_id = coinstats_account.account_id
relevant_transactions = filter_transactions_by_coin(new_transactions, coin_id)
return 0 if relevant_transactions.empty?
# Get existing transactions (already extracted as array)
existing_transactions = coinstats_account.raw_transactions_payload.to_a
# Build a set of existing transaction IDs to avoid duplicates
existing_ids = existing_transactions.map { |tx| extract_coinstats_transaction_id(tx) }.compact.to_set
# Filter to only new transactions
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?
# Merge new transactions with existing ones
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 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)
# - transactions[].items[].coin.id matching the coin_id
# @param transactions [Array<Hash>] Array of transaction objects
# @param coin_id [String] The coin ID to filter by (e.g., "chainlink", "ethereum")
# @return [Array<Hash>] Filtered transactions
def filter_transactions_by_coin(transactions, coin_id)
return [] if coin_id.blank?
coin_id_downcase = coin_id.to_s.downcase
transactions.select do |tx|
tx = tx.with_indifferent_access
# Check nested transactions items for coin match
inner_transactions = tx[:transactions] || []
inner_transactions.any? do |inner_tx|
inner_tx = inner_tx.with_indifferent_access
items = inner_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
end
end
end
end
# Normalizes API balance data to a consistent schema for storage.
# @param balance_data [Array<Hash>] Raw token balances from API
# @param coinstats_account [CoinstatsAccount] Account for context
# @return [Hash] Normalized snapshot with id, balance, address, etc.
def normalize_balance_data(balance_data, coinstats_account)
# CoinStats get_wallet_balance returns an array of token balances directly
# Normalize it to match our expected schema
# Preserve existing address/blockchain from raw_payload
existing_raw = coinstats_account.raw_payload || {}
# 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)
{
# 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
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
}
end
# Finds the token in balance_data that matches this account.
# Matches by account_id (coinId) first, then falls back to name.
# @param balance_data [Array<Hash>] Token balances from API
# @param coinstats_account [CoinstatsAccount] Account to match
# @return [Hash, nil] Matching token data or nil
def find_matching_token(balance_data, coinstats_account)
tokens = normalize_tokens(balance_data).map(&:with_indifferent_access)
return nil if tokens.empty?
# First try to match by account_id (coinId) if available
if coinstats_account.account_id.present?
account_id = coinstats_account.account_id.to_s
matching = tokens.find do |token|
token_id = (token[:coinId] || token[:id])&.to_s
token_id == account_id
end
return matching if matching
end
# Fall back to matching by name (handles legacy accounts without account_id)
account_name = coinstats_account.name&.downcase
return nil if account_name.blank?
tokens.find do |token|
token_name = token[:name]&.to_s&.downcase
token_symbol = token[:symbol]&.to_s&.downcase
# Match if account name contains the token name or symbol, or vice versa
account_name.include?(token_name) || token_name.include?(account_name) ||
(token_symbol.present? && (account_name.include?(token_symbol) || token_symbol == account_name))
end
end
# Normalizes various response formats to an array of tokens.
# @param balance_data [Array, Hash, nil] Raw balance response
# @return [Array<Hash>] Array of token hashes
def normalize_tokens(balance_data)
if balance_data.is_a?(Array)
balance_data
elsif balance_data.is_a?(Hash)
balance_data[:result] || balance_data[:tokens] || []
else
[]
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?
amount = token[:amount] || token[:balance] || 0
price = token[:price] || token[:priceUsd] || 0
(amount.to_f * price.to_f)
end
end

View File

@@ -0,0 +1,9 @@
module CoinstatsItem::Provided
extend ActiveSupport::Concern
def coinstats_provider
return nil unless credentials_configured?
Provider::Coinstats.new(api_key)
end
end

View File

@@ -0,0 +1,29 @@
# Broadcasts Turbo Stream updates when a CoinStats sync completes.
# Updates account views and notifies the family of sync completion.
class CoinstatsItem::SyncCompleteEvent
attr_reader :coinstats_item
# @param coinstats_item [CoinstatsItem] The item that completed syncing
def initialize(coinstats_item)
@coinstats_item = coinstats_item
end
# Broadcasts sync completion to update UI components.
def broadcast
# Update UI with latest account data
coinstats_item.accounts.each do |account|
account.broadcast_sync_complete
end
# Update the CoinStats item view
coinstats_item.broadcast_replace_to(
coinstats_item.family,
target: "coinstats_item_#{coinstats_item.id}",
partial: "coinstats_items/coinstats_item",
locals: { coinstats_item: coinstats_item }
)
# Let family handle sync notifications
coinstats_item.family.broadcast_sync_complete
end
end

View File

@@ -0,0 +1,61 @@
# Orchestrates the sync process for a CoinStats connection.
# Imports data, processes holdings, and schedules account syncs.
class CoinstatsItem::Syncer
attr_reader :coinstats_item
# @param coinstats_item [CoinstatsItem] Item to sync
def initialize(coinstats_item)
@coinstats_item = coinstats_item
end
# Runs the full sync workflow: import, process, and schedule.
# @param sync [Sync] Sync record for status tracking
def perform_sync(sync)
# Phase 1: Import data from CoinStats API
sync.update!(status_text: "Importing wallets from CoinStats...") if sync.respond_to?(:status_text)
coinstats_item.import_latest_coinstats_data
# Phase 2: Check account setup status and collect sync statistics
sync.update!(status_text: "Checking wallet configuration...") if sync.respond_to?(:status_text)
total_accounts = coinstats_item.coinstats_accounts.count
linked_accounts = coinstats_item.coinstats_accounts.joins(:account_provider).joins(:account).merge(Account.visible)
unlinked_accounts = coinstats_item.coinstats_accounts.left_joins(:account_provider).where(account_providers: { id: nil })
sync_stats = {
total_accounts: total_accounts,
linked_accounts: linked_accounts.count,
unlinked_accounts: unlinked_accounts.count
}
if unlinked_accounts.any?
coinstats_item.update!(pending_account_setup: true)
sync.update!(status_text: "#{unlinked_accounts.count} wallets need setup...") if sync.respond_to?(:status_text)
else
coinstats_item.update!(pending_account_setup: false)
end
# Phase 3: Process holdings for linked accounts only
if linked_accounts.any?
sync.update!(status_text: "Processing holdings...") if sync.respond_to?(:status_text)
coinstats_item.process_accounts
# Phase 4: Schedule balance calculations for linked accounts
sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text)
coinstats_item.schedule_account_syncs(
parent_sync: sync,
window_start_date: sync.window_start_date,
window_end_date: sync.window_end_date
)
end
if sync.respond_to?(:sync_stats)
sync.update!(sync_stats: sync_stats)
end
end
# Hook called after sync completion. Currently a no-op.
def perform_post_sync
# no-op
end
end

View File

@@ -0,0 +1,50 @@
# frozen_string_literal: true
# Provides unlinking functionality for CoinStats items.
# Allows disconnecting provider accounts while preserving account data.
module CoinstatsItem::Unlinking
extend ActiveSupport::Concern
# Removes all connections between this item and local accounts.
# Detaches AccountProvider links and nullifies associated Holdings.
# @param dry_run [Boolean] If true, returns results without making changes
# @return [Array<Hash>] Results per account with :provider_account_id, :name, :provider_link_ids
def unlink_all!(dry_run: false)
results = []
coinstats_accounts.find_each do |provider_account|
links = AccountProvider.where(provider_type: CoinstatsAccount.name, provider_id: provider_account.id).to_a
link_ids = links.map(&:id)
result = {
provider_account_id: provider_account.id,
name: provider_account.name,
provider_link_ids: link_ids
}
results << result
next if dry_run
begin
ActiveRecord::Base.transaction do
# Detach holdings for any provider links found
if link_ids.any?
Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil)
end
# Destroy all provider links
links.each do |ap|
ap.destroy!
end
end
rescue StandardError => e
Rails.logger.warn(
"CoinstatsItem Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}"
)
# Record error for observability; continue with other accounts
result[:error] = e.message
end
end
results
end
end

View File

@@ -0,0 +1,157 @@
# frozen_string_literal: true
# Links a cryptocurrency wallet to CoinStats by fetching token balances
# and creating corresponding accounts for each token found.
class CoinstatsItem::WalletLinker
attr_reader :coinstats_item, :address, :blockchain
Result = Struct.new(:success?, :created_count, :errors, keyword_init: true)
# @param coinstats_item [CoinstatsItem] Parent item with API credentials
# @param address [String] Wallet address to link
# @param blockchain [String] Blockchain network identifier
def initialize(coinstats_item, address:, blockchain:)
@coinstats_item = coinstats_item
@address = address
@blockchain = blockchain
end
# Fetches wallet balances and creates accounts for each token.
# @return [Result] Success status, created count, and any errors
def link
balance_data = fetch_balance_data
tokens = normalize_tokens(balance_data)
return Result.new(success?: false, created_count: 0, errors: [ "No tokens found for wallet" ]) if tokens.empty?
created_count = 0
errors = []
tokens.each do |token_data|
result = create_account_from_token(token_data)
if result[:success]
created_count += 1
else
errors << result[:error]
end
end
# Trigger a sync if we created any accounts
coinstats_item.sync_later if created_count > 0
Result.new(success?: created_count > 0, created_count: created_count, errors: errors)
end
private
# Fetches balance data for this wallet from CoinStats API.
# @return [Array<Hash>] Token balances for the wallet
def fetch_balance_data
provider = Provider::Coinstats.new(coinstats_item.api_key)
wallets_param = "#{blockchain}:#{address}"
response = provider.get_wallet_balances(wallets_param)
return [] unless response.success?
provider.extract_wallet_balance(response.data, address, blockchain)
end
# Normalizes various balance data formats to an array of tokens.
# @param balance_data [Array, Hash, Object] Raw balance response
# @return [Array<Hash>] Normalized array of token data
def normalize_tokens(balance_data)
if balance_data.is_a?(Array)
balance_data
elsif balance_data.is_a?(Hash)
balance_data[:result] || balance_data[:tokens] || [ balance_data ]
elsif balance_data.present?
[ balance_data ]
else
[]
end
end
# Creates a CoinstatsAccount and linked Account for a token.
# @param token_data [Hash] Token balance data from API
# @return [Hash] Result with :success and optional :error
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,
account_id: token_id
)
# Store wallet metadata for future syncs
snapshot = build_snapshot(token, current_balance)
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,
currency: coinstats_account.currency,
status: "active"
)
AccountProvider.create!(account: account, provider: coinstats_account)
{ success: true }
end
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
Rails.logger.error("CoinstatsItem::WalletLinker - Failed to create account: #{e.message}")
{ success: false, error: "Failed to create #{account_name || 'account'}: #{e.message}" }
rescue => e
Rails.logger.error("CoinstatsItem::WalletLinker - Unexpected error: #{e.class} - #{e.message}")
{ success: false, error: "Unexpected error: #{e.message}" }
end
# Builds a display name for the account from token and address.
# @param token [Hash] Token data with :name
# @return [String] Human-readable account name
def build_account_name(token)
token_name = token[:name].to_s.strip
truncated_address = address.present? ? "#{address.first(4)}...#{address.last(4)}" : nil
if token_name.present? && truncated_address.present?
"#{token_name} (#{truncated_address})"
elsif token_name.present?
token_name
elsif truncated_address.present?
"#{blockchain.capitalize} (#{truncated_address})"
else
"Crypto Wallet"
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(
id: (token[:coinId] || token[:id])&.to_s,
balance: current_balance,
currency: "USD",
address: address,
blockchain: blockchain,
institution_logo: token[:imgUrl]
)
end
end

View File

@@ -0,0 +1,68 @@
# frozen_string_literal: true
# Shared logic for extracting unique transaction IDs from CoinStats API responses.
# Different blockchains return transaction IDs in different locations:
# - Ethereum/EVM: hash.id (transaction hash)
# - Bitcoin/UTXO: transactions[0].items[0].id
module CoinstatsTransactionIdentifiable
extend ActiveSupport::Concern
private
# Extracts a unique transaction ID from CoinStats transaction data.
# Handles different blockchain formats and generates fallback IDs.
# @param transaction_data [Hash] Raw transaction data from API
# @return [String, nil] Unique transaction identifier or nil
def extract_coinstats_transaction_id(transaction_data)
tx = transaction_data.is_a?(Hash) ? transaction_data.with_indifferent_access : {}
# Try hash.id first (Ethereum/EVM chains)
hash_id = tx.dig(:hash, :id)
return hash_id if hash_id.present?
# Try transactions[0].items[0].id (Bitcoin/UTXO chains)
item_id = tx.dig(:transactions, 0, :items, 0, :id)
return item_id if item_id.present?
# Fallback: generate ID from multiple fields to reduce collision risk.
# Include as many distinguishing fields as possible since transactions
# with same date/type/amount are common (DCA, recurring purchases, batch trades).
fallback_id = build_fallback_transaction_id(tx)
return fallback_id if fallback_id.present?
nil
end
# Builds a fallback transaction ID from available fields.
# Uses a hash digest of combined fields to handle varying field availability
# while maintaining uniqueness across similar transactions.
# @param tx [HashWithIndifferentAccess] Transaction data
# @return [String, nil] Generated fallback ID or nil if insufficient data
def build_fallback_transaction_id(tx)
date = tx[:date]
type = tx[:type]
amount = tx.dig(:coinData, :count)
# Require minimum fields for a valid fallback
return nil unless date.present? && type.present? && amount.present?
# Collect additional distinguishing fields.
# Only use stable transaction data—avoid market-dependent values
# (currentValue, totalWorth, profit) that can change between API calls.
components = [
date,
type,
amount,
tx.dig(:coinData, :symbol),
tx.dig(:fee, :count),
tx.dig(:fee, :coin, :symbol),
tx.dig(:transactions, 0, :action),
tx.dig(:transactions, 0, :items, 0, :coin, :id),
tx.dig(:transactions, 0, :items, 0, :count)
].compact
# Generate a hash digest for a fixed-length, collision-resistant ID
content = components.join("|")
"fallback_#{Digest::SHA256.hexdigest(content)[0, 16]}"
end
end

View File

@@ -1,5 +1,5 @@
class DataEnrichment < ApplicationRecord
belongs_to :enrichable, polymorphic: true
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking" }
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats" }
end

View File

@@ -1,5 +1,5 @@
class Family < ApplicationRecord
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable, CoinstatsConnectable
DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ],

View File

@@ -0,0 +1,35 @@
# Adds CoinStats connection capabilities to Family.
# Allows families to create and manage CoinStats API connections.
module Family::CoinstatsConnectable
extend ActiveSupport::Concern
included do
has_many :coinstats_items, dependent: :destroy
end
# @return [Boolean] Whether the family can create CoinStats connections
def can_connect_coinstats?
# Families can configure their own Coinstats credentials
true
end
# Creates a new CoinStats connection and triggers initial sync.
# @param api_key [String] CoinStats API key
# @param item_name [String, nil] Optional display name for the connection
# @return [CoinstatsItem] The created connection
def create_coinstats_item!(api_key:, item_name: nil)
coinstats_item = coinstats_items.create!(
name: item_name || "CoinStats Connection",
api_key: api_key
)
coinstats_item.sync_later
coinstats_item
end
# @return [Boolean] Whether the family has any configured CoinStats connections
def has_coinstats_credentials?
coinstats_items.where.not(api_key: nil).exists?
end
end

View File

@@ -0,0 +1,184 @@
# API client for CoinStats cryptocurrency data provider.
# Handles authentication and requests to the CoinStats OpenAPI.
class Provider::Coinstats < Provider
include HTTParty
# Subclass so errors caught in this provider are raised as Provider::Coinstats::Error
Error = Class.new(Provider::Error)
BASE_URL = "https://openapiv1.coinstats.app"
headers "User-Agent" => "Sure Finance CoinStats Client (https://github.com/we-promise/sure)"
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
attr_reader :api_key
# @param api_key [String] CoinStats API key for authentication
def initialize(api_key)
@api_key = api_key
end
# Get the list of blockchains supported by CoinStats
# https://coinstats.app/api-docs/openapi/get-blockchains
def get_blockchains
with_provider_response do
res = self.class.get("#{BASE_URL}/wallet/blockchains", headers: auth_headers)
handle_response(res)
end
end
# Returns blockchain options formatted for select dropdowns
# @return [Array<Array>] Array of [label, value] pairs sorted alphabetically
def blockchain_options
response = get_blockchains
unless response.success?
Rails.logger.warn("CoinStats: failed to fetch blockchains: #{response.error&.message}")
return []
end
raw_blockchains = response.data
items = if raw_blockchains.is_a?(Array)
raw_blockchains
elsif raw_blockchains.respond_to?(:dig) && raw_blockchains[:data].is_a?(Array)
raw_blockchains[:data]
else
[]
end
items.filter_map do |b|
b = b.with_indifferent_access
value = b[:connectionId] || b[:id] || b[:name]
next unless value.present?
label = b[:name].presence || value.to_s
[ label, value ]
end.uniq { |_label, value| value }.sort_by { |label, _| label.to_s.downcase }
rescue StandardError => e
Rails.logger.warn("CoinStats: failed to fetch blockchains: #{e.class} - #{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"
# Example: "ethereum:0x123abc,bitcoin:bc1qxyz"
# @return [Provider::Response] Response with wallet balance data
def get_wallet_balances(wallets)
return with_provider_response { [] } if wallets.blank?
with_provider_response do
res = self.class.get(
"#{BASE_URL}/wallet/balances",
headers: auth_headers,
query: { wallets: wallets }
)
handle_response(res)
end
end
# Extract balance data for a specific wallet from bulk response
# @param bulk_data [Array<Hash>] Response from get_wallet_balances
# @param address [String] Wallet address to find
# @param blockchain [String] Blockchain/connectionId to find
# @return [Array<Hash>] Token balances for the wallet, or empty array if not found
def extract_wallet_balance(bulk_data, address, blockchain)
return [] unless bulk_data.is_a?(Array)
wallet_data = bulk_data.find do |entry|
entry = entry.with_indifferent_access
entry[:address]&.downcase == address&.downcase &&
(entry[:connectionId]&.downcase == blockchain&.downcase ||
entry[:blockchain]&.downcase == blockchain&.downcase)
end
return [] unless wallet_data
wallet_data = wallet_data.with_indifferent_access
wallet_data[:balances] || []
end
# Get transaction data for multiple wallet addresses in a single request
# https://coinstats.app/api-docs/openapi/get-wallet-transactions
# @param wallets [String] Comma-separated list of wallet addresses in format "blockchain:address"
# Example: "ethereum:0x123abc,bitcoin:bc1qxyz"
# @return [Provider::Response] Response with wallet transaction data
def get_wallet_transactions(wallets)
return with_provider_response { [] } if wallets.blank?
with_provider_response do
res = self.class.get(
"#{BASE_URL}/wallet/transactions",
headers: auth_headers,
query: { wallets: wallets }
)
handle_response(res)
end
end
# Extract transaction data for a specific wallet from bulk response
# The transactions API returns {result: Array<transactions>, meta: {...}}
# All transactions in the response belong to the requested wallets
# @param bulk_data [Hash, Array] Response from get_wallet_transactions
# @param address [String] Wallet address to filter by (currently unused as API returns flat list)
# @param blockchain [String] Blockchain/connectionId to filter by (currently unused)
# @return [Array<Hash>] Transactions for the wallet, or empty array if not found
def extract_wallet_transactions(bulk_data, address, blockchain)
# Handle Hash response with :result key (current API format)
if bulk_data.is_a?(Hash)
bulk_data = bulk_data.with_indifferent_access
return bulk_data[:result] || []
end
# Handle legacy Array format (per-wallet structure)
return [] unless bulk_data.is_a?(Array)
wallet_data = bulk_data.find do |entry|
entry = entry.with_indifferent_access
entry[:address]&.downcase == address&.downcase &&
(entry[:connectionId]&.downcase == blockchain&.downcase ||
entry[:blockchain]&.downcase == blockchain&.downcase)
end
return [] unless wallet_data
wallet_data = wallet_data.with_indifferent_access
wallet_data[:transactions] || []
end
private
def auth_headers
{
"X-API-KEY" => api_key,
"Accept" => "application/json"
}
end
# The CoinStats API uses standard HTTP status codes to indicate the success or failure of requests.
# https://coinstats.app/api-docs/errors
def handle_response(response)
case response.code
when 200
JSON.parse(response.body, symbolize_names: true)
when 400
raise Error, "CoinStats: #{response.code} Bad Request - Invalid parameters or request format #{response.body}"
when 401
raise Error, "CoinStats: #{response.code} Unauthorized - Invalid or missing API key #{response.body}"
when 403
raise Error, "CoinStats: #{response.code} Forbidden - #{response.body}"
when 404
raise Error, "CoinStats: #{response.code} Not Found - Resource not found #{response.body}"
when 409
raise Error, "CoinStats: #{response.code} Conflict - Resource conflict #{response.body}"
when 429
raise Error, "CoinStats: #{response.code} Too Many Requests - Rate limit exceeded #{response.body}"
when 500
raise Error, "CoinStats: #{response.code} Internal Server Error - Server error #{response.body}"
when 503
raise Error, "CoinStats: #{response.code} Service Unavailable - #{response.body}"
else
raise Error, "CoinStats: #{response.code} Unexpected Error - #{response.body}"
end
end
end

View File

@@ -0,0 +1,119 @@
# Provider adapter for CoinStats cryptocurrency wallet integration.
# Handles sync operations and institution metadata for crypto accounts.
class Provider::CoinstatsAdapter < Provider::Base
include Provider::Syncable
include Provider::InstitutionMetadata
# Register this adapter with the factory
Provider::Factory.register("CoinstatsAccount", self)
# @return [Array<String>] Account types supported by this provider
def self.supported_account_types
%w[Crypto]
end
# Returns connection configurations for this provider
# @param family [Family] The family to check connection eligibility
# @return [Array<Hash>] Connection config with name, description, and paths
def self.connection_configs(family:)
return [] unless family.can_connect_coinstats?
[ {
key: "coinstats",
name: "CoinStats",
description: "Connect to your crypto wallet via CoinStats",
can_connect: true,
new_account_path: ->(accountable_type, return_to) {
Rails.application.routes.url_helpers.new_coinstats_item_path(
accountable_type: accountable_type,
return_to: return_to
)
},
# CoinStats wallets are linked via the link_wallet action, not via existing account selection
existing_account_path: nil
} ]
end
# @return [String] Unique identifier for this provider
def provider_name
"coinstats"
end
# Build a Coinstats provider instance with family-specific credentials
# @param family [Family] The family to get credentials for (required)
# @return [Provider::Coinstats, nil] Returns nil if credentials are not configured
def self.build_provider(family: nil)
return nil unless family.present?
# Get family-specific credentials
coinstats_item = family.coinstats_items.where.not(api_key: nil).first
return nil unless coinstats_item&.credentials_configured?
Provider::Coinstats.new(coinstats_item.api_key)
end
# @return [String] URL path for triggering a sync
def sync_path
Rails.application.routes.url_helpers.sync_coinstats_item_path(item)
end
# @return [CoinstatsItem] The parent item containing API credentials
def item
provider_account.coinstats_item
end
# @return [Boolean] Whether holdings can be manually deleted
def can_delete_holdings?
false
end
# Extracts institution domain from metadata, deriving from URL if needed.
# @return [String, nil] Domain name or nil if unavailable
def institution_domain
metadata = provider_account.institution_metadata
return nil unless metadata.present?
domain = metadata["domain"]
url = metadata["url"]
# Derive domain from URL if missing
if domain.blank? && url.present?
begin
domain = URI.parse(url).host&.gsub(/^www\./, "")
rescue URI::InvalidURIError
Rails.logger.warn("Invalid institution URL for Coinstats account #{provider_account.id}: #{url}")
end
end
domain
end
# @return [String, nil] Institution display name
def institution_name
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["name"]
end
# @return [String, nil] Institution website URL
def institution_url
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["url"]
end
# @return [nil] CoinStats doesn't provide institution colors
def institution_color
nil # CoinStats doesn't provide institution colors
end
# @return [String, nil] URL for institution/token logo
def logo_url
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["logo"]
end
end

View File

@@ -27,6 +27,12 @@ module Provider::InstitutionMetadata
nil
end
# Returns the institution/account logo URL (direct image URL)
# @return [String, nil] The logo URL or nil if not available
def logo_url
nil
end
# Returns a hash of all institution metadata
# @return [Hash] Hash containing institution metadata
def institution_metadata
@@ -34,7 +40,8 @@ module Provider::InstitutionMetadata
domain: institution_domain,
name: institution_name,
url: institution_url,
color: institution_color
color: institution_color,
logo_url: logo_url
}.compact
end
end

View File

@@ -1,5 +1,5 @@
class ProviderMerchant < Merchant
enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking" }
enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats" }
validates :name, uniqueness: { scope: [ :source ] }
validates :source, presence: true

View File

@@ -9,6 +9,8 @@
<% if account.institution_domain.present? && Setting.brand_fetch_client_id.present? %>
<%= image_tag "https://cdn.brandfetch.io/#{account.institution_domain}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}", class: "shrink-0 rounded-full #{size_classes[size]}" %>
<% elsif account.logo_url.present? %>
<%= image_tag account.logo_url, class: "shrink-0 rounded-full #{size_classes[size]}", loading: "lazy" %>
<% elsif account.logo.attached? %>
<%= image_tag account.logo, class: "shrink-0 rounded-full #{size_classes[size]}" %>
<% else %>

View File

@@ -21,7 +21,7 @@
</div>
</header>
<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? %>
<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? %>
<%= render "empty" %>
<% else %>
<div class="space-y-2">
@@ -41,6 +41,10 @@
<%= render @enable_banking_items.sort_by(&:created_at) %>
<% end %>
<% if @coinstats_items.any? %>
<%= render @coinstats_items.sort_by(&:created_at) %>
<% end %>
<% if @manual_accounts.any? %>
<div id="manual-accounts">
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %>

View File

@@ -0,0 +1,92 @@
<%# locals: (coinstats_item:) %>
<%= tag.div id: dom_id(coinstats_item) do %>
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center justify-between gap-2 focus-visible:outline-hidden">
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<div class="flex items-center justify-center h-8 w-8 bg-success/10 rounded-full">
<div class="flex items-center justify-center">
<%= tag.p coinstats_item.institution_display_name.first.upcase, class: "text-success text-xs font-medium" %>
</div>
</div>
<div class="pl-1 text-sm">
<div class="flex items-center gap-2">
<%= tag.p coinstats_item.institution_display_name, class: "font-medium text-primary" %>
<% if coinstats_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
</div>
<p class="text-xs text-secondary"><%= t(".provider_name") %></p>
<% if coinstats_item.syncing? %>
<div class="text-secondary flex items-center gap-1">
<%= icon "loader", size: "sm", class: "animate-spin" %>
<%= tag.span t(".syncing") %>
</div>
<% elsif coinstats_item.requires_update? %>
<div class="text-warning flex items-center gap-1">
<%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span t(".reconnect") %>
</div>
<% else %>
<p class="text-secondary">
<% if coinstats_item.last_synced_at %>
<% if coinstats_item.sync_status_summary %>
<%= t(".status_with_summary", timestamp: time_ago_in_words(coinstats_item.last_synced_at), summary: coinstats_item.sync_status_summary) %>
<% else %>
<%= t(".status", timestamp: time_ago_in_words(coinstats_item.last_synced_at)) %>
<% end %>
<% else %>
<%= t(".status_never") %>
<% end %>
</p>
<% end %>
</div>
</div>
<div class="flex items-center gap-2">
<% if coinstats_item.requires_update? %>
<%= render DS::Link.new(
text: t(".update_api_key"),
icon: "refresh-cw",
variant: "secondary",
href: settings_providers_path,
frame: "_top"
) %>
<% elsif Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_coinstats_item_path(coinstats_item)
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: coinstats_item_path(coinstats_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(coinstats_item.institution_display_name, high_severity: true)
) %>
<% end %>
</div>
</summary>
<% unless coinstats_item.scheduled_for_deletion? %>
<div class="space-y-4 mt-4">
<% if coinstats_item.accounts.any? %>
<%= render "accounts/index/account_groups", accounts: coinstats_item.accounts %>
<% else %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".no_wallets_title") %></p>
<p class="text-secondary text-sm"><%= t(".no_wallets_message") %></p>
</div>
<% end %>
</div>
<% end %>
</details>
<% end %>

View File

@@ -0,0 +1,77 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% 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 %>
<% 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 %>
<%= 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"),
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 %>
<% 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>
<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 %>

View File

@@ -96,7 +96,7 @@
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
<p class="font-medium text-primary">Setup Steps:</p>
<ol class="list-decimal list-inside space-y-1 text-secondary">
<li>Go to <strong>Settings → Bank Sync Providers</strong></li>
<li>Go to <strong>Settings → Providers</strong></li>
<li>Find the <strong>Enable Banking</strong> section</li>
<li>Enter your Enable Banking credentials</li>
<li>Return here to link your accounts</li>

View File

@@ -14,7 +14,7 @@
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
<p class="font-medium text-primary">Setup Steps:</p>
<ol class="list-decimal list-inside space-y-1 text-secondary">
<li>Go to <strong>Settings → Bank Sync Providers</strong></li>
<li>Go to <strong>Settings → Providers</strong></li>
<li>Find the <strong>Lunch Flow</strong> section</li>
<li>Enter your Lunch Flow API key</li>
<li>Return here to link your accounts</li>

View File

@@ -5,7 +5,8 @@
"Lunch Flow" => "#6471eb",
"Plaid" => "#4da568",
"SimpleFin" => "#e99537",
"Enable Banking" => "#6471eb"
"Enable Banking" => "#6471eb",
"CoinStats" => "#FF9332" # https://coinstats.app/press-kit/
} %>
<% provider_color = provider_colors[provider_link[:name]] || "#6B7280" %>

View File

@@ -0,0 +1,54 @@
<div class="space-y-4">
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium"><%= t("coinstats_items.new.setup_instructions") %></p>
<ol>
<li><%= t("coinstats_items.new.step1_html").html_safe %></li>
<li><%= t("coinstats_items.new.step2") %></li>
<li><%= t("coinstats_items.new.step3_html", accounts_url: accounts_path).html_safe %></li>
</ol>
</div>
<% 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">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<% end %>
<%
# Get or initialize a coinstats_item for this family
# - If family has an item WITH credentials, use it (for updates)
# - If family has an item WITHOUT credentials, use it (to add credentials)
# - If family has no items at all, create a new one
coinstats_item = Current.family.coinstats_items.first_or_initialize(name: t("coinstats_items.new.default_name"))
is_new_record = coinstats_item.new_record?
%>
<%= styled_form_with model: coinstats_item,
url: is_new_record ? coinstats_items_path : coinstats_item_path(coinstats_item),
scope: :coinstats_item,
method: is_new_record ? :post : :patch,
data: { turbo: true },
class: "space-y-3" do |form| %>
<%= form.text_field :api_key,
label: t("coinstats_items.new.api_key_label"),
placeholder: t("coinstats_items.new.api_key_placeholder"),
type: :password %>
<div class="flex justify-end">
<%= form.submit is_new_record ? t("coinstats_items.new.configure") : t("coinstats_items.new.update_configuration"),
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 %>
<% items = local_assigns[:coinstats_items] || @coinstats_items || Current.family.coinstats_items.where.not(api_key: [nil, ""]) %>
<div class="flex items-center gap-2">
<% if items&.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary"><%= t("coinstats_items.new.status_configured_html", accounts_url: accounts_path).html_safe %></p>
<% else %>
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
<p class="text-sm text-secondary"><%= t("coinstats_items.new.status_not_configured") %></p>
<% end %>
</div>
</div>

View File

@@ -1,9 +1,9 @@
<%= content_for :page_title, "Bank Sync Providers" %>
<%= content_for :page_title, "Sync Providers" %>
<div class="space-y-4">
<div>
<p class="text-secondary mb-4">
Configure credentials for third-party bank sync providers. Settings configured here will override environment variables.
Configure credentials for third-party sync providers. Settings configured here will override environment variables.
</p>
</div>
@@ -32,4 +32,10 @@
<%= render "settings/providers/enable_banking_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "CoinStats", collapsible: true, open: false do %>
<turbo-frame id="coinstats-providers-panel">
<%= render "settings/providers/coinstats_panel" %>
</turbo-frame>
<% end %>
</div>

View File

@@ -102,7 +102,7 @@ en:
title: Unlink account from provider?
description_html: "You are about to unlink <strong>%{account_name}</strong> from <strong>%{provider_name}</strong>. This will convert it to a manual account."
warning_title: What this means
warning_no_sync: The account will no longer sync automatically with your bank
warning_no_sync: The account will no longer sync automatically with your provider
warning_manual_updates: You will need to add transactions and update balances manually
warning_transactions_kept: All existing transactions and balances will be preserved
warning_can_delete: After unlinking, you will be able to delete the account if needed

View File

@@ -0,0 +1,63 @@
---
en:
coinstats_items:
create:
success: CoinStats provider connection configured successfully.
default_name: CoinStats Connection
errors:
validation_failed: "Validation failed: %{message}."
update:
success: CoinStats provider connection updated successfully.
errors:
validation_failed: "Validation failed: %{message}."
destroy:
success: CoinStats provider connection scheduled for deletion.
link_wallet:
success: "%{count} crypto wallet(s) linked successfully."
missing_params: "Missing required parameters: address and blockchain."
failed: Crypto wallet linking failed.
error: "Crypto wallet linking failed: %{message}."
new:
title: Link a Crypto Wallet with CoinStats
blockchain_fetch_error: Failed load Blockchains. Please try again later.
address_label: Address
address_placeholder: Required
blockchain_label: Blockchain
blockchain_placeholder: Required
blockchain_select_blank: Select a Blockchain
link: Link Crypto Wallet
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_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
go_to_settings: Go to Provider Settings
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.
api_key_label: API Key
api_key_placeholder: Required
configure: Configure
update_configuration: Reconfigure
default_name: CoinStats Connection
status_configured_html: Ready to use
status_not_configured: Not configured
coinstats_item:
deletion_in_progress: Crypto wallet data is being deleted…
provider_name: CoinStats
syncing: Syncing…
sync_status:
no_accounts: No crypto wallets found
all_synced:
one: "%{count} crypto wallet synced"
other: "%{count} crypto wallets synced"
partial_sync: "%{linked_count} crypto wallets synced, %{unlinked_count} need setup"
reconnect: Reconnect
status: Last synced %{timestamp} ago
status_never: Never synced
status_with_summary: "Last synced %{timestamp} ago • %{summary}"
update_api_key: Update API Key
delete: Delete
no_wallets_title: No crypto wallets connected
no_wallets_message: No crypto wallets are currently connected to CoinStats.

View File

@@ -2,6 +2,16 @@ require "sidekiq/web"
require "sidekiq/cron/web"
Rails.application.routes.draw do
# CoinStats routes
resources :coinstats_items, only: [ :index, :new, :create, :update, :destroy ] do
collection do
post :link_wallet
end
member do
post :sync
end
end
resources :enable_banking_items, only: [ :new, :create, :update, :destroy ] do
collection do
get :callback

View File

@@ -24,3 +24,9 @@ sync_all_accounts:
class: "SyncAllJob"
queue: "scheduled"
description: "Syncs all accounts for all families"
sync_hourly:
cron: "0 * * * *" # every hour at the top of the hour
class: "SyncHourlyJob"
queue: "scheduled"
description: "Syncs provider items that opt-in to hourly syncing"

View File

@@ -0,0 +1,60 @@
class CreateCoinstatsItemsAndAccounts < ActiveRecord::Migration[7.2]
def change
# Create provider items table (stores per-family connection credentials)
create_table :coinstats_items, id: :uuid do |t|
t.references :family, null: false, foreign_key: true, type: :uuid
t.string :name
# Institution metadata
t.string :institution_id
t.string :institution_name
t.string :institution_domain
t.string :institution_url
t.string :institution_color
# Status and lifecycle
t.string :status, default: "good"
t.boolean :scheduled_for_deletion, default: false
t.boolean :pending_account_setup, default: false
# Sync settings
t.datetime :sync_start_date
# Raw data storage
t.jsonb :raw_payload
t.jsonb :raw_institution_payload
# Provider-specific credential fields
t.string :api_key, null: false
t.timestamps
end
add_index :coinstats_items, :status
# Create provider accounts table (stores individual account data from provider)
create_table :coinstats_accounts, id: :uuid do |t|
t.references :coinstats_item, null: false, foreign_key: true, type: :uuid
# Account identification
t.string :name
t.string :account_id
# Account details
t.string :currency
t.decimal :current_balance, precision: 19, scale: 4
t.string :account_status
t.string :account_type
t.string :provider
# Metadata and raw data
t.jsonb :institution_metadata
t.jsonb :raw_payload
t.jsonb :raw_transactions_payload
t.timestamps
end
add_index :coinstats_accounts, :account_id
end
end

43
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_12_15_100443) do
ActiveRecord::Schema[7.2].define(version: 2025_12_21_060111) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -197,6 +197,45 @@ ActiveRecord::Schema[7.2].define(version: 2025_12_15_100443) do
t.index ["user_id"], name: "index_chats_on_user_id"
end
create_table "coinstats_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "coinstats_item_id", null: false
t.string "name"
t.string "account_id"
t.string "currency"
t.decimal "current_balance", precision: 19, scale: 4
t.string "account_status"
t.string "account_type"
t.string "provider"
t.jsonb "institution_metadata"
t.jsonb "raw_payload"
t.jsonb "raw_transactions_payload"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_coinstats_accounts_on_account_id"
t.index ["coinstats_item_id"], name: "index_coinstats_accounts_on_coinstats_item_id"
end
create_table "coinstats_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false
t.string "name"
t.string "institution_id"
t.string "institution_name"
t.string "institution_domain"
t.string "institution_url"
t.string "institution_color"
t.string "status", default: "good"
t.boolean "scheduled_for_deletion", default: false
t.boolean "pending_account_setup", default: false
t.datetime "sync_start_date"
t.jsonb "raw_payload"
t.jsonb "raw_institution_payload"
t.string "api_key", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["family_id"], name: "index_coinstats_items_on_family_id"
t.index ["status"], name: "index_coinstats_items_on_status"
end
create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -1171,6 +1210,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_12_15_100443) do
add_foreign_key "budgets", "families"
add_foreign_key "categories", "families"
add_foreign_key "chats", "users"
add_foreign_key "coinstats_accounts", "coinstats_items"
add_foreign_key "coinstats_items", "families"
add_foreign_key "enable_banking_accounts", "enable_banking_items"
add_foreign_key "enable_banking_items", "families"
add_foreign_key "entries", "accounts", on_delete: :cascade

View File

@@ -0,0 +1,178 @@
require "test_helper"
class CoinstatsItemsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
end
# Helper to wrap data in Provider::Response
def success_response(data)
Provider::Response.new(success?: true, data: data, error: nil)
end
def error_response(message)
Provider::Response.new(success?: false, data: nil, error: Provider::Error.new(message))
end
test "should get new" do
get new_coinstats_item_url
assert_response :success
end
test "should create coinstats item with valid api key" do
# Mock the API key validation
Provider::Coinstats.any_instance.expects(:get_blockchains).returns(success_response([])).once
assert_difference("CoinstatsItem.count", 1) do
post coinstats_items_url, params: {
coinstats_item: {
name: "New CoinStats Connection",
api_key: "valid_api_key"
}
}
end
end
test "should not create coinstats item with invalid api key" do
# Mock the API key validation to fail
Provider::Coinstats.any_instance.expects(:get_blockchains)
.returns(error_response("Invalid API key"))
assert_no_difference("CoinstatsItem.count") do
post coinstats_items_url, params: {
coinstats_item: {
name: "New CoinStats Connection",
api_key: "invalid_api_key"
}
}
end
end
test "should destroy coinstats item" do
# Schedules for deletion, doesn't actually delete immediately
assert_no_difference("CoinstatsItem.count") do
delete coinstats_item_url(@coinstats_item)
end
assert_redirected_to settings_providers_path
@coinstats_item.reload
assert @coinstats_item.scheduled_for_deletion?
end
test "should sync coinstats item" do
post sync_coinstats_item_url(@coinstats_item)
assert_redirected_to accounts_path
end
test "sync responds to json format" do
post sync_coinstats_item_url(@coinstats_item, format: :json)
assert_response :ok
end
test "should update coinstats item with valid api key" do
Provider::Coinstats.any_instance.expects(:get_blockchains).returns(success_response([])).once
patch coinstats_item_url(@coinstats_item), params: {
coinstats_item: {
name: "Updated Name",
api_key: "new_valid_api_key"
}
}
@coinstats_item.reload
assert_equal "Updated Name", @coinstats_item.name
end
test "should not update coinstats item with invalid api key" do
Provider::Coinstats.any_instance.expects(:get_blockchains)
.returns(error_response("Invalid API key"))
original_name = @coinstats_item.name
patch coinstats_item_url(@coinstats_item), params: {
coinstats_item: {
name: "Updated Name",
api_key: "invalid_api_key"
}
}
@coinstats_item.reload
assert_equal original_name, @coinstats_item.name
end
test "link_wallet requires all parameters" do
post link_wallet_coinstats_items_url, params: {
coinstats_item_id: @coinstats_item.id,
address: "0x123"
# missing blockchain
}
assert_response :unprocessable_entity
end
test "link_wallet with valid params creates accounts" do
balance_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 1.5, price: 2000 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with(bulk_response, "0x123abc", "ethereum")
.returns(balance_data)
assert_difference("Account.count", 1) do
assert_difference("CoinstatsAccount.count", 1) do
post link_wallet_coinstats_items_url, params: {
coinstats_item_id: @coinstats_item.id,
address: "0x123abc",
blockchain: "ethereum"
}
end
end
assert_redirected_to accounts_path
end
test "link_wallet handles provider errors" do
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.raises(Provider::Coinstats::Error.new("Invalid API key"))
post link_wallet_coinstats_items_url, params: {
coinstats_item_id: @coinstats_item.id,
address: "0x123abc",
blockchain: "ethereum"
}
assert_response :unprocessable_entity
end
test "link_wallet handles no tokens found" do
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.returns(success_response([]))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.returns([])
post link_wallet_coinstats_items_url, params: {
coinstats_item_id: @coinstats_item.id,
address: "0x123abc",
blockchain: "ethereum"
}
assert_response :unprocessable_entity
assert_match(/No tokens found/, response.body)
end
end

View File

@@ -0,0 +1,33 @@
require "test_helper"
class SyncHourlyJobTest < ActiveJob::TestCase
test "syncs all active items for each hourly syncable class" do
mock_item = mock("coinstats_item")
mock_item.expects(:sync_later).once
mock_relation = mock("active_relation")
mock_relation.stubs(:find_each).yields(mock_item)
CoinstatsItem.expects(:active).returns(mock_relation)
SyncHourlyJob.perform_now
end
test "continues syncing other items when one fails" do
failing_item = mock("failing_item")
failing_item.expects(:sync_later).raises(StandardError.new("Test error"))
failing_item.stubs(:id).returns(1)
success_item = mock("success_item")
success_item.expects(:sync_later).once
mock_relation = mock("active_relation")
mock_relation.stubs(:find_each).multiple_yields([ failing_item ], [ success_item ])
CoinstatsItem.expects(:active).returns(mock_relation)
assert_nothing_raised do
SyncHourlyJob.perform_now
end
end
end

View File

@@ -129,4 +129,49 @@ class AccountProviderTest < ActiveSupport::TestCase
assert_equal "plaid", plaid_provider.provider_name
assert_equal "simplefin", simplefin_provider.provider_name
end
test "destroying account_provider does not destroy non-coinstats provider accounts" do
provider = AccountProvider.create!(
account: @account,
provider: @plaid_account
)
plaid_account_id = @plaid_account.id
assert PlaidAccount.exists?(plaid_account_id)
provider.destroy!
# Non-CoinStats provider accounts should remain (can enter "needs setup" state)
assert PlaidAccount.exists?(plaid_account_id)
end
test "destroying account_provider destroys coinstats provider account" do
coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats",
api_key: "test_key"
)
coinstats_account = CoinstatsAccount.create!(
coinstats_item: coinstats_item,
name: "Test Wallet",
currency: "USD",
current_balance: 1000
)
provider = AccountProvider.create!(
account: @account,
provider: coinstats_account
)
coinstats_account_id = coinstats_account.id
assert CoinstatsAccount.exists?(coinstats_account_id)
provider.destroy!
# CoinStats provider accounts should be destroyed to avoid orphaned records
assert_not CoinstatsAccount.exists?(coinstats_account_id)
end
end

View File

@@ -0,0 +1,159 @@
require "test_helper"
class CoinstatsAccount::ProcessorTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
@crypto = Crypto.create!
@account = @family.accounts.create!(
accountable: @crypto,
name: "Test Crypto Account",
balance: 1000,
currency: "USD"
)
@coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD",
current_balance: 2500
)
AccountProvider.create!(account: @account, provider: @coinstats_account)
end
test "skips processing when no linked account" do
# Create an unlinked coinstats account
unlinked_account = @coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD",
current_balance: 1000
)
processor = CoinstatsAccount::Processor.new(unlinked_account)
# Should not raise, just return early
assert_nothing_raised do
processor.process
end
end
test "updates account balance from coinstats account" do
@coinstats_account.update!(current_balance: 5000.50)
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal BigDecimal("5000.50"), @account.balance
assert_equal BigDecimal("5000.50"), @account.cash_balance
end
test "updates account currency from coinstats account" do
@coinstats_account.update!(currency: "EUR")
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal "EUR", @account.currency
end
test "handles zero balance" do
@coinstats_account.update!(current_balance: 0)
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal BigDecimal("0"), @account.balance
end
test "handles nil balance as zero" do
@coinstats_account.update!(current_balance: nil)
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal BigDecimal("0"), @account.balance
end
test "processes transactions" do
@coinstats_account.update!(raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xabc123", explorerUrl: "https://etherscan.io/tx/0xabc123" }
}
])
processor = CoinstatsAccount::Processor.new(@coinstats_account)
# Mock the transaction processor to verify it's called
CoinstatsAccount::Transactions::Processor.any_instance
.expects(:process)
.returns({ success: true, total: 1, imported: 1, failed: 0, errors: [] })
.once
processor.process
end
test "continues processing when transaction processing fails" do
@coinstats_account.update!(raw_transactions_payload: [
{ type: "Received", date: "2025-01-15T10:00:00.000Z" }
])
processor = CoinstatsAccount::Processor.new(@coinstats_account)
# Mock transaction processing to raise an error
CoinstatsAccount::Transactions::Processor.any_instance
.expects(:process)
.raises(StandardError.new("Transaction processing error"))
# Should not raise - error is caught and reported
assert_nothing_raised do
processor.process
end
# Balance should still be updated
@account.reload
assert_equal BigDecimal("2500"), @account.balance
end
test "normalizes currency codes" do
@coinstats_account.update!(currency: "usd")
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal "USD", @account.currency
end
test "falls back to account currency when coinstats currency is nil" do
@account.update!(currency: "GBP")
# Use update_column to bypass validation
@coinstats_account.update_column(:currency, "")
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
# Empty currency falls through to account's existing currency
assert_equal "GBP", @account.currency
end
test "raises error when account update fails" do
# Make the account invalid by directly modifying a validation constraint
Account.any_instance.stubs(:update!).raises(ActiveRecord::RecordInvalid.new(@account))
processor = CoinstatsAccount::Processor.new(@coinstats_account)
assert_raises(ActiveRecord::RecordInvalid) do
processor.process
end
end
end

View File

@@ -0,0 +1,350 @@
require "test_helper"
class CoinstatsAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
@crypto = Crypto.create!
@account = @family.accounts.create!(
accountable: @crypto,
name: "Test ETH Account",
balance: 5000,
currency: "USD"
)
@coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum Wallet",
currency: "USD",
current_balance: 5000,
account_id: "ethereum"
)
AccountProvider.create!(account: @account, provider: @coinstats_account)
end
test "returns early when no transactions payload" do
@coinstats_account.update!(raw_transactions_payload: nil)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
result = processor.process
assert result[:success]
assert_equal 0, result[:total]
assert_equal 0, result[:imported]
assert_equal 0, result[:failed]
assert_empty result[:errors]
end
test "processes transactions from raw_transactions_payload" do
@coinstats_account.update!(raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xprocess1" },
transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
}
])
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 1 do
result = processor.process
assert result[:success]
assert_equal 1, result[:total]
assert_equal 1, result[:imported]
assert_equal 0, result[:failed]
end
end
test "filters transactions to only process matching coin" do
@coinstats_account.update!(raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xmatch1" },
transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
},
{
type: "Received",
date: "2025-01-16T10:00:00.000Z",
coinData: { count: 100, symbol: "USDC", currentValue: 100 },
hash: { id: "0xdifferent" },
transactions: [ { items: [ { coin: { id: "usd-coin" } } ] } ]
}
])
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
# Should only process the ETH transaction
assert_difference "Entry.count", 1 do
result = processor.process
assert result[:success]
assert_equal 1, result[:total]
end
# Verify the correct transaction was imported
entry = @account.entries.last
assert_equal "coinstats_0xmatch1", entry.external_id
end
test "handles transaction processing errors gracefully" do
@coinstats_account.update!(raw_transactions_payload: [
{
# Invalid transaction - missing required fields
type: "Received",
coinData: { count: 1.0, symbol: "ETH" },
transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
# Missing date and hash
}
])
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_no_difference "Entry.count" do
result = processor.process
refute result[:success]
assert_equal 1, result[:total]
assert_equal 0, result[:imported]
assert_equal 1, result[:failed]
assert_equal 1, result[:errors].count
end
end
test "processes multiple valid transactions" do
@coinstats_account.update!(raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xmulti1" },
transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
},
{
type: "Sent",
date: "2025-01-16T10:00:00.000Z",
coinData: { count: -0.5, symbol: "ETH", currentValue: 1000 },
hash: { id: "0xmulti2" },
transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
}
])
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 2 do
result = processor.process
assert result[:success]
assert_equal 2, result[:total]
assert_equal 2, result[:imported]
end
end
test "matches by coin symbol in coinData as fallback" do
@coinstats_account.update!(
name: "ETH Wallet",
account_id: "ethereum",
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xsymbol1" }
# No transactions array with coin.id
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 1 do
result = processor.process
assert result[:success]
end
end
test "processes all transactions when no account_id set" do
@coinstats_account.update!(
account_id: nil,
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xnofilter1" }
},
{
type: "Received",
date: "2025-01-16T10:00:00.000Z",
coinData: { count: 100, symbol: "USDC", currentValue: 100 },
hash: { id: "0xnofilter2" }
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 2 do
result = processor.process
assert result[:success]
assert_equal 2, result[:total]
end
end
test "tracks failed transactions with errors" do
@coinstats_account.update!(
account_id: nil,
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xvalid1" }
},
{
# Missing date
type: "Received",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xinvalid" }
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
result = processor.process
refute result[:success]
assert_equal 2, result[:total]
assert_equal 1, result[:imported]
assert_equal 1, result[:failed]
assert_equal 1, result[:errors].count
error = result[:errors].first
assert_equal "0xinvalid", error[:transaction_id]
assert_match(/Validation error/, error[:error])
end
# Tests for strict symbol matching to avoid false positives
# (e.g., "ETH" should not match "Ethereum Classic" which has symbol "ETC")
test "symbol matching does not cause false positives with similar names" do
# Ethereum Classic wallet should NOT match ETH transactions
@coinstats_account.update!(
name: "Ethereum Classic (0x1234abcd...)",
account_id: "ethereum-classic",
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xfalsepositive1" }
# No coin.id, relies on symbol matching fallback
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
# Should NOT process - "ETH" should not match "Ethereum Classic"
assert_no_difference "Entry.count" do
result = processor.process
assert result[:success]
assert_equal 0, result[:total]
end
end
test "symbol matching works with parenthesized token format" do
@coinstats_account.update!(
name: "Ethereum (ETH)",
account_id: "ethereum",
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xparenthesized1" }
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 1 do
result = processor.process
assert result[:success]
end
end
test "symbol matching works with symbol as whole word in name" do
@coinstats_account.update!(
name: "ETH Wallet",
account_id: "ethereum",
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xwholeword1" }
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 1 do
result = processor.process
assert result[:success]
end
end
test "symbol matching does not match partial substrings" do
# WETH wallet should NOT match ETH transactions via symbol fallback
@coinstats_account.update!(
name: "WETH Wrapped Ethereum",
account_id: "weth",
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xpartial1" }
# No coin.id, relies on symbol matching fallback
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
# Should NOT process - "ETH" is a substring of "WETH" but not a whole word match
assert_no_difference "Entry.count" do
result = processor.process
assert result[:success]
assert_equal 0, result[:total]
end
end
test "symbol matching is case insensitive" do
@coinstats_account.update!(
name: "eth wallet",
account_id: "ethereum",
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xcaseinsensitive1" }
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 1 do
result = processor.process
assert result[:success]
end
end
end

View File

@@ -0,0 +1,202 @@
require "test_helper"
class CoinstatsAccountTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
@coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD",
current_balance: 1000.00
)
end
test "belongs to coinstats_item" do
assert_equal @coinstats_item, @coinstats_account.coinstats_item
end
test "can have account through account_provider" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Linked Crypto Account",
balance: 1000,
currency: "USD"
)
AccountProvider.create!(account: account, provider: @coinstats_account)
assert_equal account, @coinstats_account.account
assert_equal account, @coinstats_account.current_account
end
test "requires name to be present" do
coinstats_account = @coinstats_item.coinstats_accounts.build(
currency: "USD"
)
coinstats_account.name = nil
assert_not coinstats_account.valid?
assert_includes coinstats_account.errors[:name], "can't be blank"
end
test "requires currency to be present" do
coinstats_account = @coinstats_item.coinstats_accounts.build(
name: "Test"
)
coinstats_account.currency = nil
assert_not coinstats_account.valid?
assert_includes coinstats_account.errors[:currency], "can't be blank"
end
test "account_id is unique per coinstats_item" do
@coinstats_account.update!(account_id: "unique_account_id_123")
duplicate = @coinstats_item.coinstats_accounts.build(
name: "Duplicate",
currency: "USD",
account_id: "unique_account_id_123"
)
assert_not duplicate.valid?
assert_includes duplicate.errors[:account_id], "has already been taken"
end
test "allows nil account_id for multiple accounts" do
second_account = @coinstats_item.coinstats_accounts.build(
name: "Second Account",
currency: "USD",
account_id: nil
)
assert second_account.valid?
end
test "upsert_coinstats_snapshot updates balance and metadata" do
snapshot = {
balance: 2500.50,
currency: "USD",
name: "Updated Wallet Name",
status: "active",
provider: "coinstats",
institution_logo: "https://example.com/logo.png"
}
@coinstats_account.upsert_coinstats_snapshot!(snapshot)
@coinstats_account.reload
assert_equal BigDecimal("2500.50"), @coinstats_account.current_balance
assert_equal "USD", @coinstats_account.currency
assert_equal "Updated Wallet Name", @coinstats_account.name
assert_equal "active", @coinstats_account.account_status
assert_equal "coinstats", @coinstats_account.provider
assert_equal({ "logo" => "https://example.com/logo.png" }, @coinstats_account.institution_metadata)
assert_equal snapshot.stringify_keys, @coinstats_account.raw_payload
end
test "upsert_coinstats_snapshot handles symbol keys" do
snapshot = {
balance: 3000.0,
currency: "USD",
name: "Symbol Key Wallet"
}
@coinstats_account.upsert_coinstats_snapshot!(snapshot)
@coinstats_account.reload
assert_equal BigDecimal("3000.0"), @coinstats_account.current_balance
assert_equal "Symbol Key Wallet", @coinstats_account.name
end
test "upsert_coinstats_snapshot handles string keys" do
snapshot = {
"balance" => 3500.0,
"currency" => "USD",
"name" => "String Key Wallet"
}
@coinstats_account.upsert_coinstats_snapshot!(snapshot)
@coinstats_account.reload
assert_equal BigDecimal("3500.0"), @coinstats_account.current_balance
assert_equal "String Key Wallet", @coinstats_account.name
end
test "upsert_coinstats_snapshot sets account_id from id if not already set" do
@coinstats_account.update!(account_id: nil)
snapshot = {
id: "new_token_id_123",
balance: 1000.0,
currency: "USD",
name: "Test"
}
@coinstats_account.upsert_coinstats_snapshot!(snapshot)
@coinstats_account.reload
assert_equal "new_token_id_123", @coinstats_account.account_id
end
test "upsert_coinstats_snapshot preserves existing account_id" do
@coinstats_account.update!(account_id: "existing_id")
snapshot = {
id: "different_id",
balance: 1000.0,
currency: "USD",
name: "Test"
}
@coinstats_account.upsert_coinstats_snapshot!(snapshot)
@coinstats_account.reload
assert_equal "existing_id", @coinstats_account.account_id
end
test "upsert_coinstats_transactions_snapshot stores transactions array" do
transactions = [
{ type: "Received", date: "2025-01-01T10:00:00.000Z", hash: { id: "0xabc" } },
{ type: "Sent", date: "2025-01-02T11:00:00.000Z", hash: { id: "0xdef" } }
]
@coinstats_account.upsert_coinstats_transactions_snapshot!(transactions)
@coinstats_account.reload
assert_equal 2, @coinstats_account.raw_transactions_payload.count
# Keys may be strings after DB round-trip
first_tx = @coinstats_account.raw_transactions_payload.first
assert_equal "0xabc", first_tx.dig("hash", "id") || first_tx.dig(:hash, :id)
end
test "upsert_coinstats_transactions_snapshot extracts result from hash response" do
response = {
meta: { page: 1, limit: 100 },
result: [
{ type: "Received", date: "2025-01-01T10:00:00.000Z", hash: { id: "0xabc" } }
]
}
@coinstats_account.upsert_coinstats_transactions_snapshot!(response)
@coinstats_account.reload
assert_equal 1, @coinstats_account.raw_transactions_payload.count
assert_equal "0xabc", @coinstats_account.raw_transactions_payload.first["hash"]["id"].to_s
end
test "upsert_coinstats_transactions_snapshot handles empty result" do
response = {
meta: { page: 1, limit: 100 },
result: []
}
@coinstats_account.upsert_coinstats_transactions_snapshot!(response)
@coinstats_account.reload
assert_equal [], @coinstats_account.raw_transactions_payload
end
end

View File

@@ -0,0 +1,267 @@
require "test_helper"
class CoinstatsEntry::ProcessorTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
@crypto = Crypto.create!
@account = @family.accounts.create!(
accountable: @crypto,
name: "Test Crypto Account",
balance: 1000,
currency: "USD"
)
@coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test ETH Wallet",
currency: "USD",
current_balance: 5000,
institution_metadata: { "logo" => "https://example.com/eth.png" }
)
AccountProvider.create!(account: @account, provider: @coinstats_account)
end
test "processes received transaction" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.5, symbol: "ETH", currentValue: 3000 },
hash: { id: "0xabc123", explorerUrl: "https://etherscan.io/tx/0xabc123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_difference "Entry.count", 1 do
processor.process
end
entry = @account.entries.last
assert_equal "coinstats_0xabc123", entry.external_id
assert_equal BigDecimal("-3000"), entry.amount # Negative = income
assert_equal "USD", entry.currency
assert_equal Date.new(2025, 1, 15), entry.date
assert_equal "Received ETH", entry.name
end
test "processes sent transaction" do
transaction_data = {
type: "Sent",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: -0.5, symbol: "ETH", currentValue: 1000 },
hash: { id: "0xdef456", explorerUrl: "https://etherscan.io/tx/0xdef456" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_difference "Entry.count", 1 do
processor.process
end
entry = @account.entries.last
assert_equal BigDecimal("1000"), entry.amount # Positive = expense
assert_equal "Sent ETH", entry.name
end
test "stores extra metadata" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xmeta123", explorerUrl: "https://etherscan.io/tx/0xmeta123" },
profitLoss: { profit: 100.50, profitPercent: 5.25 },
fee: { count: 0.001, coin: { symbol: "ETH" }, totalWorth: 2.0 }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
extra = entry.transaction.extra["coinstats"]
assert_equal "0xmeta123", extra["transaction_hash"]
assert_equal "https://etherscan.io/tx/0xmeta123", extra["explorer_url"]
assert_equal "Received", extra["transaction_type"]
assert_equal "ETH", extra["symbol"]
assert_equal 1.0, extra["count"]
assert_equal 100.50, extra["profit"]
assert_equal 5.25, extra["profit_percent"]
assert_equal 0.001, extra["fee_amount"]
assert_equal "ETH", extra["fee_symbol"]
assert_equal 2.0, extra["fee_usd"]
end
test "handles UTXO transaction ID format" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 0.1, symbol: "BTC", currentValue: 4000 },
transactions: [
{ items: [ { id: "utxo_tx_id_123" } ] }
]
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_equal "coinstats_utxo_tx_id_123", entry.external_id
end
test "generates fallback ID when no hash available" do
transaction_data = {
type: "Swap",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 100, symbol: "USDC", currentValue: 100 }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
# Fallback IDs use a hash digest format: "coinstats_fallback_<16-char-hex>"
assert_match(/^coinstats_fallback_[a-f0-9]{16}$/, entry.external_id)
end
test "raises error when transaction missing identifier" do
transaction_data = {
type: nil,
date: nil,
coinData: { count: nil }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_raises(ArgumentError) do
processor.process
end
end
test "skips processing when no linked account" do
unlinked_account = @coinstats_item.coinstats_accounts.create!(
name: "Unlinked",
currency: "USD"
)
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xskip123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: unlinked_account)
assert_no_difference "Entry.count" do
result = processor.process
assert_nil result
end
end
test "creates notes with transaction details" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.5, symbol: "ETH", currentValue: 3000 },
hash: { id: "0xnotes123", explorerUrl: "https://etherscan.io/tx/0xnotes123" },
profitLoss: { profit: 150.00, profitPercent: 10.0 },
fee: { count: 0.002, coin: { symbol: "ETH" }, totalWorth: 4.0 }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_includes entry.notes, "1.5 ETH"
assert_includes entry.notes, "Fee: 0.002 ETH"
assert_includes entry.notes, "P/L: $150.0 (10.0%)"
assert_includes entry.notes, "Explorer: https://etherscan.io/tx/0xnotes123"
end
test "handles integer timestamp" do
timestamp = Time.new(2025, 1, 15, 10, 0, 0).to_i
transaction_data = {
type: "Received",
date: timestamp,
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xtimestamp123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_equal Date.new(2025, 1, 15), entry.date
end
test "raises error for missing date" do
transaction_data = {
type: "Received",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xnodate123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_raises(ArgumentError) do
processor.process
end
end
test "builds name with symbol preferring it over coin name" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "WETH" },
hash: { id: "0xname123" },
profitLoss: { currentValue: 2000 },
transactions: [
{ items: [ { coin: { name: "Wrapped Ether" } } ] }
]
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_equal "Received WETH", entry.name
end
test "handles swap out as outgoing transaction" do
transaction_data = {
type: "swap_out",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xswap123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_equal BigDecimal("2000"), entry.amount # Positive = expense/outflow
end
test "is idempotent - does not duplicate transactions" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xidempotent123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_difference "Entry.count", 1 do
processor.process
end
# Processing again should not create duplicate
assert_no_difference "Entry.count" do
processor.process
end
end
end

View File

@@ -0,0 +1,480 @@
require "test_helper"
class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
@mock_provider = mock("Provider::Coinstats")
end
# Helper to wrap data in Provider::Response
def success_response(data)
Provider::Response.new(success?: true, data: data, error: nil)
end
def error_response(message)
Provider::Response.new(success?: false, data: nil, error: Provider::Error.new(message))
end
test "returns early when no linked accounts" do
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
assert result[:success]
assert_equal 0, result[:accounts_updated]
assert_equal 0, result[:transactions_imported]
end
test "updates linked accounts with balance data" do
# Create a linked coinstats account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Ethereum",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum Wallet",
currency: "USD",
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
)
AccountProvider.create!(account: account, provider: coinstats_account)
# Mock balance response
balance_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 1.5, price: 2000, imgUrl: "https://example.com/eth.png" }
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
]
@mock_provider.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response(bulk_response))
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0x123abc", "ethereum")
.returns(balance_data)
bulk_transactions_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", transactions: [] }
]
@mock_provider.expects(:get_wallet_transactions)
.with("ethereum:0x123abc")
.returns(success_response(bulk_transactions_response))
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "0x123abc", "ethereum")
.returns([])
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
assert result[:success]
assert_equal 1, result[:accounts_updated]
assert_equal 0, result[:accounts_failed]
end
test "skips account when missing address or blockchain" do
# Create a linked account with missing wallet info
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Missing Info Wallet",
currency: "USD",
raw_payload: {} # Missing address and blockchain
)
AccountProvider.create!(account: account, provider: coinstats_account)
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
# The import succeeds but no accounts are updated (missing info returns success: false)
assert result[:success] # No exceptions = success
assert_equal 0, result[:accounts_updated]
assert_equal 0, result[:accounts_failed] # Doesn't count as "failed" - only exceptions do
end
test "imports transactions and merges with existing" do
# Create a linked coinstats account with existing transactions
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Ethereum",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum Wallet",
currency: "USD",
account_id: "ethereum",
raw_payload: { address: "0x123abc", blockchain: "ethereum" },
raw_transactions_payload: [
{ hash: { id: "0xexisting1" }, type: "Received", date: "2025-01-01T10:00:00.000Z", transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ] }
]
)
AccountProvider.create!(account: account, provider: coinstats_account)
balance_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 2.0, price: 2500 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
]
@mock_provider.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response(bulk_response))
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0x123abc", "ethereum")
.returns(balance_data)
new_transactions = [
{ hash: { id: "0xexisting1" }, type: "Received", date: "2025-01-01T10:00:00.000Z", transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ] }, # duplicate
{ hash: { id: "0xnew1" }, type: "Sent", date: "2025-01-02T11:00:00.000Z", transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ] } # new
]
bulk_transactions_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", transactions: new_transactions }
]
@mock_provider.expects(:get_wallet_transactions)
.with("ethereum:0x123abc")
.returns(success_response(bulk_transactions_response))
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "0x123abc", "ethereum")
.returns(new_transactions)
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
assert result[:success]
assert_equal 1, result[:accounts_updated]
# Should have 2 transactions (1 existing + 1 new, no duplicate)
coinstats_account.reload
assert_equal 2, coinstats_account.raw_transactions_payload.count
end
test "handles rate limit error during transactions fetch gracefully" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Ethereum",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum Wallet",
currency: "USD",
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
)
AccountProvider.create!(account: account, provider: coinstats_account)
balance_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 1.0, price: 2000 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
]
@mock_provider.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response(bulk_response))
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0x123abc", "ethereum")
.returns(balance_data)
# Bulk transaction fetch fails with error - returns error response from fetch_transactions_for_accounts
@mock_provider.expects(:get_wallet_transactions)
.with("ethereum:0x123abc")
.raises(Provider::Coinstats::Error.new("Rate limited"))
# When bulk fetch fails, extract_wallet_transactions is not called (bulk_transactions_data is nil)
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
# Should still succeed since balance was updated
assert result[:success]
assert_equal 1, result[:accounts_updated]
assert_equal 0, result[:transactions_imported]
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!
account1 = @family.accounts.create!(
accountable: crypto1,
name: "Ethereum (0xmu...ulti)",
balance: 0,
currency: "USD"
)
coinstats_account1 = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum (0xmu...ulti)",
currency: "USD",
account_id: "ethereum",
raw_payload: { address: "0xmulti", blockchain: "ethereum" }
)
AccountProvider.create!(account: account1, provider: coinstats_account1)
crypto2 = Crypto.create!
account2 = @family.accounts.create!(
accountable: crypto2,
name: "Dai Stablecoin (0xmu...ulti)",
balance: 0,
currency: "USD"
)
coinstats_account2 = @coinstats_item.coinstats_accounts.create!(
name: "Dai Stablecoin (0xmu...ulti)",
currency: "USD",
account_id: "dai",
raw_payload: { address: "0xmulti", blockchain: "ethereum" }
)
AccountProvider.create!(account: account2, provider: coinstats_account2)
# Multiple tokens with different values
balance_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 2.0, price: 2000 }, # $4000
{ coinId: "dai", name: "Dai Stablecoin", symbol: "DAI", amount: 1000, price: 1 } # $1000
]
# Both accounts share the same wallet address/blockchain, so only one unique wallet
bulk_response = [
{ blockchain: "ethereum", address: "0xmulti", connectionId: "ethereum", balances: balance_data }
]
@mock_provider.expects(:get_wallet_balances)
.with("ethereum:0xmulti")
.returns(success_response(bulk_response))
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0xmulti", "ethereum")
.returns(balance_data)
.twice
bulk_transactions_response = [
{ blockchain: "ethereum", address: "0xmulti", connectionId: "ethereum", transactions: [] }
]
@mock_provider.expects(:get_wallet_transactions)
.with("ethereum:0xmulti")
.returns(success_response(bulk_transactions_response))
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "0xmulti", "ethereum")
.returns([])
.twice
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
importer.import
coinstats_account1.reload
coinstats_account2.reload
# Each account should have only its matching token's balance, not the total
# ETH: 2.0 * 2000 = $4000
assert_equal 4000.0, coinstats_account1.current_balance.to_f
# DAI: 1000 * 1 = $1000
assert_equal 1000.0, coinstats_account2.current_balance.to_f
end
test "handles api errors for individual accounts without failing entire import" do
crypto1 = Crypto.create!
account1 = @family.accounts.create!(
accountable: crypto1,
name: "Working Wallet",
balance: 1000,
currency: "USD"
)
coinstats_account1 = @coinstats_item.coinstats_accounts.create!(
name: "Working Wallet",
currency: "USD",
raw_payload: { address: "0xworking", blockchain: "ethereum" }
)
AccountProvider.create!(account: account1, provider: coinstats_account1)
crypto2 = Crypto.create!
account2 = @family.accounts.create!(
accountable: crypto2,
name: "Failing Wallet",
balance: 500,
currency: "USD"
)
coinstats_account2 = @coinstats_item.coinstats_accounts.create!(
name: "Failing Wallet",
currency: "USD",
raw_payload: { address: "0xfailing", blockchain: "ethereum" }
)
AccountProvider.create!(account: account2, provider: coinstats_account2)
# With multiple wallets, bulk endpoint is used
# Bulk response includes only the working wallet's data
bulk_response = [
{
blockchain: "ethereum",
address: "0xworking",
connectionId: "ethereum",
balances: [ { coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 } ]
}
# 0xfailing not included - simulates partial failure or missing data
]
@mock_provider.expects(:get_wallet_balances)
.with("ethereum:0xworking,ethereum:0xfailing")
.returns(success_response(bulk_response))
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0xworking", "ethereum")
.returns([ { coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 } ])
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0xfailing", "ethereum")
.returns([]) # Empty array for missing wallet
bulk_transactions_response = [
{
blockchain: "ethereum",
address: "0xworking",
connectionId: "ethereum",
transactions: []
}
]
@mock_provider.expects(:get_wallet_transactions)
.with("ethereum:0xworking,ethereum:0xfailing")
.returns(success_response(bulk_transactions_response))
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "0xworking", "ethereum")
.returns([])
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "0xfailing", "ethereum")
.returns([])
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
assert result[:success] # Both accounts updated (one with empty balance)
assert_equal 2, result[:accounts_updated]
assert_equal 0, result[:accounts_failed]
end
test "uses bulk endpoint for multiple unique wallets and falls back on error" do
# Create accounts with two different wallet addresses
crypto1 = Crypto.create!
account1 = @family.accounts.create!(
accountable: crypto1,
name: "Ethereum Wallet",
balance: 0,
currency: "USD"
)
coinstats_account1 = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum Wallet",
currency: "USD",
raw_payload: { address: "0xeth123", blockchain: "ethereum" }
)
AccountProvider.create!(account: account1, provider: coinstats_account1)
crypto2 = Crypto.create!
account2 = @family.accounts.create!(
accountable: crypto2,
name: "Bitcoin Wallet",
balance: 0,
currency: "USD"
)
coinstats_account2 = @coinstats_item.coinstats_accounts.create!(
name: "Bitcoin Wallet",
currency: "USD",
raw_payload: { address: "bc1qbtc456", blockchain: "bitcoin" }
)
AccountProvider.create!(account: account2, provider: coinstats_account2)
# Bulk endpoint returns data for both wallets
bulk_response = [
{
blockchain: "ethereum",
address: "0xeth123",
connectionId: "ethereum",
balances: [ { coinId: "ethereum", name: "Ethereum", amount: 2.0, price: 2500 } ]
},
{
blockchain: "bitcoin",
address: "bc1qbtc456",
connectionId: "bitcoin",
balances: [ { coinId: "bitcoin", name: "Bitcoin", amount: 0.1, price: 45000 } ]
}
]
@mock_provider.expects(:get_wallet_balances)
.with("ethereum:0xeth123,bitcoin:bc1qbtc456")
.returns(success_response(bulk_response))
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0xeth123", "ethereum")
.returns([ { coinId: "ethereum", name: "Ethereum", amount: 2.0, price: 2500 } ])
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "bc1qbtc456", "bitcoin")
.returns([ { coinId: "bitcoin", name: "Bitcoin", amount: 0.1, price: 45000 } ])
bulk_transactions_response = [
{
blockchain: "ethereum",
address: "0xeth123",
connectionId: "ethereum",
transactions: []
},
{
blockchain: "bitcoin",
address: "bc1qbtc456",
connectionId: "bitcoin",
transactions: []
}
]
@mock_provider.expects(:get_wallet_transactions)
.with("ethereum:0xeth123,bitcoin:bc1qbtc456")
.returns(success_response(bulk_transactions_response))
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "0xeth123", "ethereum")
.returns([])
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "bc1qbtc456", "bitcoin")
.returns([])
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
assert result[:success]
assert_equal 2, result[:accounts_updated]
# Verify balances were updated
coinstats_account1.reload
coinstats_account2.reload
assert_equal 5000.0, coinstats_account1.current_balance.to_f # 2.0 * 2500
assert_equal 4500.0, coinstats_account2.current_balance.to_f # 0.1 * 45000
end
end

View File

@@ -0,0 +1,177 @@
require "test_helper"
class CoinstatsItem::SyncerTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
@syncer = CoinstatsItem::Syncer.new(@coinstats_item)
end
test "perform_sync imports data from coinstats api" do
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@syncer.perform_sync(mock_sync)
end
test "perform_sync updates pending_account_setup when unlinked accounts exist" do
# Create an unlinked coinstats account (no AccountProvider)
@coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD"
)
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@syncer.perform_sync(mock_sync)
assert @coinstats_item.reload.pending_account_setup?
end
test "perform_sync clears pending_account_setup when all accounts linked" do
@coinstats_item.update!(pending_account_setup: true)
# Create a linked coinstats account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Linked Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@coinstats_item.expects(:process_accounts).once
@coinstats_item.expects(:schedule_account_syncs).once
@syncer.perform_sync(mock_sync)
refute @coinstats_item.reload.pending_account_setup?
end
test "perform_sync processes accounts when linked accounts exist" do
# Create a linked coinstats account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Linked Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@coinstats_item.expects(:process_accounts).once
@coinstats_item.expects(:schedule_account_syncs).with(
parent_sync: mock_sync,
window_start_date: nil,
window_end_date: nil
).once
@syncer.perform_sync(mock_sync)
end
test "perform_sync skips processing when no linked accounts" do
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@coinstats_item.expects(:process_accounts).never
@coinstats_item.expects(:schedule_account_syncs).never
@syncer.perform_sync(mock_sync)
end
test "perform_sync records sync stats" do
# Create one linked and one unlinked account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
linked_account = @coinstats_item.coinstats_accounts.create!(
name: "Linked Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: linked_account)
@coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD"
)
recorded_stats = nil
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once.with do |args|
recorded_stats = args[:sync_stats] if args.key?(:sync_stats)
true
end
@coinstats_item.expects(:import_latest_coinstats_data).once
@coinstats_item.expects(:process_accounts).once
@coinstats_item.expects(:schedule_account_syncs).once
@syncer.perform_sync(mock_sync)
assert_equal 2, recorded_stats[:total_accounts]
assert_equal 1, recorded_stats[:linked_accounts]
assert_equal 1, recorded_stats[:unlinked_accounts]
end
test "perform_post_sync is a no-op" do
# Just ensure it doesn't raise
assert_nothing_raised do
@syncer.perform_post_sync
end
end
end

View File

@@ -0,0 +1,280 @@
require "test_helper"
class CoinstatsItem::WalletLinkerTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
end
# Helper to wrap data in Provider::Response
def success_response(data)
Provider::Response.new(success?: true, data: data, error: nil)
end
test "link returns failure when no tokens found" do
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response([]))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with([], "0x123abc", "ethereum")
.returns([])
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123abc", blockchain: "ethereum")
result = linker.link
refute result.success?
assert_equal 0, result.created_count
assert_includes result.errors, "No tokens found for wallet"
end
test "link creates account from single token" do
token_data = [
{
coinId: "ethereum",
name: "Ethereum",
symbol: "ETH",
amount: 1.5,
price: 2000,
imgUrl: "https://example.com/eth.png"
}
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with(bulk_response, "0x123abc", "ethereum")
.returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123abc", blockchain: "ethereum")
assert_difference [ "Account.count", "CoinstatsAccount.count", "AccountProvider.count" ], 1 do
result = linker.link
assert result.success?
assert_equal 1, result.created_count
assert_empty result.errors
end
# Verify the account was created correctly
coinstats_account = @coinstats_item.coinstats_accounts.last
# Note: upsert_coinstats_snapshot! overwrites name with raw token name
assert_equal "Ethereum", coinstats_account.name
assert_equal "USD", coinstats_account.currency
assert_equal 3000.0, coinstats_account.current_balance.to_f # 1.5 * 2000
account = coinstats_account.account
# Account name is set before upsert_coinstats_snapshot so it keeps the formatted name
assert_equal "Ethereum (0x12...3abc)", account.name
assert_equal 3000.0, account.balance.to_f
assert_equal "USD", account.currency
assert_equal "Crypto", account.accountable_type
end
test "link creates multiple accounts from multiple tokens" do
token_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 2.0, price: 2000 },
{ coinId: "dai", name: "Dai Stablecoin", symbol: "DAI", amount: 1000, price: 1 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0xmulti", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0xmulti")
.returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with(bulk_response, "0xmulti", "ethereum")
.returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xmulti", blockchain: "ethereum")
assert_difference "Account.count", 2 do
assert_difference "CoinstatsAccount.count", 2 do
result = linker.link
assert result.success?
assert_equal 2, result.created_count
end
end
end
test "link triggers sync after creating accounts" do
token_data = [
{ coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
@coinstats_item.expects(:sync_later).once
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123", blockchain: "ethereum")
linker.link
end
test "link does not trigger sync when no accounts created" do
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response([]))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns([])
@coinstats_item.expects(:sync_later).never
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123", blockchain: "ethereum")
linker.link
end
test "link stores wallet metadata in raw_payload" do
token_data = [
{
coinId: "ethereum",
name: "Ethereum",
symbol: "ETH",
amount: 1.0,
price: 2000,
imgUrl: "https://example.com/eth.png"
}
]
bulk_response = [
{ blockchain: "ethereum", address: "0xtest123", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0xtest123")
.returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with(bulk_response, "0xtest123", "ethereum")
.returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest123", blockchain: "ethereum")
linker.link
coinstats_account = @coinstats_item.coinstats_accounts.last
raw_payload = coinstats_account.raw_payload
assert_equal "0xtest123", raw_payload["address"]
assert_equal "ethereum", raw_payload["blockchain"]
assert_equal "https://example.com/eth.png", raw_payload["institution_logo"]
end
test "link handles account creation errors gracefully" do
token_data = [
{ coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 },
{ coinId: "bad", name: nil, amount: 1.0, price: 100 } # Will fail validation
]
bulk_response = [
{ blockchain: "ethereum", address: "0xtest", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
# We need to mock the error scenario - name can't be blank
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest", blockchain: "ethereum")
result = linker.link
# Should create the valid account but have errors for the invalid one
assert result.success? # At least one succeeded
assert result.created_count >= 1
end
test "link builds correct account name with address suffix" do
token_data = [
{ coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0xABCDEF123456", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xABCDEF123456", blockchain: "ethereum")
linker.link
# Account name includes the address suffix (created before upsert_coinstats_snapshot)
account = @coinstats_item.accounts.last
assert_equal "Ethereum (0xAB...3456)", account.name
end
test "link handles single token as hash instead of array" do
token_data = {
coinId: "bitcoin",
name: "Bitcoin",
symbol: "BTC",
amount: 0.5,
price: 40000
}
bulk_response = [
{ blockchain: "bitcoin", address: "bc1qtest", connectionId: "bitcoin", balances: [ token_data ] }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "bc1qtest", blockchain: "bitcoin")
assert_difference "Account.count", 1 do
result = linker.link
assert result.success?
end
account = @coinstats_item.coinstats_accounts.last
assert_equal 20000.0, account.current_balance.to_f # 0.5 * 40000
end
test "link stores correct account_id from token" do
token_data = [
{ coinId: "unique_token_123", name: "My Token", amount: 100, price: 1 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0xtest", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest", blockchain: "ethereum")
linker.link
coinstats_account = @coinstats_item.coinstats_accounts.last
assert_equal "unique_token_123", coinstats_account.account_id
end
test "link falls back to id field for account_id" do
token_data = [
{ id: "fallback_id_456", name: "Fallback Token", amount: 50, price: 2 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0xtest", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest", blockchain: "ethereum")
linker.link
coinstats_account = @coinstats_item.coinstats_accounts.last
assert_equal "fallback_id_456", coinstats_account.account_id
end
end

View File

@@ -0,0 +1,231 @@
require "test_helper"
class CoinstatsItemTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
end
test "belongs to family" do
assert_equal @family, @coinstats_item.family
end
test "has many coinstats_accounts" do
account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD",
current_balance: 1000.00
)
assert_includes @coinstats_item.coinstats_accounts, account
end
test "has good status by default" do
assert_equal "good", @coinstats_item.status
end
test "can be marked for deletion" do
refute @coinstats_item.scheduled_for_deletion?
@coinstats_item.destroy_later
assert @coinstats_item.scheduled_for_deletion?
end
test "is syncable" do
assert_respond_to @coinstats_item, :sync_later
assert_respond_to @coinstats_item, :syncing?
end
test "requires name to be present" do
coinstats_item = CoinstatsItem.new(family: @family, api_key: "key")
coinstats_item.name = nil
assert_not coinstats_item.valid?
assert_includes coinstats_item.errors[:name], "can't be blank"
end
test "requires api_key to be present" do
coinstats_item = CoinstatsItem.new(family: @family, name: "Test")
coinstats_item.api_key = nil
assert_not coinstats_item.valid?
assert_includes coinstats_item.errors[:api_key], "can't be blank"
end
test "requires api_key to be present on update" do
@coinstats_item.api_key = ""
assert_not @coinstats_item.valid?
assert_includes @coinstats_item.errors[:api_key], "can't be blank"
end
test "scopes work correctly" do
# Create one for deletion
item_for_deletion = CoinstatsItem.create!(
family: @family,
name: "Delete Me",
api_key: "delete_key",
scheduled_for_deletion: true
)
active_items = CoinstatsItem.active
ordered_items = CoinstatsItem.ordered
assert_includes active_items, @coinstats_item
refute_includes active_items, item_for_deletion
assert_equal [ @coinstats_item, item_for_deletion ].sort_by(&:created_at).reverse,
ordered_items.to_a
end
test "needs_update scope returns items requiring update" do
@coinstats_item.update!(status: :requires_update)
good_item = CoinstatsItem.create!(
family: @family,
name: "Good Item",
api_key: "good_key"
)
needs_update_items = CoinstatsItem.needs_update
assert_includes needs_update_items, @coinstats_item
refute_includes needs_update_items, good_item
end
test "institution display name returns name when present" do
assert_equal "Test CoinStats Connection", @coinstats_item.institution_display_name
end
test "institution display name falls back to CoinStats" do
# Bypass validation by using update_column
@coinstats_item.update_column(:name, "")
assert_equal "CoinStats", @coinstats_item.institution_display_name
end
test "credentials_configured? returns true when api_key present" do
assert @coinstats_item.credentials_configured?
end
test "credentials_configured? returns false when api_key blank" do
@coinstats_item.api_key = nil
refute @coinstats_item.credentials_configured?
end
test "upserts coinstats snapshot" do
snapshot_data = {
total_balance: 5000.0,
wallets: [ { address: "0x123", blockchain: "ethereum" } ]
}
@coinstats_item.upsert_coinstats_snapshot!(snapshot_data)
@coinstats_item.reload
# Verify key data is stored correctly (keys may be string or symbol)
assert_equal 5000.0, @coinstats_item.raw_payload["total_balance"]
assert_equal 1, @coinstats_item.raw_payload["wallets"].count
assert_equal "0x123", @coinstats_item.raw_payload["wallets"].first["address"]
end
test "has_completed_initial_setup? returns false when no accounts" do
refute @coinstats_item.has_completed_initial_setup?
end
test "has_completed_initial_setup? returns true when accounts exist" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
assert @coinstats_item.has_completed_initial_setup?
end
test "linked_accounts_count returns count of accounts with provider links" do
# Initially no linked accounts
assert_equal 0, @coinstats_item.linked_accounts_count
# Create a linked account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
assert_equal 1, @coinstats_item.linked_accounts_count
end
test "unlinked_accounts_count returns count of accounts without provider links" do
# Create an unlinked account
@coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD"
)
assert_equal 1, @coinstats_item.unlinked_accounts_count
end
test "sync_status_summary shows no accounts message" do
assert_equal "No crypto wallets found", @coinstats_item.sync_status_summary
end
test "sync_status_summary shows all synced message" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
assert_equal "1 crypto wallet synced", @coinstats_item.sync_status_summary
end
test "sync_status_summary shows mixed status message" do
# Create a linked account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Linked Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
# Create an unlinked account
@coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD"
)
assert_equal "1 crypto wallets synced, 1 need setup", @coinstats_item.sync_status_summary
end
end

View File

@@ -0,0 +1,124 @@
require "test_helper"
class Provider::CoinstatsAdapterTest < ActiveSupport::TestCase
include ProviderAdapterTestInterface
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Bank",
api_key: "test_api_key_123"
)
@coinstats_account = CoinstatsAccount.create!(
coinstats_item: @coinstats_item,
name: "CoinStats Crypto Account",
account_id: "cs_mock_1",
currency: "USD",
current_balance: 1000,
institution_metadata: {
"name" => "CoinStats Test Wallet",
"domain" => "coinstats.app",
"url" => "https://coinstats.app",
"logo" => "https://example.com/logo.png"
}
)
@account = accounts(:crypto)
@adapter = Provider::CoinstatsAdapter.new(@coinstats_account, account: @account)
end
def adapter
@adapter
end
# Run shared interface tests
test_provider_adapter_interface
test_syncable_interface
test_institution_metadata_interface
# Provider-specific tests
test "returns correct provider name" do
assert_equal "coinstats", @adapter.provider_name
end
test "returns correct provider type" do
assert_equal "CoinstatsAccount", @adapter.provider_type
end
test "returns coinstats item" do
assert_equal @coinstats_account.coinstats_item, @adapter.item
end
test "returns account" do
assert_equal @account, @adapter.account
end
test "can_delete_holdings? returns false" do
assert_equal false, @adapter.can_delete_holdings?
end
test "parses institution domain from institution_metadata" do
assert_equal "coinstats.app", @adapter.institution_domain
end
test "parses institution name from institution_metadata" do
assert_equal "CoinStats Test Wallet", @adapter.institution_name
end
test "parses institution url from institution_metadata" do
assert_equal "https://coinstats.app", @adapter.institution_url
end
test "returns logo_url from institution_metadata" do
assert_equal "https://example.com/logo.png", @adapter.logo_url
end
test "derives domain from url if domain is blank" do
@coinstats_account.update!(institution_metadata: {
"url" => "https://www.example.com/path"
})
adapter = Provider::CoinstatsAdapter.new(@coinstats_account, account: @account)
assert_equal "example.com", adapter.institution_domain
end
test "supported_account_types includes Crypto" do
assert_includes Provider::CoinstatsAdapter.supported_account_types, "Crypto"
end
test "connection_configs returns configurations when family can connect" do
@family.stubs(:can_connect_coinstats?).returns(true)
configs = Provider::CoinstatsAdapter.connection_configs(family: @family)
assert_equal 1, configs.length
assert_equal "coinstats", configs.first[:key]
assert_equal "CoinStats", configs.first[:name]
assert configs.first[:can_connect]
end
test "connection_configs returns empty when family cannot connect" do
@family.stubs(:can_connect_coinstats?).returns(false)
configs = Provider::CoinstatsAdapter.connection_configs(family: @family)
assert_equal [], configs
end
test "build_provider returns nil when family is nil" do
result = Provider::CoinstatsAdapter.build_provider(family: nil)
assert_nil result
end
test "build_provider returns nil when no coinstats_items with api_key" do
empty_family = families(:empty)
result = Provider::CoinstatsAdapter.build_provider(family: empty_family)
assert_nil result
end
test "build_provider returns Provider::Coinstats when credentials configured" do
result = Provider::CoinstatsAdapter.build_provider(family: @family)
assert_instance_of Provider::Coinstats, result
end
end

View File

@@ -0,0 +1,164 @@
require "test_helper"
class Provider::CoinstatsTest < ActiveSupport::TestCase
setup do
@provider = Provider::Coinstats.new("test_api_key")
end
test "extract_wallet_balance finds matching wallet by address and connectionId" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "ethereum",
balances: [
{ coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
]
},
{
blockchain: "bitcoin",
address: "bc1qxyz",
connectionId: "bitcoin",
balances: [
{ coinId: "bitcoin", name: "Bitcoin", amount: 0.5, price: 50000 }
]
}
]
result = @provider.extract_wallet_balance(bulk_data, "0x123abc", "ethereum")
assert_equal 1, result.size
assert_equal "ethereum", result.first[:coinId]
end
test "extract_wallet_balance handles case insensitive matching" do
bulk_data = [
{
blockchain: "Ethereum",
address: "0x123ABC",
connectionId: "Ethereum",
balances: [
{ coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
]
}
]
result = @provider.extract_wallet_balance(bulk_data, "0x123abc", "ethereum")
assert_equal 1, result.size
assert_equal "ethereum", result.first[:coinId]
end
test "extract_wallet_balance returns empty array when wallet not found" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "ethereum",
balances: [
{ coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
]
}
]
result = @provider.extract_wallet_balance(bulk_data, "0xnotfound", "ethereum")
assert_equal [], result
end
test "extract_wallet_balance returns empty array for nil bulk_data" do
result = @provider.extract_wallet_balance(nil, "0x123abc", "ethereum")
assert_equal [], result
end
test "extract_wallet_balance returns empty array for non-array bulk_data" do
result = @provider.extract_wallet_balance({ error: "invalid" }, "0x123abc", "ethereum")
assert_equal [], result
end
test "extract_wallet_balance matches by blockchain when connectionId differs" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "eth-mainnet", # Different connectionId
balances: [
{ coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
]
}
]
result = @provider.extract_wallet_balance(bulk_data, "0x123abc", "ethereum")
assert_equal 1, result.size
end
test "extract_wallet_transactions finds matching wallet transactions" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "ethereum",
transactions: [
{ hash: { id: "0xtx1" }, type: "Received", date: "2025-01-01T10:00:00.000Z" },
{ hash: { id: "0xtx2" }, type: "Sent", date: "2025-01-02T11:00:00.000Z" }
]
},
{
blockchain: "bitcoin",
address: "bc1qxyz",
connectionId: "bitcoin",
transactions: [
{ hash: { id: "btctx1" }, type: "Received", date: "2025-01-03T12:00:00.000Z" }
]
}
]
result = @provider.extract_wallet_transactions(bulk_data, "0x123abc", "ethereum")
assert_equal 2, result.size
assert_equal "0xtx1", result.first[:hash][:id]
end
test "extract_wallet_transactions returns empty array when wallet not found" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "ethereum",
transactions: [
{ hash: { id: "0xtx1" }, type: "Received" }
]
}
]
result = @provider.extract_wallet_transactions(bulk_data, "0xnotfound", "ethereum")
assert_equal [], result
end
test "extract_wallet_transactions returns empty array for nil bulk_data" do
result = @provider.extract_wallet_transactions(nil, "0x123abc", "ethereum")
assert_equal [], result
end
test "extract_wallet_transactions handles case insensitive matching" do
bulk_data = [
{
blockchain: "Ethereum",
address: "0x123ABC",
connectionId: "Ethereum",
transactions: [
{ hash: { id: "0xtx1" }, type: "Received" }
]
}
]
result = @provider.extract_wallet_transactions(bulk_data, "0x123abc", "ethereum")
assert_equal 1, result.size
end
end