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

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

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

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

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

* Fix log message wording for orphan cleanup

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

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

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

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

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

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

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

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

* fix schema drift

* Beta labels to lower expectations

---------

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

325 lines
10 KiB
Ruby

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