mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
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:
1
Gemfile
1
Gemfile
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -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
|
||||
|
||||
324
app/controllers/coinbase_items_controller.rb
Normal file
324
app/controllers/coinbase_items_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
14
app/javascript/controllers/select_all_controller.js
Normal file
14
app/javascript/controllers/select_all_controller.js
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
67
app/models/coinbase_account.rb
Normal file
67
app/models/coinbase_account.rb
Normal 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
|
||||
166
app/models/coinbase_account/holdings_processor.rb
Normal file
166
app/models/coinbase_account/holdings_processor.rb
Normal 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
|
||||
374
app/models/coinbase_account/processor.rb
Normal file
374
app/models/coinbase_account/processor.rb
Normal 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
184
app/models/coinbase_item.rb
Normal 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
|
||||
109
app/models/coinbase_item/importer.rb
Normal file
109
app/models/coinbase_item/importer.rb
Normal 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
|
||||
9
app/models/coinbase_item/provided.rb
Normal file
9
app/models/coinbase_item/provided.rb
Normal 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
|
||||
29
app/models/coinbase_item/sync_complete_event.rb
Normal file
29
app/models/coinbase_item/sync_complete_event.rb
Normal 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
|
||||
92
app/models/coinbase_item/syncer.rb
Normal file
92
app/models/coinbase_item/syncer.rb
Normal 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
|
||||
49
app/models/coinbase_item/unlinking.rb
Normal file
49
app/models/coinbase_item/unlinking.rb
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class Family < ApplicationRecord
|
||||
include CoinbaseConnectable
|
||||
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable, CoinstatsConnectable
|
||||
|
||||
DATE_FORMATS = [
|
||||
|
||||
28
app/models/family/coinbase_connectable.rb
Normal file
28
app/models/family/coinbase_connectable.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
200
app/models/provider/coinbase.rb
Normal file
200
app/models/provider/coinbase.rb
Normal 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
|
||||
104
app/models/provider/coinbase_adapter.rb
Normal file
104
app/models/provider/coinbase_adapter.rb
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 }
|
||||
) %>
|
||||
|
||||
143
app/views/coinbase_items/_coinbase_item.html.erb
Normal file
143
app/views/coinbase_items/_coinbase_item.html.erb
Normal 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 %>
|
||||
43
app/views/coinbase_items/select_existing_account.html.erb
Normal file
43
app/views/coinbase_items/select_existing_account.html.erb
Normal 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 %> • <%= 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 %>
|
||||
104
app/views/coinbase_items/setup_accounts.html.erb
Normal file
104
app/views/coinbase_items/setup_accounts.html.erb
Normal 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 %>
|
||||
@@ -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 %>
|
||||
|
||||
5
app/views/cryptos/tabs/_holdings.html.erb
Normal file
5
app/views/cryptos/tabs/_holdings.html.erb
Normal 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 %>
|
||||
@@ -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" %>
|
||||
|
||||
@@ -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",
|
||||
|
||||
94
app/views/settings/providers/_coinbase_panel.html.erb
Normal file
94
app/views/settings/providers/_coinbase_panel.html.erb
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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| %>
|
||||
|
||||
5
config/locales/models/coinbase_account/en.yml
Normal file
5
config/locales/models/coinbase_account/en.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
en:
|
||||
coinbase:
|
||||
processor:
|
||||
paid_via: "Paid via %{method}"
|
||||
@@ -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"
|
||||
|
||||
77
config/locales/views/coinbase_items/en.yml
Normal file
77
config/locales/views/coinbase_items/en.yml
Normal 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...
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
46
db/schema.rb
generated
@@ -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"
|
||||
|
||||
178
test/controllers/coinbase_items_controller_test.rb
Normal file
178
test/controllers/coinbase_items_controller_test.rb
Normal 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
|
||||
131
test/models/coinbase_account_test.rb
Normal file
131
test/models/coinbase_account_test.rb
Normal 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
|
||||
227
test/models/coinbase_item_test.rb
Normal file
227
test/models/coinbase_item_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user