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>
This commit is contained in:
LPW
2026-01-21 16:56:39 -05:00
committed by GitHub
parent 0357cd7d44
commit dd991fa339
51 changed files with 3054 additions and 29 deletions

View File

@@ -53,6 +53,7 @@ gem "image_processing", ">= 1.2"
gem "ostruct"
gem "bcrypt", "~> 3.1"
gem "jwt"
gem "ed25519" # For Coinbase CDP API authentication
gem "jbuilder"
gem "countries"

View File

@@ -180,6 +180,7 @@ GEM
dotenv (= 3.1.8)
railties (>= 6.1)
drb (2.2.3)
ed25519 (1.4.0)
email_validator (2.2.4)
activemodel
erb (5.0.1)
@@ -778,6 +779,7 @@ DEPENDENCIES
derailed_benchmarks
doorkeeper
dotenv-rails
ed25519
erb_lint
faker
faraday

View File

@@ -38,7 +38,7 @@ class UI::AccountPage < ApplicationComponent
def tabs
case account.accountable_type
when "Investment"
when "Investment", "Crypto"
[ :activity, :holdings ]
when "Property", "Vehicle", "Loan"
[ :activity, :overview ]

View File

@@ -11,6 +11,7 @@ class AccountsController < ApplicationController
@lunchflow_items = family.lunchflow_items.ordered.includes(:syncs, :lunchflow_accounts)
@enable_banking_items = family.enable_banking_items.ordered.includes(:syncs)
@coinstats_items = family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs)
@coinbase_items = family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs)
# Build sync stats maps for all providers
build_sync_stats_maps
@@ -151,7 +152,9 @@ class AccountsController < ApplicationController
)
# Build available providers list with paths resolved for this specific account
@available_providers = provider_configs.map do |config|
# Filter out providers that don't support linking to existing accounts
@available_providers = provider_configs.filter_map do |config|
next unless config[:existing_account_path].present?
{
name: config[:name],
key: config[:key],
@@ -238,5 +241,20 @@ class AccountsController < ApplicationController
latest_sync = item.syncs.ordered.first
@coinstats_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end
# Coinbase sync stats
@coinbase_sync_stats_map = {}
@coinbase_unlinked_count_map = {}
@coinbase_items.each do |item|
latest_sync = item.syncs.ordered.first
@coinbase_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
# Count unlinked accounts
count = item.coinbase_accounts
.left_joins(:account_provider)
.where(account_providers: { id: nil })
.count
@coinbase_unlinked_count_map[item.id] = count
end
end
end

View File

@@ -0,0 +1,324 @@
class CoinbaseItemsController < ApplicationController
before_action :set_coinbase_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
def index
@coinbase_items = Current.family.coinbase_items.ordered
end
def show
end
def new
@coinbase_item = Current.family.coinbase_items.build
end
def edit
end
def create
@coinbase_item = Current.family.coinbase_items.build(coinbase_item_params)
@coinbase_item.name ||= t(".default_name")
if @coinbase_item.save
# Set default institution metadata
@coinbase_item.set_coinbase_institution_defaults!
# Trigger initial sync to fetch accounts
@coinbase_item.sync_later
if turbo_frame_request?
flash.now[:notice] = t(".success")
@coinbase_items = Current.family.coinbase_items.ordered
render turbo_stream: [
turbo_stream.replace(
"coinbase-providers-panel",
partial: "settings/providers/coinbase_panel",
locals: { coinbase_items: @coinbase_items }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
else
@error_message = @coinbase_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"coinbase-providers-panel",
partial: "settings/providers/coinbase_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity
end
end
end
def update
if @coinbase_item.update(coinbase_item_params)
if turbo_frame_request?
flash.now[:notice] = t(".success")
@coinbase_items = Current.family.coinbase_items.ordered
render turbo_stream: [
turbo_stream.replace(
"coinbase-providers-panel",
partial: "settings/providers/coinbase_panel",
locals: { coinbase_items: @coinbase_items }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
else
@error_message = @coinbase_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"coinbase-providers-panel",
partial: "settings/providers/coinbase_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :unprocessable_entity
end
end
end
def destroy
@coinbase_item.destroy_later
redirect_to settings_providers_path, notice: t(".success")
end
def sync
unless @coinbase_item.syncing?
@coinbase_item.sync_later
end
respond_to do |format|
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end
# Legacy provider linking flow (not used - Coinbase uses setup_accounts flow instead)
# These exist for route compatibility but redirect to the providers page.
def preload_accounts
redirect_to settings_providers_path
end
def select_accounts
redirect_to settings_providers_path
end
def link_accounts
redirect_to settings_providers_path
end
def select_existing_account
@account = Current.family.accounts.find(params[:account_id])
# List all available Coinbase accounts for the family that can be linked
@available_coinbase_accounts = Current.family.coinbase_items
.includes(coinbase_accounts: [ :account, { account_provider: :account } ])
.flat_map(&:coinbase_accounts)
# Show accounts that are still linkable:
# - Already linked via AccountProvider (can be relinked to different account)
# - Or fully unlinked (no account_provider)
.select { |ca| ca.account.present? || ca.account_provider.nil? }
.sort_by { |ca| ca.updated_at || ca.created_at }
.reverse
render :select_existing_account, layout: false
end
def link_existing_account
@account = Current.family.accounts.find(params[:account_id])
# Scope lookup to family's coinbase accounts for security
coinbase_account = Current.family.coinbase_items
.joins(:coinbase_accounts)
.where(coinbase_accounts: { id: params[:coinbase_account_id] })
.first&.coinbase_accounts&.find_by(id: params[:coinbase_account_id])
unless coinbase_account
flash[:alert] = t(".errors.invalid_coinbase_account")
if turbo_frame_request?
render turbo_stream: Array(flash_notification_stream_items)
else
redirect_to account_path(@account), alert: flash[:alert]
end
return
end
# Guard: only manual accounts can be linked (no existing provider links)
if @account.account_providers.any? || @account.plaid_account_id.present? || @account.simplefin_account_id.present?
flash[:alert] = t(".errors.only_manual")
if turbo_frame_request?
return render turbo_stream: Array(flash_notification_stream_items)
else
return redirect_to account_path(@account), alert: flash[:alert]
end
end
# Relink behavior: detach any existing link and point provider link at the chosen account
Account.transaction do
coinbase_account.lock!
# Upsert the AccountProvider mapping
ap = AccountProvider.find_or_initialize_by(provider: coinbase_account)
previous_account = ap.account
ap.account_id = @account.id
ap.save!
# If the provider was previously linked to a different account in this family,
# and that account is now orphaned, queue it for deletion
if previous_account && previous_account.id != @account.id && previous_account.family_id == @account.family_id
begin
previous_account.reload
if previous_account.account_providers.none?
previous_account.destroy_later if previous_account.may_mark_for_deletion?
end
rescue => e
Rails.logger.warn("Failed orphan cleanup for account ##{previous_account&.id}: #{e.class} - #{e.message}")
end
end
end
if turbo_frame_request?
coinbase_account.reload
item = coinbase_account.coinbase_item
item.reload
@manual_accounts = Account.uncached {
Current.family.accounts
.visible_manual
.order(:name)
.to_a
}
@coinbase_items = Current.family.coinbase_items.ordered.includes(:syncs)
flash[:notice] = t(".success")
@account.reload
manual_accounts_stream = if @manual_accounts.any?
turbo_stream.update(
"manual-accounts",
partial: "accounts/index/manual_accounts",
locals: { accounts: @manual_accounts }
)
else
turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts"))
end
render turbo_stream: [
turbo_stream.replace(
ActionView::RecordIdentifier.dom_id(item),
partial: "coinbase_items/coinbase_item",
locals: { coinbase_item: item }
),
manual_accounts_stream,
*Array(flash_notification_stream_items)
]
else
redirect_to accounts_path, notice: t(".success")
end
end
def setup_accounts
# Only show unlinked accounts
@coinbase_accounts = @coinbase_item.coinbase_accounts
.left_joins(:account_provider)
.where(account_providers: { id: nil })
.order(:name)
end
def complete_account_setup
selected_accounts = Array(params[:selected_accounts]).reject(&:blank?)
created_accounts = []
selected_accounts.each do |coinbase_account_id|
# Find account - scoped to this item to prevent cross-item manipulation
coinbase_account = @coinbase_item.coinbase_accounts.find_by(id: coinbase_account_id)
unless coinbase_account
Rails.logger.warn("Coinbase account #{coinbase_account_id} not found for item #{@coinbase_item.id}")
next
end
# Lock row to prevent concurrent account creation (race condition protection)
coinbase_account.with_lock do
# Re-check after acquiring lock - another request may have created the account
if coinbase_account.account.present?
Rails.logger.info("Coinbase account #{coinbase_account_id} already linked, skipping")
next
end
# Create account as Crypto exchange (all Coinbase accounts are crypto)
account = Account.create_from_coinbase_account(coinbase_account)
coinbase_account.ensure_account_provider!(account)
created_accounts << account
end
# Reload to pick up the new account_provider association (outside lock)
coinbase_account.reload
# Process holdings immediately so user sees them right away
# (sync_later is async and would delay holdings visibility)
begin
CoinbaseAccount::HoldingsProcessor.new(coinbase_account).process
rescue => e
Rails.logger.error("Failed to process holdings for #{coinbase_account.id}: #{e.message}")
end
end
# Only clear pending if ALL accounts are now linked
unlinked_remaining = @coinbase_item.coinbase_accounts
.left_joins(:account_provider)
.where(account_providers: { id: nil })
.count
@coinbase_item.update!(pending_account_setup: unlinked_remaining > 0)
# Set appropriate flash message
if created_accounts.any?
flash[:notice] = t(".success", count: created_accounts.count)
elsif selected_accounts.empty?
flash[:notice] = t(".none_selected")
else
flash[:notice] = t(".no_accounts")
end
# Trigger a sync to process the newly linked accounts
@coinbase_item.sync_later if created_accounts.any?
if turbo_frame_request?
@coinbase_items = Current.family.coinbase_items.ordered.includes(:syncs)
render turbo_stream: [
turbo_stream.replace(
ActionView::RecordIdentifier.dom_id(@coinbase_item),
partial: "coinbase_items/coinbase_item",
locals: { coinbase_item: @coinbase_item }
)
] + Array(flash_notification_stream_items)
else
redirect_to accounts_path, status: :see_other
end
end
private
def set_coinbase_item
@coinbase_item = Current.family.coinbase_items.find(params[:id])
end
def coinbase_item_params
params.require(:coinbase_item).permit(
:name,
:sync_start_date,
:api_key,
:api_secret
)
end
end

View File

@@ -125,7 +125,8 @@ class Settings::ProvidersController < ApplicationController
@provider_configurations = Provider::ConfigurationRegistry.all.reject do |config|
config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \
config.provider_key.to_s.casecmp("enable_banking").zero? || \
config.provider_key.to_s.casecmp("coinstats").zero?
config.provider_key.to_s.casecmp("coinstats").zero? || \
config.provider_key.to_s.casecmp("coinbase").zero?
end
# Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials
@@ -133,5 +134,6 @@ class Settings::ProvidersController < ApplicationController
@lunchflow_items = Current.family.lunchflow_items.where.not(api_key: [ nil, "" ]).ordered.select(:id)
@enable_banking_items = Current.family.enable_banking_items.ordered # Enable Banking panel needs session info for status display
@coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display
@coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display
end
end

View File

@@ -0,0 +1,14 @@
import { Controller } from "@hotwired/stimulus"
// Simple "select all" checkbox controller
// Connect to a container, specify which checkboxes to control via target
export default class extends Controller {
static targets = ["checkbox", "selectAll"]
toggle(event) {
const checked = event.target.checked
this.checkboxTargets.forEach((checkbox) => {
checkbox.checked = checked
})
}
}

View File

@@ -76,7 +76,10 @@ class Account < ApplicationRecord
class << self
def create_and_sync(attributes, skip_initial_sync: false)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
account = new(attributes.merge(cash_balance: attributes[:balance]))
# Default cash_balance to balance unless explicitly provided (e.g., Crypto sets it to 0)
attrs = attributes.dup
attrs[:cash_balance] = attrs[:balance] unless attrs.key?(:cash_balance)
account = new(attrs)
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d
transaction do
@@ -175,6 +178,31 @@ class Account < ApplicationRecord
)
end
def create_from_coinbase_account(coinbase_account)
# All Coinbase accounts are crypto exchange accounts
family = coinbase_account.coinbase_item.family
# Extract native balance and currency from Coinbase (e.g., USD, EUR, GBP)
native_balance = coinbase_account.raw_payload&.dig("native_balance", "amount").to_d
native_currency = coinbase_account.raw_payload&.dig("native_balance", "currency") || family.currency
attributes = {
family: family,
name: coinbase_account.name,
balance: native_balance,
cash_balance: 0, # No cash - all value is in holdings
currency: native_currency,
accountable_type: "Crypto",
accountable_attributes: {
subtype: "exchange",
tax_treatment: "taxable"
}
}
# Skip initial sync - provider sync will handle balance/holdings creation
create_and_sync(attributes, skip_initial_sync: true)
end
private
@@ -266,6 +294,14 @@ class Account < ApplicationRecord
accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name
end
# Determines if this account supports manual trade entry
# Investment accounts always support trades; Crypto only if subtype is "exchange"
def supports_trades?
return true if investment?
return accountable.supports_trades? if crypto? && accountable.respond_to?(:supports_trades?)
false
end
# The balance type determines which "component" of balance is being tracked.
# This is primarily used for balance related calculations and updates.
#

View File

@@ -0,0 +1,67 @@
class CoinbaseAccount < ApplicationRecord
include CurrencyNormalizable
belongs_to :coinbase_item
# New association through account_providers
has_one :account_provider, as: :provider, dependent: :destroy
has_one :account, through: :account_provider, source: :account
has_one :linked_account, through: :account_provider, source: :account
validates :name, :currency, presence: true
# Helper to get account using account_providers system
def current_account
account
end
# Create or update the AccountProvider link for this coinbase_account
def ensure_account_provider!(linked_account = nil)
acct = linked_account || current_account
return nil unless acct
AccountProvider
.find_or_initialize_by(provider_type: "CoinbaseAccount", provider_id: id)
.tap do |provider|
provider.account = acct
provider.save!
end
rescue => e
Rails.logger.warn("Coinbase provider link ensure failed for #{id}: #{e.class} - #{e.message}")
nil
end
def upsert_coinbase_snapshot!(account_snapshot)
# Convert to symbol keys or handle both string and symbol keys
snapshot = account_snapshot.with_indifferent_access
# Map Coinbase field names to our field names
update!(
current_balance: snapshot[:balance] || snapshot[:current_balance],
currency: parse_currency(snapshot[:currency]) || "USD",
name: snapshot[:name],
account_id: snapshot[:id]&.to_s,
account_status: snapshot[:status],
provider: snapshot[:provider],
institution_metadata: {
name: snapshot[:institution_name],
logo: snapshot[:institution_logo]
}.compact,
raw_payload: account_snapshot
)
end
def upsert_coinbase_transactions_snapshot!(transactions_snapshot)
assign_attributes(
raw_transactions_payload: transactions_snapshot
)
save!
end
private
def log_invalid_currency(currency_value)
Rails.logger.warn("Invalid currency code '#{currency_value}' for Coinbase account #{id}, defaulting to USD")
end
end

View File

@@ -0,0 +1,166 @@
# 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

View File

@@ -0,0 +1,374 @@
# 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

184
app/models/coinbase_item.rb Normal file
View File

@@ -0,0 +1,184 @@
class CoinbaseItem < ApplicationRecord
include Syncable, Provided, Unlinking
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
# Helper to detect if ActiveRecord Encryption is configured for this app
def self.encryption_ready?
creds_ready = Rails.application.credentials.active_record_encryption.present?
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
creds_ready || env_ready
end
# Encrypt sensitive credentials if ActiveRecord encryption is configured
# api_key uses deterministic encryption for querying, api_secret uses standard encryption
if encryption_ready?
encrypts :api_key, deterministic: true
encrypts :api_secret
end
validates :name, presence: true
validates :api_key, presence: true
validates :api_secret, presence: true
belongs_to :family
has_one_attached :logo
has_many :coinbase_accounts, dependent: :destroy
has_many :accounts, through: :coinbase_accounts
scope :active, -> { where(scheduled_for_deletion: false) }
scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) }
def destroy_later
update!(scheduled_for_deletion: true)
DestroyJob.perform_later(self)
end
def import_latest_coinbase_data
provider = coinbase_provider
unless provider
Rails.logger.error "CoinbaseItem #{id} - Cannot import: credentials not configured"
raise StandardError.new("Coinbase credentials not configured")
end
CoinbaseItem::Importer.new(self, coinbase_provider: provider).import
rescue => e
Rails.logger.error "CoinbaseItem #{id} - Failed to import data: #{e.message}"
raise
end
def process_accounts
Rails.logger.info "CoinbaseItem #{id} - process_accounts: total coinbase_accounts=#{coinbase_accounts.count}"
return [] if coinbase_accounts.empty?
# Debug: log all coinbase accounts and their linked status
coinbase_accounts.each do |ca|
Rails.logger.info(
"CoinbaseItem #{id} - coinbase_account #{ca.id}: " \
"name='#{ca.name}' balance=#{ca.current_balance} " \
"account_provider=#{ca.account_provider&.id || 'nil'} " \
"account=#{ca.account&.id || 'nil'}"
)
end
linked = coinbase_accounts.joins(:account).merge(Account.visible)
Rails.logger.info "CoinbaseItem #{id} - found #{linked.count} linked visible accounts to process"
results = []
linked.each do |coinbase_account|
begin
Rails.logger.info "CoinbaseItem #{id} - processing coinbase_account #{coinbase_account.id}"
result = CoinbaseAccount::Processor.new(coinbase_account).process
results << { coinbase_account_id: coinbase_account.id, success: true, result: result }
rescue => e
Rails.logger.error "CoinbaseItem #{id} - Failed to process account #{coinbase_account.id}: #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n")
results << { coinbase_account_id: coinbase_account.id, success: false, error: e.message }
end
end
results
end
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
return [] if accounts.empty?
results = []
accounts.visible.each do |account|
begin
account.sync_later(
parent_sync: parent_sync,
window_start_date: window_start_date,
window_end_date: window_end_date
)
results << { account_id: account.id, success: true }
rescue => e
Rails.logger.error "CoinbaseItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}"
results << { account_id: account.id, success: false, error: e.message }
end
end
results
end
def upsert_coinbase_snapshot!(accounts_snapshot)
assign_attributes(
raw_payload: accounts_snapshot
)
save!
end
def has_completed_initial_setup?
# Setup is complete if we have any linked accounts
accounts.any?
end
def sync_status_summary
total_accounts = total_accounts_count
linked_count = linked_accounts_count
unlinked_count = unlinked_accounts_count
if total_accounts == 0
I18n.t("coinbase_items.coinbase_item.sync_status.no_accounts")
elsif unlinked_count == 0
I18n.t("coinbase_items.coinbase_item.sync_status.all_synced", count: linked_count)
else
I18n.t("coinbase_items.coinbase_item.sync_status.partial_sync", linked_count: linked_count, unlinked_count: unlinked_count)
end
end
def linked_accounts_count
coinbase_accounts.joins(:account_provider).count
end
def unlinked_accounts_count
coinbase_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count
end
def total_accounts_count
coinbase_accounts.count
end
def institution_display_name
institution_name.presence || institution_domain.presence || name
end
def connected_institutions
coinbase_accounts.includes(:account)
.where.not(institution_metadata: nil)
.map { |acc| acc.institution_metadata }
.uniq { |inst| inst["name"] || inst["institution_name"] }
end
def institution_summary
institutions = connected_institutions
case institutions.count
when 0
"No institutions connected"
when 1
institutions.first["name"] || institutions.first["institution_name"] || "1 institution"
else
"#{institutions.count} institutions"
end
end
def credentials_configured?
api_key.present? && api_secret.present?
end
# Default institution metadata for Coinbase
def set_coinbase_institution_defaults!
update!(
institution_name: "Coinbase",
institution_domain: "coinbase.com",
institution_url: "https://www.coinbase.com",
institution_color: "#0052FF"
)
end
end

View File

@@ -0,0 +1,109 @@
# Imports wallet/account data from Coinbase API.
# Fetches accounts, balances, and transaction history.
class CoinbaseItem::Importer
attr_reader :coinbase_item, :coinbase_provider
# @param coinbase_item [CoinbaseItem] Item containing accounts to import
# @param coinbase_provider [Provider::Coinbase] API client instance
def initialize(coinbase_item, coinbase_provider:)
@coinbase_item = coinbase_item
@coinbase_provider = coinbase_provider
end
# Imports accounts and transaction data from Coinbase.
# Creates or updates coinbase_accounts for each Coinbase wallet.
# @return [Hash] Result with :success, :accounts_imported
def import
Rails.logger.info "CoinbaseItem::Importer - Starting import for item #{coinbase_item.id}"
# Fetch all accounts (wallets) from Coinbase
accounts_data = coinbase_provider.get_accounts
if accounts_data.blank?
Rails.logger.info "CoinbaseItem::Importer - No accounts found for item #{coinbase_item.id}"
return { success: true, accounts_imported: 0 }
end
# Store raw payload for debugging
coinbase_item.upsert_coinbase_snapshot!(accounts_data)
accounts_imported = 0
accounts_failed = 0
accounts_data.each do |account_data|
import_account(account_data)
accounts_imported += 1
rescue => e
accounts_failed += 1
Rails.logger.error "CoinbaseItem::Importer - Failed to import account: #{e.message}"
end
Rails.logger.info "CoinbaseItem::Importer - Imported #{accounts_imported} accounts (#{accounts_failed} failed)"
{
success: accounts_failed == 0,
accounts_imported: accounts_imported,
accounts_failed: accounts_failed
}
end
private
def import_account(account_data)
# Skip accounts with zero balance unless they have transaction history
balance = account_data.dig("balance", "amount").to_d
return if balance.zero? && account_data.dig("balance", "currency") != "USD"
# Find or create the coinbase_account record
coinbase_account = coinbase_item.coinbase_accounts.find_or_initialize_by(
account_id: account_data["id"]
)
# Determine the currency (crypto symbol)
currency_code = account_data.dig("balance", "currency") || account_data.dig("currency", "code")
# Update account details
coinbase_account.assign_attributes(
name: account_data["name"] || currency_code,
currency: currency_code,
current_balance: balance,
account_type: account_data["type"], # "wallet", "vault", etc.
account_status: account_data.dig("status") || "active",
provider: "coinbase",
raw_payload: account_data,
institution_metadata: {
"name" => "Coinbase",
"domain" => "coinbase.com",
"crypto_name" => account_data.dig("currency", "name"),
"crypto_code" => currency_code,
"crypto_type" => account_data.dig("currency", "type") # "crypto" or "fiat"
}
)
# Fetch transactions for this account if it has a balance
if balance > 0
fetch_and_store_transactions(coinbase_account, account_data["id"])
end
coinbase_account.save!
end
def fetch_and_store_transactions(coinbase_account, account_id)
# Fetch transactions for this account (includes buys, sells, sends, receives)
# This endpoint returns better data than separate buys/sells endpoints
transactions = coinbase_provider.get_transactions(account_id, limit: 100)
# Store raw transaction data for processing later
coinbase_account.raw_transactions_payload = {
"transactions" => transactions,
"fetched_at" => Time.current.iso8601
}
Rails.logger.info(
"CoinbaseItem::Importer - Fetched #{transactions.count} transactions for #{coinbase_account.name}"
)
rescue Provider::Coinbase::ApiError => e
# Some accounts may not support transaction endpoints
Rails.logger.debug "CoinbaseItem::Importer - Could not fetch transactions for account #{account_id}: #{e.message}"
end
end

View File

@@ -0,0 +1,9 @@
module CoinbaseItem::Provided
extend ActiveSupport::Concern
def coinbase_provider
return nil unless credentials_configured?
Provider::Coinbase.new(api_key: api_key, api_secret: api_secret)
end
end

View File

@@ -0,0 +1,29 @@
# Broadcasts Turbo Stream updates when a Coinbase sync completes.
# Updates account views and notifies the family of sync completion.
class CoinbaseItem::SyncCompleteEvent
attr_reader :coinbase_item
# @param coinbase_item [CoinbaseItem] The item that completed syncing
def initialize(coinbase_item)
@coinbase_item = coinbase_item
end
# Broadcasts sync completion to update UI components.
def broadcast
# Update UI with latest account data
coinbase_item.accounts.each do |account|
account.broadcast_sync_complete
end
# Update the Coinbase item view
coinbase_item.broadcast_replace_to(
coinbase_item.family,
target: "coinbase_item_#{coinbase_item.id}",
partial: "coinbase_items/coinbase_item",
locals: { coinbase_item: coinbase_item }
)
# Let family handle sync notifications
coinbase_item.family.broadcast_sync_complete
end
end

View File

@@ -0,0 +1,92 @@
# Orchestrates the sync process for a Coinbase connection.
# Imports data, processes accounts, and schedules account syncs.
class CoinbaseItem::Syncer
include SyncStats::Collector
attr_reader :coinbase_item
# @param coinbase_item [CoinbaseItem] Item to sync
def initialize(coinbase_item)
@coinbase_item = coinbase_item
end
# Runs the full sync workflow: import, process, and schedule.
# @param sync [Sync] Sync record for status tracking
def perform_sync(sync)
# Phase 1: Check credentials are configured
sync.update!(status_text: I18n.t("coinbase_item.syncer.checking_credentials")) if sync.respond_to?(:status_text)
unless coinbase_item.credentials_configured?
error_message = I18n.t("coinbase_item.syncer.credentials_invalid")
coinbase_item.update!(status: :requires_update)
mark_failed(sync, error_message)
return
end
# Phase 2: Import data from Coinbase API
sync.update!(status_text: I18n.t("coinbase_item.syncer.importing_accounts")) if sync.respond_to?(:status_text)
coinbase_item.import_latest_coinbase_data
# Phase 3: Check account setup status and collect sync statistics
sync.update!(status_text: I18n.t("coinbase_item.syncer.checking_configuration")) if sync.respond_to?(:status_text)
# Use SyncStats::Collector for consistent stats (checks current_account.present? by default)
collect_setup_stats(sync, provider_accounts: coinbase_item.coinbase_accounts.to_a)
unlinked_accounts = coinbase_item.coinbase_accounts.left_joins(:account_provider).where(account_providers: { id: nil })
linked_accounts = coinbase_item.coinbase_accounts.joins(:account_provider).joins(:account).merge(Account.visible)
if unlinked_accounts.any?
coinbase_item.update!(pending_account_setup: true)
sync.update!(status_text: I18n.t("coinbase_item.syncer.accounts_need_setup", count: unlinked_accounts.count)) if sync.respond_to?(:status_text)
else
coinbase_item.update!(pending_account_setup: false)
end
# Phase 4: Process holdings for linked accounts only
if linked_accounts.any?
sync.update!(status_text: I18n.t("coinbase_item.syncer.processing_accounts")) if sync.respond_to?(:status_text)
coinbase_item.process_accounts
# Phase 5: Schedule balance calculations for linked accounts
sync.update!(status_text: I18n.t("coinbase_item.syncer.calculating_balances")) if sync.respond_to?(:status_text)
coinbase_item.schedule_account_syncs(
parent_sync: sync,
window_start_date: sync.window_start_date,
window_end_date: sync.window_end_date
)
# Phase 6: Collect trade statistics
account_ids = linked_accounts.map { |ca| ca.current_account&.id }.compact
collect_transaction_stats(sync, account_ids: account_ids, source: "coinbase") if account_ids.any?
end
end
# Hook called after sync completion. Currently a no-op.
def perform_post_sync
# no-op
end
private
# Marks the sync as failed with an error message.
# Mirrors SimplefinItem::Syncer#mark_failed for consistent failure handling.
#
# @param sync [Sync] The sync record to mark as failed
# @param error_message [String] The error message to record
def mark_failed(sync, error_message)
if sync.respond_to?(:status) && sync.status.to_s == "completed"
Rails.logger.warn("CoinbaseItem::Syncer#mark_failed called after completion: #{error_message}")
return
end
sync.start! if sync.respond_to?(:may_start?) && sync.may_start?
if sync.respond_to?(:may_fail?) && sync.may_fail?
sync.fail!
elsif sync.respond_to?(:status)
sync.update!(status: :failed)
end
sync.update!(error: error_message) if sync.respond_to?(:error)
sync.update!(status_text: error_message) if sync.respond_to?(:status_text)
end
end

View File

@@ -0,0 +1,49 @@
# frozen_string_literal: true
module CoinbaseItem::Unlinking
# Concern that encapsulates unlinking logic for a Coinbase item.
extend ActiveSupport::Concern
# Idempotently remove all connections between this Coinbase item and local accounts.
# - Detaches any AccountProvider links for each CoinbaseAccount
# - Detaches Holdings that point at the AccountProvider links
# Returns a per-account result payload for observability
def unlink_all!(dry_run: false)
results = []
coinbase_accounts.find_each do |provider_account|
links = AccountProvider.where(provider_type: CoinbaseAccount.name, provider_id: provider_account.id).to_a
link_ids = links.map(&:id)
result = {
provider_account_id: provider_account.id,
name: provider_account.name,
provider_link_ids: link_ids
}
results << result
next if dry_run
begin
ActiveRecord::Base.transaction do
# Detach holdings for any provider links found
if link_ids.any?
Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil)
end
# Destroy all provider links
links.each do |ap|
ap.destroy!
end
end
rescue StandardError => e
Rails.logger.warn(
"CoinbaseItem Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}"
)
# Record error for observability; continue with other accounts
result[:error] = e.message
end
end
results
end
end

View File

@@ -1,6 +1,14 @@
class Crypto < ApplicationRecord
include Accountable
# Subtypes differentiate how crypto is held:
# - wallet: Self-custody or provider-synced wallets (CoinStats, etc.)
# - exchange: Centralized exchanges with trade history (Coinbase, Kraken, etc.)
SUBTYPES = {
"wallet" => { short: "Wallet", long: "Crypto Wallet" },
"exchange" => { short: "Exchange", long: "Crypto Exchange" }
}.freeze
# Crypto is taxable by default, but can be held in tax-advantaged accounts
# (e.g., self-directed IRA, though rare)
enum :tax_treatment, {
@@ -9,6 +17,11 @@ class Crypto < ApplicationRecord
tax_exempt: "tax_exempt"
}, default: :taxable
# Exchange accounts support manual trade entry; wallets are sync-only
def supports_trades?
subtype == "exchange"
end
class << self
def color
"#737373"

View File

@@ -1,4 +1,5 @@
class Family < ApplicationRecord
include CoinbaseConnectable
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable, CoinstatsConnectable
DATE_FORMATS = [

View File

@@ -0,0 +1,28 @@
module Family::CoinbaseConnectable
extend ActiveSupport::Concern
included do
has_many :coinbase_items, dependent: :destroy
end
def can_connect_coinbase?
# Families can configure their own Coinbase credentials
true
end
def create_coinbase_item!(api_key:, api_secret:, item_name: nil)
coinbase_item = coinbase_items.create!(
name: item_name || "Coinbase",
api_key: api_key,
api_secret: api_secret
)
coinbase_item.sync_later
coinbase_item
end
def has_coinbase_credentials?
coinbase_items.where.not(api_key: nil).exists?
end
end

View File

@@ -46,10 +46,14 @@ class Holding < ApplicationRecord
# otherwise falls back to calculating from trades. Returns nil when cost
# basis cannot be determined (no trades and no provider cost_basis).
def avg_cost
# Use stored cost_basis if available and positive (eliminates N+1 queries)
# Note: cost_basis of 0 is treated as "unknown" since providers sometimes
# return 0 when they don't have the data
return Money.new(cost_basis, currency) if cost_basis.present? && cost_basis.positive?
# Use stored cost_basis if available (eliminates N+1 queries)
# - If locked (user-set), trust the value even if 0 (valid for airdrops)
# - Otherwise require positive since providers sometimes return 0 when unknown
if cost_basis.present?
if cost_basis_locked? || cost_basis.positive?
return Money.new(cost_basis, currency)
end
end
# Fallback to calculation for holdings without pre-computed cost_basis
calculate_avg_cost
@@ -139,7 +143,7 @@ class Holding < ApplicationRecord
private
def calculate_trend
return nil unless amount_money
return nil unless avg_cost # Can't calculate trend without cost basis
return nil if avg_cost.nil? # Can't calculate trend without cost basis (0 is valid for airdrops)
start_amount = qty * avg_cost

View File

@@ -53,6 +53,16 @@ class Holding::Materializer
key = holding_key(holding)
existing = existing_holdings_map[key]
# Skip provider-sourced holdings - they have authoritative data from the provider
# (e.g., Coinbase, SimpleFIN) and should not be overwritten by calculated holdings
if existing&.account_provider_id.present?
Rails.logger.debug(
"Holding::Materializer - Skipping provider-sourced holding id=#{existing.id} " \
"security_id=#{existing.security_id} date=#{existing.date}"
)
next
end
reconciled = Holding::CostBasisReconciler.reconcile(
existing_holding: existing,
incoming_cost_basis: holding.cost_basis,

View File

@@ -0,0 +1,200 @@
class Provider::Coinbase
class Error < StandardError; end
class AuthenticationError < Error; end
class RateLimitError < Error; end
class ApiError < Error; end
# CDP API base URL
API_BASE_URL = "https://api.coinbase.com".freeze
attr_reader :api_key, :api_secret
def initialize(api_key:, api_secret:)
@api_key = api_key
@api_secret = api_secret
end
# Get current user info
def get_user
get("/v2/user")["data"]
end
# Get all accounts (wallets)
def get_accounts
paginated_get("/v2/accounts")
end
# Get single account details
def get_account(account_id)
get("/v2/accounts/#{account_id}")["data"]
end
# Get transactions for an account
def get_transactions(account_id, limit: 100)
paginated_get("/v2/accounts/#{account_id}/transactions", limit: limit)
end
# Get buy transactions for an account
def get_buys(account_id, limit: 100)
paginated_get("/v2/accounts/#{account_id}/buys", limit: limit)
end
# Get sell transactions for an account
def get_sells(account_id, limit: 100)
paginated_get("/v2/accounts/#{account_id}/sells", limit: limit)
end
# Get deposits for an account
def get_deposits(account_id, limit: 100)
paginated_get("/v2/accounts/#{account_id}/deposits", limit: limit)
end
# Get withdrawals for an account
def get_withdrawals(account_id, limit: 100)
paginated_get("/v2/accounts/#{account_id}/withdrawals", limit: limit)
end
# Get spot price for a currency pair (e.g., "BTC-USD")
# This is a public endpoint that doesn't require authentication
def get_spot_price(currency_pair)
response = HTTParty.get("#{API_BASE_URL}/v2/prices/#{currency_pair}/spot", timeout: 10)
handle_response(response)["data"]
rescue => e
Rails.logger.warn("Coinbase: Failed to fetch spot price for #{currency_pair}: #{e.message}")
nil
end
# Get spot prices for multiple currencies in USD
# Returns hash like { "BTC" => 92520.90, "ETH" => 3200.50 }
def get_spot_prices(currencies)
prices = {}
currencies.each do |currency|
result = get_spot_price("#{currency}-USD")
prices[currency] = result["amount"].to_d if result && result["amount"]
end
prices
end
private
def get(path, params: {})
url = "#{API_BASE_URL}#{path}"
url += "?#{params.to_query}" if params.any?
response = HTTParty.get(
url,
headers: auth_headers("GET", path),
timeout: 30
)
handle_response(response)
end
def paginated_get(path, limit: 100)
results = []
next_uri = nil
fetched = 0
loop do
if next_uri
# Parse the next_uri to get just the path
uri = URI.parse(next_uri)
current_path = uri.path
current_path += "?#{uri.query}" if uri.query
url = "#{API_BASE_URL}#{current_path}"
else
current_path = path
url = "#{API_BASE_URL}#{path}"
end
response = HTTParty.get(
url,
headers: auth_headers("GET", current_path.split("?").first),
timeout: 30
)
data = handle_response(response)
results.concat(data["data"] || [])
fetched += (data["data"] || []).size
break if fetched >= limit
break unless data.dig("pagination", "next_uri")
next_uri = data.dig("pagination", "next_uri")
end
results.first(limit)
end
# Generate JWT token for CDP API authentication
# Uses Ed25519 signing algorithm
def generate_jwt(method, path)
# Decode the base64 private key
private_key_bytes = Base64.decode64(api_secret)
# Create Ed25519 signing key
signing_key = Ed25519::SigningKey.new(private_key_bytes[0, 32])
now = Time.now.to_i
uri = "#{method} api.coinbase.com#{path}"
# JWT header
header = {
alg: "EdDSA",
kid: api_key,
nonce: SecureRandom.hex(16),
typ: "JWT"
}
# JWT payload
payload = {
sub: api_key,
iss: "cdp",
nbf: now,
exp: now + 120,
uri: uri
}
# Encode header and payload
encoded_header = Base64.urlsafe_encode64(header.to_json, padding: false)
encoded_payload = Base64.urlsafe_encode64(payload.to_json, padding: false)
# Sign
message = "#{encoded_header}.#{encoded_payload}"
signature = signing_key.sign(message)
encoded_signature = Base64.urlsafe_encode64(signature, padding: false)
"#{message}.#{encoded_signature}"
end
def auth_headers(method, path)
{
"Authorization" => "Bearer #{generate_jwt(method, path)}",
"Content-Type" => "application/json"
}
end
def handle_response(response)
parsed = response.parsed_response
case response.code
when 200..299
parsed.is_a?(Hash) ? parsed : { "data" => parsed }
when 401
error_msg = extract_error_message(parsed) || "Unauthorized - check your API key and secret"
raise AuthenticationError, error_msg
when 429
raise RateLimitError, "Rate limit exceeded"
else
error_msg = extract_error_message(parsed) || "API error: #{response.code}"
raise ApiError, error_msg
end
end
def extract_error_message(parsed)
return parsed if parsed.is_a?(String)
return nil unless parsed.is_a?(Hash)
parsed.dig("errors", 0, "message") || parsed["error"] || parsed["message"]
end
end

View File

@@ -0,0 +1,104 @@
class Provider::CoinbaseAdapter < Provider::Base
include Provider::Syncable
include Provider::InstitutionMetadata
# Register this adapter with the factory
Provider::Factory.register("CoinbaseAccount", self)
# Define which account types this provider supports
def self.supported_account_types
%w[Crypto]
end
# Returns connection configurations for this provider
def self.connection_configs(family:)
return [] unless family.can_connect_coinbase?
[ {
key: "coinbase",
name: "Coinbase",
description: "Link to a Coinbase wallet",
can_connect: true,
new_account_path: ->(accountable_type, return_to) {
Rails.application.routes.url_helpers.select_accounts_coinbase_items_path(
accountable_type: accountable_type,
return_to: return_to
)
},
existing_account_path: ->(account_id) {
Rails.application.routes.url_helpers.select_existing_account_coinbase_items_path(
account_id: account_id
)
}
} ]
end
def provider_name
"coinbase"
end
# Build a Coinbase provider instance with family-specific credentials
# @param family [Family] The family to get credentials for (required)
# @return [Provider::Coinbase, nil] Returns nil if credentials are not configured
def self.build_provider(family: nil)
return nil unless family.present?
# Get family-specific credentials
coinbase_item = family.coinbase_items.where.not(api_key: nil).first
return nil unless coinbase_item&.credentials_configured?
Provider::Coinbase.new(
api_key: coinbase_item.api_key,
api_secret: coinbase_item.api_secret
)
end
def sync_path
Rails.application.routes.url_helpers.sync_coinbase_item_path(item)
end
def item
provider_account.coinbase_item
end
def can_delete_holdings?
false
end
def institution_domain
metadata = provider_account.institution_metadata
return nil unless metadata.present?
domain = metadata["domain"]
url = metadata["url"]
# Derive domain from URL if missing
if domain.blank? && url.present?
begin
domain = URI.parse(url).host&.gsub(/^www\./, "")
rescue URI::InvalidURIError
Rails.logger.warn("Invalid institution URL for Coinbase account #{provider_account.id}: #{url}")
end
end
domain
end
def institution_name
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["name"] || item&.institution_name
end
def institution_url
metadata = provider_account.institution_metadata
return nil unless metadata.present?
metadata["url"] || item&.institution_url
end
def institution_color
item&.institution_color
end
end

View File

@@ -33,7 +33,7 @@
<%= icon("pencil-line", size: "sm") %>
<% end %>
<% if !account.linked? && ["Depository", "CreditCard", "Investment"].include?(account.accountable_type) %>
<% if !account.linked? && ["Depository", "CreditCard", "Investment", "Crypto"].include?(account.accountable_type) %>
<%= link_to select_provider_account_path(account),
data: { turbo_frame: :modal },
class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center gap-1",

View File

@@ -21,7 +21,7 @@
</div>
</header>
<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? %>
<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? %>
<%= render "empty" %>
<% else %>
<div class="space-y-2">
@@ -45,6 +45,10 @@
<%= render @coinstats_items.sort_by(&:created_at) %>
<% end %>
<% if @coinbase_items.any? %>
<%= render @coinbase_items.sort_by(&:created_at) %>
<% end %>
<% if @manual_accounts.any? %>
<div id="manual-accounts">
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %>

View File

@@ -15,13 +15,19 @@
href: new_valuation_path(account_id: @account.id),
data: { turbo_frame: :modal }) %>
<% unless @account.crypto? %>
<% href = @account.investment? ? new_trade_path(account_id: @account.id) : new_transaction_path(account_id: @account.id) %>
<% if @account.supports_trades? %>
<% menu.with_item(
variant: "link",
text: "New transaction",
text: t(".new_trade"),
icon: "credit-card",
href: href,
href: new_trade_path(account_id: @account.id),
data: { turbo_frame: :modal }) %>
<% elsif !@account.crypto? %>
<% menu.with_item(
variant: "link",
text: t(".new_transaction"),
icon: "credit-card",
href: new_transaction_path(account_id: @account.id),
data: { turbo_frame: :modal }) %>
<% end %>
<% end %>

View File

@@ -3,11 +3,19 @@
<%= render DS::Menu.new(testid: "account-menu") do |menu| %>
<% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %>
<% unless account.crypto? %>
<% if account.supports_trades? %>
<% menu.with_item(
variant: "link",
text: "Import transactions",
href: imports_path({ import: { type: account.investment? ? "TradeImport" : "TransactionImport", account_id: account.id } }),
text: t(".import_trades"),
href: imports_path({ import: { type: "TradeImport", account_id: account.id } }),
icon: "download",
data: { turbo_frame: :_top }
) %>
<% elsif !account.crypto? %>
<% menu.with_item(
variant: "link",
text: t(".import_transactions"),
href: imports_path({ import: { type: "TransactionImport", account_id: account.id } }),
icon: "download",
data: { turbo_frame: :_top }
) %>

View File

@@ -0,0 +1,143 @@
<%# locals: (coinbase_item:) %>
<%= tag.div id: dom_id(coinbase_item) do %>
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center justify-between gap-2 focus-visible:outline-hidden">
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<div class="flex items-center justify-center h-8 w-8 rounded-full" style="background-color: rgba(0, 82, 255, 0.1);">
<div class="flex items-center justify-center">
<%= icon "bitcoin", size: "sm", class: "text-[#0052FF]" %>
</div>
</div>
<div class="pl-1 text-sm">
<div class="flex items-center gap-2">
<%= tag.p coinbase_item.institution_display_name, class: "font-medium text-primary" %>
<% if coinbase_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
</div>
<p class="text-xs text-secondary"><%= t(".provider_name") %></p>
<% if coinbase_item.syncing? %>
<div class="text-secondary flex items-center gap-1">
<%= icon "loader", size: "sm", class: "animate-spin" %>
<%= tag.span t(".syncing") %>
</div>
<% elsif coinbase_item.requires_update? %>
<div class="text-warning flex items-center gap-1">
<%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span t(".reconnect") %>
</div>
<% else %>
<p class="text-secondary">
<% if coinbase_item.last_synced_at %>
<% if coinbase_item.sync_status_summary %>
<%= t(".status_with_summary", timestamp: time_ago_in_words(coinbase_item.last_synced_at), summary: coinbase_item.sync_status_summary) %>
<% else %>
<%= t(".status", timestamp: time_ago_in_words(coinbase_item.last_synced_at)) %>
<% end %>
<% else %>
<%= t(".status_never") %>
<% end %>
</p>
<% end %>
</div>
</div>
<div class="flex items-center gap-2">
<% if coinbase_item.requires_update? %>
<%= render DS::Link.new(
text: t(".update_credentials"),
icon: "refresh-cw",
variant: "secondary",
href: settings_providers_path,
frame: "_top"
) %>
<% elsif Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_coinbase_item_path(coinbase_item)
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: coinbase_item_path(coinbase_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(coinbase_item.institution_display_name, high_severity: true)
) %>
<% end %>
</div>
</summary>
<% unless coinbase_item.scheduled_for_deletion? %>
<div class="space-y-4 mt-4">
<% if coinbase_item.accounts.any? %>
<%= render "accounts/index/account_groups", accounts: coinbase_item.accounts %>
<% end %>
<%# Sync summary (collapsible) - using shared ProviderSyncSummary component %>
<% stats = if defined?(@coinbase_sync_stats_map) && @coinbase_sync_stats_map
@coinbase_sync_stats_map[coinbase_item.id] || {}
else
coinbase_item.syncs.ordered.first&.sync_stats || {}
end %>
<%= render ProviderSyncSummary.new(
stats: stats,
provider_item: coinbase_item
) %>
<%# Compute unlinked Coinbase accounts (no AccountProvider link) %>
<% unlinked_count = if defined?(@coinbase_unlinked_count_map) && @coinbase_unlinked_count_map
@coinbase_unlinked_count_map[coinbase_item.id] || 0
else
begin
coinbase_item.coinbase_accounts
.left_joins(:account_provider)
.where(account_providers: { id: nil })
.count
rescue => e
0
end
end %>
<% if unlinked_count.to_i > 0 && coinbase_item.accounts.empty? %>
<%# No accounts imported yet - show prominent setup prompt %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
<p class="text-secondary text-sm"><%= t(".setup_description") %></p>
<%= render DS::Link.new(
text: t(".setup_action"),
icon: "plus",
variant: "primary",
href: setup_accounts_coinbase_item_path(coinbase_item),
frame: :modal
) %>
</div>
<% elsif unlinked_count.to_i > 0 %>
<%# Some accounts imported, more available - show subtle link %>
<div class="pt-2 border-t border-primary">
<%= link_to setup_accounts_coinbase_item_path(coinbase_item),
data: { turbo_frame: :modal },
class: "flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors" do %>
<%= icon "plus", size: "sm" %>
<span><%= t(".more_wallets_available", count: unlinked_count) %></span>
<% end %>
</div>
<% elsif coinbase_item.accounts.empty? && coinbase_item.coinbase_accounts.none? %>
<%# No coinbase_accounts at all - waiting for sync %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_title") %></p>
<p class="text-secondary text-sm"><%= t(".no_accounts_message") %></p>
</div>
<% end %>
</div>
<% end %>
</details>
<% end %>

View File

@@ -0,0 +1,43 @@
<%# Modal: Link an existing manual account to a Coinbase account %>
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
<% if @available_coinbase_accounts.blank? %>
<div class="p-4 text-sm text-secondary">
<p class="mb-2"><%= t(".no_accounts_found") %></p>
<ul class="list-disc list-inside space-y-1">
<li><%= t(".wait_for_sync") %></li>
<li><%= t(".check_provider_health") %></li>
</ul>
</div>
<% else %>
<%= form_with url: link_existing_account_coinbase_items_path, method: :post, class: "space-y-4" do %>
<%= hidden_field_tag :account_id, @account.id %>
<div class="space-y-2 max-h-64 overflow-auto">
<% @available_coinbase_accounts.each do |ca| %>
<label class="flex items-center gap-3 p-2 rounded border border-surface-inset/50 hover:border-primary cursor-pointer">
<%= radio_button_tag :coinbase_account_id, ca.id, false %>
<div class="flex flex-col">
<span class="text-sm text-primary font-medium"><%= ca.name.presence || ca.account_id %></span>
<span class="text-xs text-secondary">
<%= ca.currency %> &bull; <%= t(".balance") %>: <%= number_with_delimiter(ca.current_balance || 0, delimiter: ",") %>
</span>
<% if ca.current_account.present? %>
<span class="text-xs text-secondary"><%= t(".currently_linked_to", account_name: ca.current_account.name) %></span>
<% end %>
</div>
</label>
<% end %>
</div>
<div class="flex items-center justify-end gap-2">
<%= render DS::Button.new(text: t(".link"), variant: :primary, icon: "link-2", type: :submit) %>
<%= render DS::Link.new(text: t(".cancel"), variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
</div>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,104 @@
<% content_for :title, t(".title") %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title")) do %>
<div class="flex items-center gap-2">
<%= icon "bitcoin", class: "text-primary" %>
<span class="text-primary"><%= t(".subtitle") %></span>
</div>
<% end %>
<% dialog.with_body do %>
<%= form_with url: complete_account_setup_coinbase_item_path(@coinbase_item),
method: :post,
local: true,
id: "coinbase-setup-form",
data: {
controller: "loading-button",
action: "submit->loading-button#showLoading",
loading_button_loading_text_value: t(".creating"),
turbo_frame: "_top"
},
class: "space-y-6" do |form| %>
<div class="space-y-4">
<div class="bg-surface border border-primary p-4 rounded-lg">
<div class="flex items-start gap-3">
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<div>
<p class="text-sm text-primary">
<%= t(".instructions") %>
</p>
</div>
</div>
</div>
<% if @coinbase_accounts.empty? %>
<div class="text-center py-8">
<p class="text-secondary"><%= t(".no_accounts") %></p>
</div>
<% else %>
<div data-controller="select-all">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-secondary">
<%= t(".accounts_count", count: @coinbase_accounts.count) %>
</span>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox"
id="coinbase-select-all"
data-action="change->select-all#toggle"
class="checkbox checkbox--dark">
<span class="text-secondary"><%= t(".select_all") %></span>
</label>
</div>
<div class="space-y-2 max-h-96 overflow-y-auto">
<% @coinbase_accounts.each do |coinbase_account| %>
<label for="cb_<%= coinbase_account.id %>" class="flex items-center gap-3 p-3 border border-primary rounded-lg hover:bg-surface transition-colors cursor-pointer">
<%= check_box_tag "selected_accounts[]",
coinbase_account.id,
false,
id: "cb_#{coinbase_account.id}",
class: "checkbox checkbox--dark",
data: { select_all_target: "checkbox" } %>
<div class="flex-1 min-w-0">
<p class="font-medium text-primary truncate">
<%= coinbase_account.name %>
</p>
<p class="text-xs text-secondary">
<%= coinbase_account.currency %>
</p>
</div>
<div class="text-right flex-shrink-0">
<p class="text-sm font-medium text-primary">
<%= number_with_delimiter(coinbase_account.current_balance || 0, delimiter: ",") %>
</p>
<p class="text-xs text-secondary">
<%= coinbase_account.currency %>
</p>
</div>
</label>
<% end %>
</div>
</div>
<% end %>
</div>
<div class="flex gap-3">
<%= render DS::Button.new(
text: t(".import_selected"),
variant: "primary",
icon: "plus",
type: "submit",
class: "flex-1",
data: { loading_button_target: "button" }
) %>
<%= render DS::Link.new(
text: t(".cancel"),
variant: "secondary",
href: accounts_path
) %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -2,10 +2,14 @@
<%= render "accounts/form", account: account, url: url do |form| %>
<%= form.fields_for :accountable do |crypto_form| %>
<%= crypto_form.select :subtype,
Crypto::SUBTYPES.map { |k, v| [v[:long], k] },
{ label: t(".subtype_label"), prompt: t(".subtype_prompt"), include_blank: t(".subtype_none") } %>
<%= crypto_form.select :tax_treatment,
Crypto.tax_treatments.keys.map { |k| [t("accounts.tax_treatments.#{k}"), k] },
{ label: t("cryptos.form.tax_treatment_label"), include_blank: false },
{ label: t(".tax_treatment_label"), include_blank: false },
{} %>
<p class="text-xs text-secondary mt-1"><%= t("cryptos.form.tax_treatment_hint") %></p>
<p class="text-xs text-secondary mt-1"><%= t(".tax_treatment_hint") %></p>
<% end %>
<% end %>

View File

@@ -0,0 +1,5 @@
<%# locals: (account:) %>
<%= turbo_frame_tag dom_id(account, :holdings), src: holdings_path(account_id: account.id) do %>
<%= render "entries/loading" %>
<% end %>

View File

@@ -48,7 +48,7 @@
<%# Show Total Return (unrealized G/L) when cost basis exists (from trades or manual) %>
<% if holding.trend %>
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
<%= tag.p "(#{holding.trend.percent_formatted})", style: "color: #{holding.trend.color};" %>
<% else %>
<%= tag.p "--", class: "text-secondary" %>
<%= tag.p t(".no_cost_basis"), class: "text-xs text-secondary" %>

View File

@@ -24,8 +24,10 @@
</div>
<div class="bg-container rounded-lg shadow-border-xs">
<%= render "holdings/cash", account: @account %>
<%= render "shared/ruler" %>
<% if @account.cash_balance.to_d > 0 %>
<%= render "holdings/cash", account: @account %>
<%= render "shared/ruler" %>
<% end %>
<% if @account.current_holdings.any? %>
<%= render partial: "holdings/holding",

View File

@@ -0,0 +1,94 @@
<div id="coinbase-providers-panel" class="space-y-4">
<% items = local_assigns[:coinbase_items] || @coinbase_items || Current.family.coinbase_items.active.ordered %>
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium"><%= t("settings.providers.coinbase_panel.setup_instructions") %></p>
<ol>
<li><%= t("settings.providers.coinbase_panel.step1_html").html_safe %></li>
<li><%= t("settings.providers.coinbase_panel.step2") %></li>
<li><%= t("settings.providers.coinbase_panel.step3") %></li>
</ol>
</div>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
</div>
<% end %>
<% if items.any? %>
<div class="space-y-3">
<% items.each do |item| %>
<div class="flex items-center justify-between p-3 bg-container-inset rounded-lg border border-primary">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-[#0052FF] flex items-center justify-center">
<%= icon "bitcoin", size: "md", class: "text-white" %>
</div>
<div>
<p class="font-medium text-primary"><%= item.name %></p>
<p class="text-xs text-secondary">
<% if item.syncing? %>
<%= t("settings.providers.coinbase_panel.syncing") %>
<% else %>
<%= item.sync_status_summary %>
<% end %>
</p>
</div>
</div>
<div class="flex items-center gap-2">
<%= button_to sync_coinbase_item_path(item),
method: :post,
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg hover:border-primary",
disabled: item.syncing? do %>
<%= icon "refresh-cw", size: "sm" %>
<%= t("settings.providers.coinbase_panel.sync") %>
<% end %>
<%= button_to coinbase_item_path(item),
method: :delete,
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-destructive hover:bg-destructive/10 rounded-lg",
data: { turbo_confirm: t("settings.providers.coinbase_panel.disconnect_confirm") } do %>
<%= icon "trash-2", size: "sm" %>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<%
coinbase_item = Current.family.coinbase_items.build(name: "Coinbase")
%>
<%= styled_form_with model: coinbase_item,
url: coinbase_items_path,
scope: :coinbase_item,
method: :post,
data: { turbo: true },
class: "space-y-3" do |form| %>
<%= form.text_field :api_key,
label: t("settings.providers.coinbase_panel.api_key_label"),
placeholder: t("settings.providers.coinbase_panel.api_key_placeholder"),
type: :password %>
<%= form.text_field :api_secret,
label: t("settings.providers.coinbase_panel.api_secret_label"),
placeholder: t("settings.providers.coinbase_panel.api_secret_placeholder"),
type: :password %>
<div class="flex justify-end">
<%= form.submit t("settings.providers.coinbase_panel.connect_button"),
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %>
</div>
<% end %>
<% end %>
<div class="flex items-center gap-2">
<% if items.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary"><%= t("settings.providers.coinbase_panel.status_connected") %></p>
<% else %>
<div class="w-2 h-2 bg-tertiary rounded-full"></div>
<p class="text-sm text-secondary"><%= t("settings.providers.coinbase_panel.status_not_connected") %></p>
<% end %>
</div>
</div>

View File

@@ -32,9 +32,15 @@
</turbo-frame>
<% end %>
<%= settings_section title: "CoinStats", collapsible: true, open: false do %>
<%= settings_section title: "CoinStats (beta)", collapsible: true, open: false do %>
<turbo-frame id="coinstats-providers-panel">
<%= render "settings/providers/coinstats_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "#{t('.coinbase_title')} (beta)", collapsible: true, open: false do %>
<turbo-frame id="coinbase-providers-panel">
<%= render "settings/providers/coinbase_panel" %>
</turbo-frame>
<% end %>
</div>

View File

@@ -3,7 +3,7 @@
<div id="<%= dom_id(entry, :header) %>">
<%= tag.header class: "mb-4 space-y-1" do %>
<span class="text-secondary text-sm">
<%= entry.amount.negative? ? t(".sell") : t(".buy") %>
<%= entry.amount.negative? ? t(".buy") : t(".sell") %>
</span>
<div class="flex items-center gap-4">

View File

@@ -20,8 +20,8 @@
<div class="flex items-center gap-2">
<%= f.select :nature,
[["Buy", "outflow"], ["Sell", "inflow"]],
{ container_class: "w-1/3", label: "Type", selected: @entry.amount.negative? ? "inflow" : "outflow" },
[[t(".buy"), "outflow"], [t(".sell"), "inflow"]],
{ container_class: "w-1/3", label: t(".type_label"), selected: @entry.amount.negative? ? "outflow" : "inflow" },
{ data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? } %>
<%= f.fields_for :entryable do |ef| %>

View File

@@ -0,0 +1,5 @@
---
en:
coinbase:
processor:
paid_via: "Paid via %{method}"

View File

@@ -58,6 +58,7 @@ en:
new: New
new_activity: New activity
new_balance: New balance
new_trade: New trade
new_transaction: New transaction
new_transfer: New transfer
no_entries: No entries found
@@ -79,6 +80,8 @@ en:
confirm_title: Delete account?
edit: Edit
import: Import transactions
import_trades: Import trades
import_transactions: Import transactions
manage: Manage accounts
update:
success: "%{type} account updated"

View File

@@ -0,0 +1,77 @@
---
en:
coinbase_items:
create:
default_name: Coinbase
success: Successfully connected to Coinbase! Your accounts are being synced.
update:
success: Successfully updated Coinbase configuration.
destroy:
success: Scheduled Coinbase connection for deletion.
setup_accounts:
title: Import Coinbase Wallets
subtitle: Select which wallets to track
instructions: Select the wallets you want to import. Unselected wallets will remain available if you want to add them later.
no_accounts: All wallets have been imported.
accounts_count:
one: "%{count} wallet available"
other: "%{count} wallets available"
select_all: Select all
import_selected: Import Selected
cancel: Cancel
creating: Importing...
complete_account_setup:
success:
one: "Imported %{count} wallet"
other: "Imported %{count} wallets"
none_selected: No wallets selected
no_accounts: No wallets to import
coinbase_item:
provider_name: Coinbase
syncing: Syncing...
reconnect: Credentials need updating
deletion_in_progress: Deleting...
sync_status:
no_accounts: No accounts found
all_synced:
one: "%{count} account synced"
other: "%{count} accounts synced"
partial_sync: "%{linked_count} synced, %{unlinked_count} need setup"
status: "Last synced %{timestamp} ago"
status_with_summary: "Last synced %{timestamp} ago - %{summary}"
status_never: Never synced
update_credentials: Update credentials
delete: Delete
no_accounts_title: No accounts found
no_accounts_message: Your Coinbase wallets will appear here after syncing.
setup_needed: Wallets ready to import
setup_description: Select which Coinbase wallets you want to track.
setup_action: Import Wallets
more_wallets_available:
one: "%{count} more wallet available to import"
other: "%{count} more wallets available to import"
select_existing_account:
title: Link Coinbase Account
no_accounts_found: No Coinbase accounts found.
wait_for_sync: Wait for Coinbase to finish syncing
check_provider_health: Check that your Coinbase API credentials are valid
balance: Balance
currently_linked_to: "Currently linked to: %{account_name}"
link: Link
cancel: Cancel
link_existing_account:
success: Successfully linked to Coinbase account
errors:
only_manual: Only manual accounts can be linked to Coinbase
invalid_coinbase_account: Invalid Coinbase account
coinbase_item:
syncer:
checking_credentials: Checking credentials...
credentials_invalid: Invalid API credentials. Please check your API key and secret.
importing_accounts: Importing accounts from Coinbase...
checking_configuration: Checking account configuration...
accounts_need_setup:
one: "%{count} account needs setup"
other: "%{count} accounts need setup"
processing_accounts: Processing account data...
calculating_balances: Calculating balances...

View File

@@ -4,7 +4,17 @@ en:
edit:
edit: Edit %{account}
form:
subtype_label: Account type
subtype_prompt: Select type...
subtype_none: Not specified
tax_treatment_label: Tax Treatment
tax_treatment_hint: Most cryptocurrency is held in taxable accounts. Select a different option if held in a tax-advantaged account like a self-directed IRA.
new:
title: Enter account balance
subtypes:
wallet:
short: Wallet
long: Crypto Wallet
exchange:
short: Exchange
long: Crypto Exchange

View File

@@ -144,3 +144,21 @@ en:
choose: Upload photo
choose_label: (optional)
change: Change photo
providers:
show:
coinbase_title: Coinbase
coinbase_panel:
setup_instructions: "To connect Coinbase:"
step1_html: Go to <a href="https://www.coinbase.com/settings/api" target="_blank" class="text-primary underline">Coinbase API Settings</a>
step2: Create a new API key with read-only permissions (view accounts, view transactions)
step3: Copy your API key and API secret and paste them below
api_key_label: API Key
api_key_placeholder: Enter your Coinbase API key
api_secret_label: API Secret
api_secret_placeholder: Enter your Coinbase API secret
connect_button: Connect Coinbase
syncing: Syncing...
sync: Sync
disconnect_confirm: Are you sure you want to disconnect this Coinbase connection? Your synced accounts will become manual accounts.
status_connected: Coinbase is connected and syncing your crypto holdings.
status_not_connected: Not connected. Enter your API credentials above to get started.

View File

@@ -24,6 +24,7 @@ en:
title: New transaction
show:
additional: Additional
buy: Buy
category_label: Category
cost_per_share_label: Cost per Share
date_label: Date
@@ -37,4 +38,6 @@ en:
note_label: Note
note_placeholder: Add any additional notes here...
quantity_label: Quantity
sell: Sell
settings: Settings
type_label: Type

View File

@@ -2,6 +2,21 @@ require "sidekiq/web"
require "sidekiq/cron/web"
Rails.application.routes.draw do
resources :coinbase_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do
collection do
get :preload_accounts
get :select_accounts
post :link_accounts
get :select_existing_account
post :link_existing_account
end
member do
post :sync
get :setup_accounts
post :complete_account_setup
end
end
# CoinStats routes
resources :coinstats_items, only: [ :index, :new, :create, :update, :destroy ] do
collection do

View File

@@ -0,0 +1,61 @@
class CreateCoinbaseItemsAndAccounts < ActiveRecord::Migration[7.2]
def change
# Create provider items table (stores per-family connection credentials)
create_table :coinbase_items, id: :uuid do |t|
t.references :family, null: false, foreign_key: true, type: :uuid
t.string :name
# Institution metadata
t.string :institution_id
t.string :institution_name
t.string :institution_domain
t.string :institution_url
t.string :institution_color
# Status and lifecycle
t.string :status, default: "good"
t.boolean :scheduled_for_deletion, default: false
t.boolean :pending_account_setup, default: false
# Sync settings
t.datetime :sync_start_date
# Raw data storage
t.jsonb :raw_payload
t.jsonb :raw_institution_payload
# Provider-specific credential fields (API Key authentication)
t.text :api_key
t.text :api_secret
t.timestamps
end
add_index :coinbase_items, :status
# Create provider accounts table (stores individual account data from provider)
create_table :coinbase_accounts, id: :uuid do |t|
t.references :coinbase_item, null: false, foreign_key: true, type: :uuid
# Account identification
t.string :name
t.string :account_id
# Account details
t.string :currency
t.decimal :current_balance, precision: 19, scale: 4
t.string :account_status
t.string :account_type
t.string :provider
# Metadata and raw data
t.jsonb :institution_metadata
t.jsonb :raw_payload
t.jsonb :raw_transactions_payload
t.timestamps
end
add_index :coinbase_accounts, :account_id
end
end

46
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_01_17_200000) do
ActiveRecord::Schema[7.2].define(version: 2026_01_19_005756) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -199,6 +199,46 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_17_200000) do
t.index ["user_id"], name: "index_chats_on_user_id"
end
create_table "coinbase_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "coinbase_item_id", null: false
t.string "name"
t.string "account_id"
t.string "currency"
t.decimal "current_balance", precision: 19, scale: 4
t.string "account_status"
t.string "account_type"
t.string "provider"
t.jsonb "institution_metadata"
t.jsonb "raw_payload"
t.jsonb "raw_transactions_payload"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_coinbase_accounts_on_account_id"
t.index ["coinbase_item_id"], name: "index_coinbase_accounts_on_coinbase_item_id"
end
create_table "coinbase_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false
t.string "name"
t.string "institution_id"
t.string "institution_name"
t.string "institution_domain"
t.string "institution_url"
t.string "institution_color"
t.string "status", default: "good"
t.boolean "scheduled_for_deletion", default: false
t.boolean "pending_account_setup", default: false
t.datetime "sync_start_date"
t.jsonb "raw_payload"
t.jsonb "raw_institution_payload"
t.text "api_key"
t.text "api_secret"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["family_id"], name: "index_coinbase_items_on_family_id"
t.index ["status"], name: "index_coinbase_items_on_status"
end
create_table "coinstats_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "coinstats_item_id", null: false
t.string "name"
@@ -214,7 +254,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_17_200000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "wallet_address"
t.index [ :coinstats_item_id, :account_id, :wallet_address ], name: "index_coinstats_accounts_on_item_account_and_wallet", unique: true
t.index ["coinstats_item_id", "account_id", "wallet_address"], name: "index_coinstats_accounts_on_item_account_and_wallet", unique: true
t.index ["coinstats_item_id"], name: "index_coinstats_accounts_on_coinstats_item_id"
end
@@ -1296,6 +1336,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_17_200000) do
add_foreign_key "budgets", "families"
add_foreign_key "categories", "families"
add_foreign_key "chats", "users"
add_foreign_key "coinbase_accounts", "coinbase_items"
add_foreign_key "coinbase_items", "families"
add_foreign_key "coinstats_accounts", "coinstats_items"
add_foreign_key "coinstats_items", "families"
add_foreign_key "enable_banking_accounts", "enable_banking_items"

View File

@@ -0,0 +1,178 @@
require "test_helper"
class CoinbaseItemsControllerTest < ActionDispatch::IntegrationTest
include ActiveJob::TestHelper
setup do
sign_in users(:family_admin)
@family = families(:dylan_family)
@coinbase_item = CoinbaseItem.create!(
family: @family,
name: "Test Coinbase",
api_key: "test_key",
api_secret: "test_secret"
)
end
test "should destroy coinbase item" do
assert_difference("CoinbaseItem.count", 0) do # doesn't delete immediately
delete coinbase_item_url(@coinbase_item)
end
assert_redirected_to settings_providers_path
@coinbase_item.reload
assert @coinbase_item.scheduled_for_deletion?
end
test "should sync coinbase item" do
post sync_coinbase_item_url(@coinbase_item)
assert_response :redirect
end
test "should show setup_accounts page" do
get setup_accounts_coinbase_item_url(@coinbase_item)
assert_response :success
end
test "complete_account_setup creates accounts for selected coinbase_accounts" do
coinbase_account = @coinbase_item.coinbase_accounts.create!(
name: "BTC Wallet",
account_id: "btc_123",
currency: "BTC",
current_balance: 0.5,
raw_payload: { "native_balance" => { "amount" => "50000", "currency" => "USD" } }
)
assert_difference "Account.count", 1 do
post complete_account_setup_coinbase_item_url(@coinbase_item), params: {
selected_accounts: [ coinbase_account.id ]
}
end
assert_response :redirect
# Verify account was created and linked
coinbase_account.reload
assert_not_nil coinbase_account.current_account
assert_equal "Crypto", coinbase_account.current_account.accountable_type
end
test "complete_account_setup with no selection shows message" do
@coinbase_item.coinbase_accounts.create!(
name: "BTC Wallet",
account_id: "btc_123",
currency: "BTC",
current_balance: 0.5
)
assert_no_difference "Account.count" do
post complete_account_setup_coinbase_item_url(@coinbase_item), params: {
selected_accounts: []
}
end
assert_response :redirect
end
test "complete_account_setup skips already linked accounts" do
coinbase_account = @coinbase_item.coinbase_accounts.create!(
name: "BTC Wallet",
account_id: "btc_123",
currency: "BTC",
current_balance: 0.5
)
# Pre-link the account
account = Account.create!(
family: @family,
name: "Existing BTC",
balance: 50000,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: account, provider: coinbase_account)
assert_no_difference "Account.count" do
post complete_account_setup_coinbase_item_url(@coinbase_item), params: {
selected_accounts: [ coinbase_account.id ]
}
end
end
test "cannot access other family's coinbase_item" do
other_family = families(:empty)
other_item = CoinbaseItem.create!(
family: other_family,
name: "Other Coinbase",
api_key: "other_test_key",
api_secret: "other_test_secret"
)
get setup_accounts_coinbase_item_url(other_item)
assert_response :not_found
end
test "link_existing_account links manual account to coinbase_account" do
# Create a manual account (no provider links)
manual_account = Account.create!(
family: @family,
name: "Manual Crypto",
balance: 0,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
# Create a coinbase account
coinbase_account = @coinbase_item.coinbase_accounts.create!(
name: "BTC Wallet",
account_id: "btc_123",
currency: "BTC",
current_balance: 0.5
)
assert_difference "AccountProvider.count", 1 do
post link_existing_account_coinbase_items_url, params: {
account_id: manual_account.id,
coinbase_account_id: coinbase_account.id
}
end
coinbase_account.reload
assert_equal manual_account, coinbase_account.current_account
end
test "link_existing_account rejects account with existing provider" do
# Create an account already linked via AccountProvider
linked_account = Account.create!(
family: @family,
name: "Already Linked",
balance: 0,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
# Create an existing provider link (e.g., from another Coinbase account)
other_coinbase_account = @coinbase_item.coinbase_accounts.create!(
name: "Other Wallet",
account_id: "other_123",
currency: "ETH",
current_balance: 1.0
)
AccountProvider.create!(account: linked_account, provider: other_coinbase_account)
# Try to link a different coinbase account to the same account
coinbase_account = @coinbase_item.coinbase_accounts.create!(
name: "BTC Wallet",
account_id: "btc_123",
currency: "BTC",
current_balance: 0.5
)
assert_no_difference "AccountProvider.count" do
post link_existing_account_coinbase_items_url, params: {
account_id: linked_account.id,
coinbase_account_id: coinbase_account.id
}
end
end
end

View File

@@ -0,0 +1,131 @@
require "test_helper"
class CoinbaseAccountTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinbase_item = CoinbaseItem.create!(
family: @family,
name: "Test Coinbase",
api_key: "test_key",
api_secret: "test_secret"
)
@coinbase_account = @coinbase_item.coinbase_accounts.create!(
name: "Bitcoin Wallet",
account_id: "cb_btc_123",
currency: "BTC",
current_balance: 0.5
)
end
test "belongs to coinbase_item" do
assert_equal @coinbase_item, @coinbase_account.coinbase_item
end
test "validates presence of name" do
account = CoinbaseAccount.new(coinbase_item: @coinbase_item, currency: "BTC")
assert_not account.valid?
assert_includes account.errors[:name], "can't be blank"
end
test "validates presence of currency" do
account = CoinbaseAccount.new(coinbase_item: @coinbase_item, name: "Test")
assert_not account.valid?
assert_includes account.errors[:currency], "can't be blank"
end
test "upsert_coinbase_snapshot! updates account data" do
snapshot = {
"id" => "new_account_id",
"name" => "Updated Wallet",
"balance" => 1.5,
"status" => "active",
"currency" => "BTC"
}
@coinbase_account.upsert_coinbase_snapshot!(snapshot)
assert_equal "new_account_id", @coinbase_account.account_id
assert_equal "Updated Wallet", @coinbase_account.name
assert_equal 1.5, @coinbase_account.current_balance
assert_equal "active", @coinbase_account.account_status
end
test "upsert_coinbase_transactions_snapshot! stores transaction data" do
transactions = {
"transactions" => [
{ "id" => "tx1", "type" => "buy", "amount" => { "amount" => "0.1", "currency" => "BTC" } }
]
}
@coinbase_account.upsert_coinbase_transactions_snapshot!(transactions)
assert_equal transactions, @coinbase_account.raw_transactions_payload
end
test "current_account returns nil when no account_provider exists" do
assert_nil @coinbase_account.current_account
end
test "current_account returns linked account when account_provider exists" do
account = Account.create!(
family: @family,
name: "Coinbase BTC",
balance: 50000,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: account, provider: @coinbase_account)
# Reload to pick up the association
@coinbase_account.reload
assert_equal account, @coinbase_account.current_account
end
test "ensure_account_provider! creates provider link" do
account = Account.create!(
family: @family,
name: "Coinbase BTC",
balance: 50000,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
assert_difference "AccountProvider.count", 1 do
@coinbase_account.ensure_account_provider!(account)
end
@coinbase_account.reload
assert_equal account, @coinbase_account.current_account
end
test "ensure_account_provider! updates existing link" do
account1 = Account.create!(
family: @family,
name: "Coinbase BTC 1",
balance: 50000,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
account2 = Account.create!(
family: @family,
name: "Coinbase BTC 2",
balance: 60000,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
@coinbase_account.ensure_account_provider!(account1)
@coinbase_account.reload
assert_equal account1, @coinbase_account.current_account
# Now link to a different account
assert_no_difference "AccountProvider.count" do
@coinbase_account.ensure_account_provider!(account2)
end
@coinbase_account.reload
assert_equal account2, @coinbase_account.current_account
end
end

View File

@@ -0,0 +1,227 @@
require "test_helper"
class CoinbaseItemTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinbase_item = CoinbaseItem.create!(
family: @family,
name: "Test Coinbase Connection",
api_key: "test_key",
api_secret: "test_secret"
)
end
test "belongs to family" do
assert_equal @family, @coinbase_item.family
end
test "has many coinbase_accounts" do
account = @coinbase_item.coinbase_accounts.create!(
name: "Bitcoin Wallet",
account_id: "test_btc_123",
currency: "BTC",
current_balance: 0.5
)
assert_includes @coinbase_item.coinbase_accounts, account
end
test "has good status by default" do
assert_equal "good", @coinbase_item.status
end
test "validates presence of name" do
item = CoinbaseItem.new(family: @family, api_key: "key", api_secret: "secret")
assert_not item.valid?
assert_includes item.errors[:name], "can't be blank"
end
test "validates presence of api_key" do
item = CoinbaseItem.new(family: @family, name: "Test", api_secret: "secret")
assert_not item.valid?
assert_includes item.errors[:api_key], "can't be blank"
end
test "validates presence of api_secret" do
item = CoinbaseItem.new(family: @family, name: "Test", api_key: "key")
assert_not item.valid?
assert_includes item.errors[:api_secret], "can't be blank"
end
test "can be marked for deletion" do
refute @coinbase_item.scheduled_for_deletion?
@coinbase_item.destroy_later
assert @coinbase_item.scheduled_for_deletion?
end
test "is syncable" do
assert_respond_to @coinbase_item, :sync_later
assert_respond_to @coinbase_item, :syncing?
end
test "scopes work correctly" do
# Use explicit timestamp to ensure deterministic ordering
item_for_deletion = CoinbaseItem.create!(
family: @family,
name: "Delete Me",
api_key: "test_key",
api_secret: "test_secret",
scheduled_for_deletion: true,
created_at: 1.day.ago
)
active_items = @family.coinbase_items.active
ordered_items = @family.coinbase_items.ordered
assert_includes active_items, @coinbase_item
refute_includes active_items, item_for_deletion
# ordered scope sorts by created_at desc, so newer (@coinbase_item) comes first
assert_equal [ @coinbase_item, item_for_deletion ], ordered_items.to_a
end
test "credentials_configured? returns true when both keys present" do
assert @coinbase_item.credentials_configured?
end
test "credentials_configured? returns false when keys missing" do
@coinbase_item.api_key = nil
refute @coinbase_item.credentials_configured?
@coinbase_item.api_key = "key"
@coinbase_item.api_secret = nil
refute @coinbase_item.credentials_configured?
end
test "set_coinbase_institution_defaults! sets metadata" do
@coinbase_item.set_coinbase_institution_defaults!
assert_equal "Coinbase", @coinbase_item.institution_name
assert_equal "coinbase.com", @coinbase_item.institution_domain
assert_equal "https://www.coinbase.com", @coinbase_item.institution_url
assert_equal "#0052FF", @coinbase_item.institution_color
end
test "linked_accounts_count returns count of accounts with providers" do
coinbase_account = @coinbase_item.coinbase_accounts.create!(
name: "BTC Wallet",
account_id: "btc_123",
currency: "BTC",
current_balance: 1.0
)
assert_equal 0, @coinbase_item.linked_accounts_count
account = Account.create!(
family: @family,
name: "Coinbase BTC",
balance: 50000,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: account, provider: coinbase_account)
assert_equal 1, @coinbase_item.linked_accounts_count
end
test "unlinked_accounts_count returns count of accounts without providers" do
@coinbase_item.coinbase_accounts.create!(
name: "BTC Wallet",
account_id: "btc_123",
currency: "BTC",
current_balance: 1.0
)
assert_equal 1, @coinbase_item.unlinked_accounts_count
end
test "sync_status_summary with no accounts" do
assert_equal I18n.t("coinbase_items.coinbase_item.sync_status.no_accounts"), @coinbase_item.sync_status_summary
end
test "sync_status_summary with one linked account" do
coinbase_account = @coinbase_item.coinbase_accounts.create!(
name: "BTC Wallet",
account_id: "btc_123",
currency: "BTC",
current_balance: 1.0
)
account = Account.create!(
family: @family,
name: "Coinbase BTC",
balance: 50000,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: account, provider: coinbase_account)
assert_equal I18n.t("coinbase_items.coinbase_item.sync_status.all_synced", count: 1), @coinbase_item.sync_status_summary
end
test "sync_status_summary with multiple linked accounts" do
# Create first account
coinbase_account1 = @coinbase_item.coinbase_accounts.create!(
name: "BTC Wallet",
account_id: "btc_123",
currency: "BTC",
current_balance: 1.0
)
account1 = Account.create!(
family: @family,
name: "Coinbase BTC",
balance: 50000,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: account1, provider: coinbase_account1)
# Create second account
coinbase_account2 = @coinbase_item.coinbase_accounts.create!(
name: "ETH Wallet",
account_id: "eth_456",
currency: "ETH",
current_balance: 10.0
)
account2 = Account.create!(
family: @family,
name: "Coinbase ETH",
balance: 25000,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: account2, provider: coinbase_account2)
assert_equal I18n.t("coinbase_items.coinbase_item.sync_status.all_synced", count: 2), @coinbase_item.sync_status_summary
end
test "sync_status_summary with partial setup" do
# Create linked account
coinbase_account1 = @coinbase_item.coinbase_accounts.create!(
name: "BTC Wallet",
account_id: "btc_123",
currency: "BTC",
current_balance: 1.0
)
account = Account.create!(
family: @family,
name: "Coinbase BTC",
balance: 50000,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: account, provider: coinbase_account1)
# Create unlinked account
@coinbase_item.coinbase_accounts.create!(
name: "ETH Wallet",
account_id: "eth_456",
currency: "ETH",
current_balance: 10.0
)
assert_equal I18n.t("coinbase_items.coinbase_item.sync_status.partial_sync", linked_count: 1, unlinked_count: 1), @coinbase_item.sync_status_summary
end
end