Files
sure/app/models/coinbase_account/holdings_processor.rb
LPW dd991fa339 Add Coinbase exchange integration with CDP API support (#704)
* **Add Coinbase integration with item and account management**
- Creates migrations for `coinbase_items` and `coinbase_accounts`.
- Adds models, controllers, views, and background tasks to support account linking, syncing, and transaction handling.
- Implements Coinbase API client and adapter for seamless integration.
- Supports ActiveRecord encryption for secure credential storage.
- Adds UI components for provider setup, account management, and synchronization.

* Localize Coinbase-related UI strings, refine account linking for security, and add timeouts to Coinbase API requests.

* Localize Coinbase account handling to support native currencies (USD, EUR, GBP, etc.) across balances, trades, holdings, and transactions.

* Improve Coinbase processing with timezone-safe parsing, native currency support, and immediate holdings updates.

* Improve trend percentage formatting and enhance race condition handling for Coinbase account linking.

* Fix log message wording for orphan cleanup

* Ensure `selected_accounts` parameter is sanitized by rejecting blank entries.

* Add tests for Coinbase integration: account, item, and controller coverage

- Adds unit tests for `CoinbaseAccount` and `CoinbaseItem` models.
- Adds integration tests for `CoinbaseItemsController`.
- Introduces Stimulus `select-all` controller for UI checkbox handling.
- Localizes UI strings and logging for Coinbase integration.

* Update test fixtures to use consistent placeholder API keys and secrets

* Refine `coinbase_item` tests to ensure deterministic ordering and improve scope assertions.

* Integrate `SyncStats::Collector` into Coinbase syncer to streamline statistics collection and enhance consistency.

* Localize Coinbase sync status messages and improve sync summary test coverage.

* Update `CoinbaseItem` encryption: use deterministic encryption for `api_key` and standard for `api_secret`.

* fix schema drift

* Beta labels to lower expectations

---------

Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-01-21 22:56:39 +01:00

167 lines
5.6 KiB
Ruby

# Processes Coinbase account data to create/update Holdings records.
# Each Coinbase wallet is a single holding of one cryptocurrency.
class CoinbaseAccount::HoldingsProcessor
def initialize(coinbase_account)
@coinbase_account = coinbase_account
end
def process
Rails.logger.info(
"CoinbaseAccount::HoldingsProcessor - Processing coinbase_account #{coinbase_account.id}: " \
"account=#{account&.id || 'nil'} accountable_type=#{account&.accountable_type || 'nil'} " \
"quantity=#{quantity} crypto=#{crypto_code}"
)
unless account&.accountable_type == "Crypto"
Rails.logger.info("CoinbaseAccount::HoldingsProcessor - Skipping: not a Crypto account")
return
end
if quantity.zero?
Rails.logger.info("CoinbaseAccount::HoldingsProcessor - Skipping: quantity is zero")
return
end
# Resolve the security for this cryptocurrency
security = resolve_security
unless security
Rails.logger.warn("CoinbaseAccount::HoldingsProcessor - Skipping: could not resolve security for #{crypto_code}")
return
end
# Get price from market data or calculate from native_balance if available
current_price = fetch_current_price || 0
amount = calculate_amount(current_price)
Rails.logger.info(
"CoinbaseAccount::HoldingsProcessor - Importing holding for #{coinbase_account.id}: " \
"#{quantity} #{crypto_code} @ #{current_price} = #{amount} #{native_currency}"
)
# Import the holding using the adapter
# Use native currency from Coinbase (USD, EUR, GBP, etc.)
holding = import_adapter.import_holding(
security: security,
quantity: quantity,
amount: amount,
currency: native_currency,
date: Date.current,
price: current_price,
cost_basis: nil, # Coinbase doesn't provide cost basis in basic API
external_id: "coinbase_#{coinbase_account.account_id}_#{Date.current}",
account_provider_id: coinbase_account.account_provider&.id,
source: "coinbase",
delete_future_holdings: false
)
Rails.logger.info(
"CoinbaseAccount::HoldingsProcessor - Saved holding id=#{holding.id} " \
"security=#{holding.security_id} qty=#{holding.qty}"
)
holding
rescue => e
Rails.logger.error("CoinbaseAccount::HoldingsProcessor - Error: #{e.message}")
Rails.logger.error(e.backtrace.first(5).join("\n"))
nil
end
private
attr_reader :coinbase_account
def import_adapter
@import_adapter ||= Account::ProviderImportAdapter.new(account)
end
def account
coinbase_account.current_account
end
def quantity
@quantity ||= (coinbase_account.current_balance || 0).to_d
end
def crypto_code
@crypto_code ||= coinbase_account.currency.to_s.upcase
end
def native_currency
# Get native currency from Coinbase (USD, EUR, GBP, etc.) or fall back to account currency
@native_currency ||= coinbase_account.raw_payload&.dig("native_balance", "currency") ||
account&.currency ||
"USD"
end
def resolve_security
# Use CRYPTO: prefix to distinguish from stock tickers
# This matches SimpleFIN's handling of crypto assets
ticker = crypto_code.include?(":") ? crypto_code : "CRYPTO:#{crypto_code}"
# Try to resolve via Security::Resolver first
begin
Security::Resolver.new(ticker).resolve
rescue => e
Rails.logger.warn(
"CoinbaseAccount::HoldingsProcessor - Resolver failed for #{ticker}: " \
"#{e.class} - #{e.message}; creating offline security"
)
# Fall back to creating an offline security
Security.find_or_initialize_by(ticker: ticker).tap do |sec|
sec.offline = true if sec.respond_to?(:offline=) && sec.offline != true
sec.name = crypto_name if sec.name.blank?
sec.exchange_operating_mic = "XCBS" # Coinbase exchange MIC
sec.save! if sec.changed?
end
end
end
def crypto_name
# Try to get the full name from institution_metadata
coinbase_account.institution_metadata&.dig("crypto_name") ||
coinbase_account.raw_payload&.dig("currency", "name") ||
crypto_code
end
def fetch_current_price
# Try to get price from Coinbase's native_balance (USD equivalent) if available
native_amount = coinbase_account.raw_payload&.dig("native_balance", "amount")
if native_amount.present? && quantity > 0
return (native_amount.to_d / quantity).round(8)
end
# Fetch spot price from Coinbase API in native currency
provider = coinbase_provider
if provider
spot_data = provider.get_spot_price("#{crypto_code}-#{native_currency}")
if spot_data && spot_data["amount"].present?
price = spot_data["amount"].to_d
Rails.logger.info(
"CoinbaseAccount::HoldingsProcessor - Fetched spot price for #{crypto_code}: #{price} #{native_currency}"
)
return price
end
end
# Fall back to Security's latest price if available
if (security = resolve_security)
latest_price = security.prices.order(date: :desc).first
return latest_price.price if latest_price.present?
end
# If no price available, return nil
Rails.logger.warn("CoinbaseAccount::HoldingsProcessor - No price available for #{crypto_code}")
nil
end
def coinbase_provider
coinbase_account.coinbase_item&.coinbase_provider
end
def calculate_amount(price)
return 0 unless price && price > 0
(quantity * price).round(2)
end
end