# 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 EXCHANGE_TRADE_TYPES = %w[buy sell swap trade convert fill].freeze # @param coinstats_transaction [Hash] Raw transaction data from API # @param coinstats_account [CoinstatsAccount] Parent account for context def initialize(coinstats_transaction, coinstats_account:) @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 if exchange_trade? && trade_security.present? return legacy_transaction_entry if skip_legacy_transaction_migration? Account.transaction do remove_legacy_transaction_entry! import_adapter.import_trade( external_id: external_id, security: trade_security, quantity: trade_quantity, price: trade_price, amount: trade_amount, currency: currency, date: date, name: name, source: "coinstats", activity_label: trade_activity_label ) end else import_adapter.import_transaction( external_id: external_id, amount: amount, currency: currency, date: date, name: name, source: "coinstats", merchant: merchant, notes: notes, extra: extra_metadata, investment_activity_label: transaction_activity_label ) end rescue ArgumentError => e Rails.logger.error "CoinstatsEntry::Processor - Validation error for transaction #{external_id rescue 'unknown'}: #{e.message}" raise 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 if matched_item.present? cs["matched_item"] = { "count" => matched_item[:count], "total_worth" => matched_item[:totalWorth], "coin_id" => matched_item.dig(:coin, :id), "coin_symbol" => matched_item.dig(:coin, :symbol) }.compact end # Store profit/loss info if profit_loss.present? cs["profit"] = profit_loss[:profit] if profit_loss[:profit].present? 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? if fee_data[:totalWorth].present? cs["fee_value"] = fee_data[:totalWorth] cs["fee_usd"] = fee_data[:totalWorth] end 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] || data[:transactionType] 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 = matched_symbol || coin_data[:symbol] # Get coin name from nested transaction items if available (used as fallback) coin_name = transactions_data.dig(0, :items, 0, :coin, :name) if symbol.present? "#{tx_type} #{symbol}" elsif coin_name.present? "#{tx_type} #{coin_name}" else tx_type.to_s end end def amount if portfolio_exchange_account? absolute_amount = matched_item_total_worth.abs.nonzero? || coin_data[:currentValue]&.to_d&.abs&.nonzero? || profit_loss[:currentValue]&.to_d&.abs&.nonzero? || 0.to_d return portfolio_outflow? ? absolute_amount : -absolute_amount end if coinstats_account.exchange_source? && coinstats_account.fiat_asset? fiat_value = matched_item_total_worth.abs absolute_amount = fiat_value.positive? ? fiat_value : coin_data[:count].to_d.abs return outgoing_transaction_type? ? absolute_amount : -absolute_amount end raw_value = if coinstats_account.exchange_source? matched_item_total_worth.nonzero? || coin_data[:currentValue] || profit_loss[:currentValue] || 0 else coin_data[:currentValue] || profit_loss[:currentValue] || 0 end parsed_amount = case raw_value when String BigDecimal(raw_value) when Numeric BigDecimal(raw_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: #{data.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 account.currency || coinstats_account.currency || "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 = matched_symbol || coin_data[:symbol] count = trade_item_count.nonzero? || 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: #{formatted_currency_amount(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 def exchange_trade? return false unless coinstats_account.exchange_source? return false if coinstats_account.fiat_asset? return false if trade_quantity.zero? || trade_price.zero? EXCHANGE_TRADE_TYPES.include?(normalized_transaction_type) end def trade_security symbol = trade_item&.dig(:coin, :symbol) || matched_symbol || coinstats_account.asset_symbol return if symbol.blank? Security::Resolver.new(symbol.start_with?("CRYPTO:") ? symbol : "CRYPTO:#{symbol}").resolve end def trade_quantity trade_item_count.nonzero? || matched_item_count.nonzero? || coin_data[:count].to_d end def trade_price @trade_price ||= begin quantity = trade_quantity.abs return 0.to_d if quantity.zero? value = trade_item_total_worth.nonzero? || matched_item_total_worth.nonzero? || coin_data[:currentValue] || coin_data[:totalWorth] || profit_loss[:currentValue] || 0 BigDecimal(value.to_s).abs / quantity rescue ArgumentError 0.to_d end end def trade_amount trade_quantity * trade_price end def trade_activity_label normalized_transaction_type == "sell" || trade_quantity.negative? ? "Sell" : "Buy" end def transaction_activity_label case normalized_transaction_type when "buy" then "Buy" when "sell" then "Sell" when "swap", "trade", "convert" then "Other" when "received", "receive", "deposit", "transfer_in", "roll_in" then "Transfer" when "sent", "send", "withdraw", "transfer_out", "roll_out" then "Transfer" when "reward", "interest" then "Interest" when "dividend" then "Dividend" when "fee" then "Fee" else "Other" end end def normalized_transaction_type @normalized_transaction_type ||= transaction_type.to_s.downcase.parameterize(separator: "_") end def remove_legacy_transaction_entry! legacy_transaction_entry&.destroy! end def legacy_transaction_entry @legacy_transaction_entry ||= account.entries.find_by( external_id: external_id, source: "coinstats", entryable_type: "Transaction" ) end def skip_legacy_transaction_migration? return false unless legacy_transaction_entry.present? skip_reason = import_adapter.send(:determine_skip_reason, legacy_transaction_entry) return false if skip_reason.blank? import_adapter.send(:record_skip, legacy_transaction_entry, skip_reason) true end def matched_symbol matched_item&.dig(:coin, :symbol) end def matched_item @matched_item ||= begin return primary_portfolio_item if portfolio_exchange_account? items = transaction_items account_id = coinstats_account.account_id.to_s.downcase account_symbol = coinstats_account.asset_symbol.to_s.downcase items.find do |item| coin = item[:coin].to_h.with_indifferent_access coin[:id]&.to_s&.downcase == account_id || coin[:identifier]&.to_s&.downcase == account_id || coin[:symbol]&.to_s&.downcase == account_symbol end end end def trade_item @trade_item ||= portfolio_exchange_account? ? portfolio_trade_item : matched_item end def trade_item_count trade_item&.[](:count).to_d end def trade_item_total_worth trade_item&.[](:totalWorth).to_d end def matched_item_count matched_item&.[](:count).to_d end def matched_item_total_worth matched_item&.[](:totalWorth).to_d end def portfolio_exchange_account? coinstats_account.exchange_portfolio_account? end def portfolio_trade_item crypto_items = transaction_items.reject { |item| portfolio_fiat_item?(item) || item[:count].to_d.zero? } crypto_items.find { |item| item[:count].to_d.negative? } || crypto_items.find { |item| item[:count].to_d.positive? } || crypto_items.first end def primary_portfolio_item portfolio_trade_item || transaction_items.find { |item| item[:count].to_d.nonzero? } || transaction_items.first end def portfolio_fiat_item?(item) coinstats_account.fiat_asset?(item[:coin] || item) end def transaction_items @transaction_items ||= begin Array(transactions_data).flat_map do |entry| Array(entry.with_indifferent_access[:items]).map(&:with_indifferent_access) end + Array(data[:transfers]).flat_map do |entry| Array(entry.with_indifferent_access[:items]).map(&:with_indifferent_access) end end end def portfolio_outflow? outgoing_transaction_type? || trade_item_count.negative? || matched_item_count.negative? || coin_data[:count].to_d.negative? end def formatted_currency_amount(amount) return "$#{amount}" if currency == "USD" "#{amount} #{currency}" end end