mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 22:34: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>
375 lines
14 KiB
Ruby
375 lines
14 KiB
Ruby
# Processes a Coinbase account to update balance and import trades.
|
|
# Updates the linked Account balance and creates Holdings records.
|
|
class CoinbaseAccount::Processor
|
|
include CurrencyNormalizable
|
|
|
|
attr_reader :coinbase_account
|
|
|
|
# @param coinbase_account [CoinbaseAccount] Account to process
|
|
def initialize(coinbase_account)
|
|
@coinbase_account = coinbase_account
|
|
end
|
|
|
|
# Updates account balance and processes trades.
|
|
# Skips processing if no linked account exists.
|
|
def process
|
|
unless coinbase_account.current_account.present?
|
|
Rails.logger.info "CoinbaseAccount::Processor - No linked account for coinbase_account #{coinbase_account.id}, skipping processing"
|
|
return
|
|
end
|
|
|
|
Rails.logger.info "CoinbaseAccount::Processor - Processing coinbase_account #{coinbase_account.id}"
|
|
|
|
# Process holdings first to get the USD value
|
|
begin
|
|
process_holdings
|
|
rescue StandardError => e
|
|
Rails.logger.error "CoinbaseAccount::Processor - Failed to process holdings for #{coinbase_account.id}: #{e.message}"
|
|
report_exception(e, "holdings")
|
|
# Continue processing - balance update may still work
|
|
end
|
|
|
|
# Update account balance based on holdings value
|
|
begin
|
|
process_account!
|
|
rescue StandardError => e
|
|
Rails.logger.error "CoinbaseAccount::Processor - Failed to process account #{coinbase_account.id}: #{e.message}"
|
|
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
|
|
report_exception(e, "account")
|
|
raise
|
|
end
|
|
|
|
# Process buy/sell transactions as trades
|
|
process_trades
|
|
end
|
|
|
|
private
|
|
|
|
# Creates/updates Holdings record for this crypto wallet.
|
|
def process_holdings
|
|
HoldingsProcessor.new(coinbase_account).process
|
|
end
|
|
|
|
# Updates the linked Account with current balance from Coinbase.
|
|
# Balance is in the user's native currency (USD, EUR, GBP, etc.).
|
|
def process_account!
|
|
account = coinbase_account.current_account
|
|
|
|
# Calculate balance from holdings value or native_balance
|
|
native_value = calculate_native_balance
|
|
|
|
Rails.logger.info(
|
|
"CoinbaseAccount::Processor - Updating account #{account.id} balance: " \
|
|
"#{native_value} #{native_currency} (#{coinbase_account.current_balance} #{coinbase_account.currency})"
|
|
)
|
|
|
|
account.update!(
|
|
balance: native_value,
|
|
cash_balance: 0, # Crypto accounts have no cash, all value is in holdings
|
|
currency: native_currency
|
|
)
|
|
end
|
|
|
|
# Calculates the value of this Coinbase wallet in the user's native currency.
|
|
def calculate_native_balance
|
|
# Primary source: Coinbase's native_balance if available
|
|
native_amount = coinbase_account.raw_payload&.dig("native_balance", "amount")
|
|
return native_amount.to_d if native_amount.present?
|
|
|
|
# Try to calculate using spot price (always fetched in native currency pair)
|
|
crypto_code = coinbase_account.currency
|
|
quantity = (coinbase_account.current_balance || 0).to_d
|
|
return 0 if quantity.zero?
|
|
|
|
# Fetch spot price from Coinbase
|
|
provider = coinbase_account.coinbase_item&.coinbase_provider
|
|
if provider
|
|
# Coinbase spot price API returns price in the pair's quote currency
|
|
spot_data = provider.get_spot_price("#{crypto_code}-#{native_currency}")
|
|
if spot_data && spot_data["amount"].present?
|
|
price = spot_data["amount"].to_d
|
|
native_value = (quantity * price).round(2)
|
|
Rails.logger.info(
|
|
"CoinbaseAccount::Processor - Calculated #{native_currency} value for #{crypto_code}: " \
|
|
"#{quantity} * #{price} = #{native_value}"
|
|
)
|
|
return native_value
|
|
end
|
|
end
|
|
|
|
# Fallback: Sum holdings values for this account
|
|
account = coinbase_account.current_account
|
|
if account.present?
|
|
today_holdings = account.holdings.where(date: Date.current)
|
|
if today_holdings.any?
|
|
return today_holdings.sum(:amount)
|
|
end
|
|
end
|
|
|
|
# Last resort: Return 0 if we can't calculate value
|
|
Rails.logger.warn(
|
|
"CoinbaseAccount::Processor - Could not calculate #{native_currency} value for #{crypto_code}, returning 0"
|
|
)
|
|
0
|
|
end
|
|
|
|
# Get native currency from Coinbase (USD, EUR, GBP, etc.)
|
|
def native_currency
|
|
@native_currency ||= coinbase_account.raw_payload&.dig("native_balance", "currency") ||
|
|
coinbase_account.current_account&.currency ||
|
|
"USD"
|
|
end
|
|
|
|
# Processes transactions (buys, sells, sends, receives) as trades.
|
|
def process_trades
|
|
return unless coinbase_account.raw_transactions_payload.present?
|
|
|
|
# New format uses "transactions" array from /v2/accounts/{id}/transactions endpoint
|
|
transactions = coinbase_account.raw_transactions_payload["transactions"] || []
|
|
|
|
# Legacy format support (buys/sells arrays from deprecated endpoints)
|
|
buys = coinbase_account.raw_transactions_payload["buys"] || []
|
|
sells = coinbase_account.raw_transactions_payload["sells"] || []
|
|
|
|
Rails.logger.info(
|
|
"CoinbaseAccount::Processor - Processing #{transactions.count} transactions, " \
|
|
"#{buys.count} legacy buys, #{sells.count} legacy sells"
|
|
)
|
|
|
|
# Process new format transactions
|
|
transactions.each { |txn| process_transaction(txn) }
|
|
|
|
# Process legacy format (for backwards compatibility)
|
|
buys.each { |buy| process_legacy_buy(buy) }
|
|
sells.each { |sell| process_legacy_sell(sell) }
|
|
rescue StandardError => e
|
|
report_exception(e, "trades")
|
|
end
|
|
|
|
# Process a transaction from the /v2/accounts/{id}/transactions endpoint
|
|
def process_transaction(txn_data)
|
|
return unless txn_data["status"] == "completed"
|
|
|
|
account = coinbase_account.current_account
|
|
return unless account
|
|
|
|
txn_type = txn_data["type"]
|
|
return unless %w[buy sell].include?(txn_type)
|
|
|
|
# Get or create the security for this crypto
|
|
security = find_or_create_security(txn_data)
|
|
return unless security
|
|
|
|
# Extract data from transaction (use Time.zone.parse for timezone safety)
|
|
date = Time.zone.parse(txn_data["created_at"]).to_date
|
|
qty = txn_data.dig("amount", "amount").to_d.abs
|
|
native_amount = txn_data.dig("native_amount", "amount").to_d.abs
|
|
|
|
# Get subtotal from buy/sell details if available (more accurate)
|
|
if txn_type == "buy" && txn_data["buy"]
|
|
subtotal = txn_data.dig("buy", "subtotal", "amount").to_d
|
|
native_amount = subtotal if subtotal > 0
|
|
elsif txn_type == "sell" && txn_data["sell"]
|
|
subtotal = txn_data.dig("sell", "subtotal", "amount").to_d
|
|
native_amount = subtotal if subtotal > 0
|
|
end
|
|
|
|
# Calculate price per unit (after subtotal override for accuracy)
|
|
price = qty > 0 ? (native_amount / qty).round(8) : 0
|
|
|
|
# Build notes from available Coinbase metadata
|
|
notes_parts = []
|
|
notes_parts << txn_data["description"] if txn_data["description"].present?
|
|
notes_parts << txn_data.dig("details", "title") if txn_data.dig("details", "title").present?
|
|
notes_parts << txn_data.dig("details", "subtitle") if txn_data.dig("details", "subtitle").present?
|
|
# Add payment method info from buy/sell details
|
|
payment_method = txn_data.dig(txn_type, "payment_method_name")
|
|
notes_parts << I18n.t("coinbase.processor.paid_via", method: payment_method) if payment_method.present?
|
|
notes = notes_parts.join(" - ").presence
|
|
|
|
# Check if trade already exists by external_id
|
|
external_id = "coinbase_txn_#{txn_data['id']}"
|
|
existing = account.entries.find_by(external_id: external_id)
|
|
if existing.present?
|
|
# Update activity label if missing (fixes existing trades from before this was added)
|
|
if existing.entryable.is_a?(Trade) && existing.entryable.investment_activity_label.blank?
|
|
expected_label = txn_type == "buy" ? "Buy" : "Sell"
|
|
existing.entryable.update!(investment_activity_label: expected_label)
|
|
Rails.logger.info("CoinbaseAccount::Processor - Updated activity label to #{expected_label} for existing trade #{existing.id}")
|
|
end
|
|
return
|
|
end
|
|
|
|
# Get currency from native_amount or fall back to account's native currency
|
|
txn_currency = txn_data.dig("native_amount", "currency") || native_currency
|
|
|
|
# Create the trade
|
|
if txn_type == "buy"
|
|
# Buy: positive qty, money going out (negative amount)
|
|
account.entries.create!(
|
|
date: date,
|
|
name: "Buy #{qty.round(8)} #{security.ticker}",
|
|
amount: -native_amount,
|
|
currency: txn_currency,
|
|
external_id: external_id,
|
|
source: "coinbase",
|
|
notes: notes,
|
|
entryable: Trade.new(
|
|
security: security,
|
|
qty: qty,
|
|
price: price,
|
|
currency: txn_currency,
|
|
investment_activity_label: "Buy"
|
|
)
|
|
)
|
|
Rails.logger.info("CoinbaseAccount::Processor - Created buy trade: #{qty} #{security.ticker} @ #{price} #{txn_currency}")
|
|
else
|
|
# Sell: negative qty, money coming in (positive amount)
|
|
account.entries.create!(
|
|
date: date,
|
|
name: "Sell #{qty.round(8)} #{security.ticker}",
|
|
amount: native_amount,
|
|
currency: txn_currency,
|
|
external_id: external_id,
|
|
source: "coinbase",
|
|
notes: notes,
|
|
entryable: Trade.new(
|
|
security: security,
|
|
qty: -qty,
|
|
price: price,
|
|
currency: txn_currency,
|
|
investment_activity_label: "Sell"
|
|
)
|
|
)
|
|
Rails.logger.info("CoinbaseAccount::Processor - Created sell trade: #{qty} #{security.ticker} @ #{price} #{txn_currency}")
|
|
end
|
|
rescue => e
|
|
Rails.logger.error "CoinbaseAccount::Processor - Failed to process transaction #{txn_data['id']}: #{e.message}"
|
|
end
|
|
|
|
# Legacy format processor for buy transactions (deprecated endpoint)
|
|
def process_legacy_buy(buy_data)
|
|
return unless buy_data["status"] == "completed"
|
|
|
|
account = coinbase_account.current_account
|
|
return unless account
|
|
|
|
security = find_or_create_security(buy_data)
|
|
return unless security
|
|
|
|
date = Time.zone.parse(buy_data["created_at"]).to_date
|
|
qty = buy_data.dig("amount", "amount").to_d
|
|
price = buy_data.dig("unit_price", "amount").to_d
|
|
total = buy_data.dig("total", "amount").to_d
|
|
currency = buy_data.dig("total", "currency") || native_currency
|
|
|
|
external_id = "coinbase_buy_#{buy_data['id']}"
|
|
existing = account.entries.find_by(external_id: external_id)
|
|
if existing.present?
|
|
# Update activity label if missing
|
|
if existing.entryable.is_a?(Trade) && existing.entryable.investment_activity_label.blank?
|
|
existing.entryable.update!(investment_activity_label: "Buy")
|
|
end
|
|
return
|
|
end
|
|
|
|
account.entries.create!(
|
|
date: date,
|
|
name: "Buy #{security.ticker}",
|
|
amount: -total,
|
|
currency: currency,
|
|
external_id: external_id,
|
|
source: "coinbase",
|
|
entryable: Trade.new(
|
|
security: security,
|
|
qty: qty,
|
|
price: price,
|
|
currency: currency,
|
|
investment_activity_label: "Buy"
|
|
)
|
|
)
|
|
rescue => e
|
|
Rails.logger.error "CoinbaseAccount::Processor - Failed to process legacy buy: #{e.message}"
|
|
end
|
|
|
|
# Legacy format processor for sell transactions (deprecated endpoint)
|
|
def process_legacy_sell(sell_data)
|
|
return unless sell_data["status"] == "completed"
|
|
|
|
account = coinbase_account.current_account
|
|
return unless account
|
|
|
|
security = find_or_create_security(sell_data)
|
|
return unless security
|
|
|
|
date = Time.zone.parse(sell_data["created_at"]).to_date
|
|
qty = sell_data.dig("amount", "amount").to_d
|
|
price = sell_data.dig("unit_price", "amount").to_d
|
|
total = sell_data.dig("total", "amount").to_d
|
|
currency = sell_data.dig("total", "currency") || native_currency
|
|
|
|
external_id = "coinbase_sell_#{sell_data['id']}"
|
|
existing = account.entries.find_by(external_id: external_id)
|
|
if existing.present?
|
|
# Update activity label if missing
|
|
if existing.entryable.is_a?(Trade) && existing.entryable.investment_activity_label.blank?
|
|
existing.entryable.update!(investment_activity_label: "Sell")
|
|
end
|
|
return
|
|
end
|
|
|
|
account.entries.create!(
|
|
date: date,
|
|
name: "Sell #{security.ticker}",
|
|
amount: total,
|
|
currency: currency,
|
|
external_id: external_id,
|
|
source: "coinbase",
|
|
entryable: Trade.new(
|
|
security: security,
|
|
qty: -qty,
|
|
price: price,
|
|
currency: currency,
|
|
investment_activity_label: "Sell"
|
|
)
|
|
)
|
|
rescue => e
|
|
Rails.logger.error "CoinbaseAccount::Processor - Failed to process legacy sell: #{e.message}"
|
|
end
|
|
|
|
def find_or_create_security(transaction_data)
|
|
crypto_code = transaction_data.dig("amount", "currency")
|
|
return nil unless crypto_code.present?
|
|
|
|
# Use CRYPTO: prefix to distinguish from stock tickers
|
|
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.debug(
|
|
"CoinbaseAccount::Processor - Resolver failed for #{ticker}: #{e.message}; creating offline security"
|
|
)
|
|
# Fall back to creating an offline security
|
|
Security.find_or_create_by(ticker: ticker) do |security|
|
|
security.name = transaction_data.dig("amount", "currency") || crypto_code
|
|
security.exchange_operating_mic = "XCBS" # Coinbase exchange MIC
|
|
security.offline = true if security.respond_to?(:offline=)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Reports errors to Sentry with context tags.
|
|
# @param error [Exception] The error to report
|
|
# @param context [String] Processing context (e.g., "account", "trades")
|
|
def report_exception(error, context)
|
|
Sentry.capture_exception(error) do |scope|
|
|
scope.set_tags(
|
|
coinbase_account_id: coinbase_account.id,
|
|
context: context
|
|
)
|
|
end
|
|
end
|
|
end
|