mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
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:
@@ -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 = {}
|
||||
|
||||
169
app/controllers/coinstats_items_controller.rb
Normal file
169
app/controllers/coinstats_items_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
27
app/jobs/sync_hourly_job.rb
Normal file
27
app/jobs/sync_hourly_job.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
71
app/models/coinstats_account.rb
Normal file
71
app/models/coinstats_account.rb
Normal 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
|
||||
68
app/models/coinstats_account/processor.rb
Normal file
68
app/models/coinstats_account/processor.rb
Normal 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
|
||||
138
app/models/coinstats_account/transactions/processor.rb
Normal file
138
app/models/coinstats_account/transactions/processor.rb
Normal 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
|
||||
270
app/models/coinstats_entry/processor.rb
Normal file
270
app/models/coinstats_entry/processor.rb
Normal 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
|
||||
150
app/models/coinstats_item.rb
Normal file
150
app/models/coinstats_item.rb
Normal 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
|
||||
315
app/models/coinstats_item/importer.rb
Normal file
315
app/models/coinstats_item/importer.rb
Normal 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
|
||||
9
app/models/coinstats_item/provided.rb
Normal file
9
app/models/coinstats_item/provided.rb
Normal 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
|
||||
29
app/models/coinstats_item/sync_complete_event.rb
Normal file
29
app/models/coinstats_item/sync_complete_event.rb
Normal 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
|
||||
61
app/models/coinstats_item/syncer.rb
Normal file
61
app/models/coinstats_item/syncer.rb
Normal 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
|
||||
50
app/models/coinstats_item/unlinking.rb
Normal file
50
app/models/coinstats_item/unlinking.rb
Normal 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
|
||||
157
app/models/coinstats_item/wallet_linker.rb
Normal file
157
app/models/coinstats_item/wallet_linker.rb
Normal 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
|
||||
68
app/models/concerns/coinstats_transaction_identifiable.rb
Normal file
68
app/models/concerns/coinstats_transaction_identifiable.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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" ],
|
||||
|
||||
35
app/models/family/coinstats_connectable.rb
Normal file
35
app/models/family/coinstats_connectable.rb
Normal 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
|
||||
184
app/models/provider/coinstats.rb
Normal file
184
app/models/provider/coinstats.rb
Normal 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
|
||||
119
app/models/provider/coinstats_adapter.rb
Normal file
119
app/models/provider/coinstats_adapter.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
92
app/views/coinstats_items/_coinstats_item.html.erb
Normal file
92
app/views/coinstats_items/_coinstats_item.html.erb
Normal 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 %>
|
||||
77
app/views/coinstats_items/new.html.erb
Normal file
77
app/views/coinstats_items/new.html.erb
Normal 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 %>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
|
||||
54
app/views/settings/providers/_coinstats_panel.html.erb
Normal file
54
app/views/settings/providers/_coinstats_panel.html.erb
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
63
config/locales/views/coinstats_items/en.yml
Normal file
63
config/locales/views/coinstats_items/en.yml
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
43
db/schema.rb
generated
@@ -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
|
||||
|
||||
178
test/controllers/coinstats_items_controller_test.rb
Normal file
178
test/controllers/coinstats_items_controller_test.rb
Normal 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
|
||||
33
test/jobs/sync_hourly_job_test.rb
Normal file
33
test/jobs/sync_hourly_job_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
159
test/models/coinstats_account/processor_test.rb
Normal file
159
test/models/coinstats_account/processor_test.rb
Normal 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
|
||||
350
test/models/coinstats_account/transactions/processor_test.rb
Normal file
350
test/models/coinstats_account/transactions/processor_test.rb
Normal 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
|
||||
202
test/models/coinstats_account_test.rb
Normal file
202
test/models/coinstats_account_test.rb
Normal 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
|
||||
267
test/models/coinstats_entry/processor_test.rb
Normal file
267
test/models/coinstats_entry/processor_test.rb
Normal 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
|
||||
480
test/models/coinstats_item/importer_test.rb
Normal file
480
test/models/coinstats_item/importer_test.rb
Normal 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
|
||||
177
test/models/coinstats_item/syncer_test.rb
Normal file
177
test/models/coinstats_item/syncer_test.rb
Normal 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
|
||||
280
test/models/coinstats_item/wallet_linker_test.rb
Normal file
280
test/models/coinstats_item/wallet_linker_test.rb
Normal 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
|
||||
231
test/models/coinstats_item_test.rb
Normal file
231
test/models/coinstats_item_test.rb
Normal 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
|
||||
124
test/models/provider/coinstats_adapter_test.rb
Normal file
124
test/models/provider/coinstats_adapter_test.rb
Normal 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
|
||||
164
test/models/provider/coinstats_test.rb
Normal file
164
test/models/provider/coinstats_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user