mirror of
https://github.com/we-promise/sure.git
synced 2026-04-09 07:14:47 +00:00
* **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>
167 lines
5.6 KiB
Ruby
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
|