mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
* **Add Coinbase integration with item and account management** - Creates migrations for `coinbase_items` and `coinbase_accounts`. - Adds models, controllers, views, and background tasks to support account linking, syncing, and transaction handling. - Implements Coinbase API client and adapter for seamless integration. - Supports ActiveRecord encryption for secure credential storage. - Adds UI components for provider setup, account management, and synchronization. * Localize Coinbase-related UI strings, refine account linking for security, and add timeouts to Coinbase API requests. * Localize Coinbase account handling to support native currencies (USD, EUR, GBP, etc.) across balances, trades, holdings, and transactions. * Improve Coinbase processing with timezone-safe parsing, native currency support, and immediate holdings updates. * Improve trend percentage formatting and enhance race condition handling for Coinbase account linking. * Fix log message wording for orphan cleanup * Ensure `selected_accounts` parameter is sanitized by rejecting blank entries. * Add tests for Coinbase integration: account, item, and controller coverage - Adds unit tests for `CoinbaseAccount` and `CoinbaseItem` models. - Adds integration tests for `CoinbaseItemsController`. - Introduces Stimulus `select-all` controller for UI checkbox handling. - Localizes UI strings and logging for Coinbase integration. * Update test fixtures to use consistent placeholder API keys and secrets * Refine `coinbase_item` tests to ensure deterministic ordering and improve scope assertions. * Integrate `SyncStats::Collector` into Coinbase syncer to streamline statistics collection and enhance consistency. * Localize Coinbase sync status messages and improve sync summary test coverage. * Update `CoinbaseItem` encryption: use deterministic encryption for `api_key` and standard for `api_secret`. * fix schema drift * Beta labels to lower expectations --------- Co-authored-by: luckyPipewrench <luckypipewrench@proton.me> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
325 lines
10 KiB
Ruby
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
|