mirror of
https://github.com/we-promise/sure.git
synced 2026-04-08 23:04:49 +00:00
* 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
139 lines
6.1 KiB
Ruby
139 lines
6.1 KiB
Ruby
# 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
|