mirror of
https://github.com/we-promise/sure.git
synced 2026-04-21 13:04:18 +00: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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user