Files
sure/app/controllers/binance_items_controller.rb
Louis 455c74dcfa Add Binance support, heavily inspired by the Coinbase one (#1317)
* feat: add Binance support (Items, Accounts, Importers, Processor, and Sync)

* refactor: deduplicate 'stablecoins' constant and push stale_rate filter to SQL

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-04-07 14:43:17 +02:00

288 lines
9.0 KiB
Ruby

# frozen_string_literal: true
class BinanceItemsController < ApplicationController
before_action :set_binance_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
before_action :require_admin!, only: [ :new, :create, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
def index
@binance_items = Current.family.binance_items.ordered
end
def show
end
def new
@binance_item = Current.family.binance_items.build
end
def edit
end
def create
@binance_item = Current.family.binance_items.build(binance_item_params)
@binance_item.name ||= t(".default_name")
if @binance_item.save
@binance_item.set_binance_institution_defaults!
@binance_item.sync_later
if turbo_frame_request?
flash.now[:notice] = t(".success")
@binance_items = Current.family.binance_items.ordered
render turbo_stream: [
turbo_stream.update(
"binance-providers-panel",
partial: "settings/providers/binance_panel",
locals: { binance_items: @binance_items }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
else
@error_message = @binance_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"binance-providers-panel",
partial: "settings/providers/binance_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :see_other
end
end
end
def update
if @binance_item.update(binance_item_params)
if turbo_frame_request?
flash.now[:notice] = t(".success")
@binance_items = Current.family.binance_items.ordered
render turbo_stream: [
turbo_stream.update(
"binance-providers-panel",
partial: "settings/providers/binance_panel",
locals: { binance_items: @binance_items }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
else
@error_message = @binance_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"binance-providers-panel",
partial: "settings/providers/binance_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :see_other
end
end
end
def destroy
@binance_item.destroy_later
redirect_to settings_providers_path, notice: t(".success")
end
def sync
unless @binance_item.syncing?
@binance_item.sync_later
end
respond_to do |format|
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
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])
@available_binance_accounts = Current.family.binance_items
.includes(binance_accounts: [ :account, { account_provider: :account } ])
.flat_map(&:binance_accounts)
.select { |ba| ba.account.present? || ba.account_provider.nil? }
.sort_by { |ba| ba.updated_at || ba.created_at }
.reverse
render :select_existing_account, layout: false
end
def link_existing_account
@account = Current.family.accounts.find(params[:account_id])
binance_account = BinanceAccount
.joins(:binance_item)
.where(id: params[:binance_account_id], binance_items: { family_id: Current.family.id })
.first
unless binance_account
alert_msg = t(".errors.invalid_binance_account")
if turbo_frame_request?
flash.now[:alert] = alert_msg
render turbo_stream: Array(flash_notification_stream_items)
else
redirect_to account_path(@account), alert: alert_msg
end
return
end
if @account.account_providers.any? || @account.plaid_account_id.present? || @account.simplefin_account_id.present?
alert_msg = t(".errors.only_manual")
if turbo_frame_request?
flash.now[:alert] = alert_msg
return render turbo_stream: Array(flash_notification_stream_items)
else
return redirect_to account_path(@account), alert: alert_msg
end
end
unless @account.crypto?
alert_msg = t(".errors.only_manual")
if turbo_frame_request?
flash.now[:alert] = alert_msg
return render turbo_stream: Array(flash_notification_stream_items)
else
return redirect_to account_path(@account), alert: alert_msg
end
end
Account.transaction do
binance_account.lock!
ap = AccountProvider.find_or_initialize_by(provider: binance_account)
previous_account = ap.account
ap.account_id = @account.id
ap.save!
# Orphan cleanup (detaching the old account from this provider) is handled
# by the background sync job; no immediate action is required here.
if previous_account && previous_account.id != @account.id && previous_account.family_id == @account.family_id
Rails.logger.info("Binance: re-linked BinanceAccount #{binance_account.id} from account ##{previous_account.id} to ##{@account.id}")
end
end
if turbo_frame_request?
item = binance_account.binance_item.reload
@binance_items = Current.family.binance_items.ordered.includes(:syncs)
@manual_accounts = Account.uncached { Current.family.accounts.visible_manual.order(:name).to_a }
flash.now[: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: "binance_items/binance_item",
locals: { binance_item: item }
),
manual_accounts_stream,
*Array(flash_notification_stream_items)
]
else
redirect_to accounts_path, notice: t(".success")
end
end
def setup_accounts
@binance_accounts = @binance_item.binance_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 |binance_account_id|
ba = @binance_item.binance_accounts.find_by(id: binance_account_id)
next unless ba
begin
ba.with_lock do
next if ba.account.present?
account = Account.create_from_binance_account(ba)
provider_link = ba.ensure_account_provider!(account)
if provider_link
created_accounts << account
else
account.destroy!
end
end
rescue StandardError => e
Rails.logger.error("Failed to setup account for BinanceAccount #{ba.id}: #{e.message}")
next
end
ba.reload
begin
BinanceAccount::HoldingsProcessor.new(ba).process
rescue StandardError => e
Rails.logger.error("Failed to process holdings for #{ba.id}: #{e.message}")
end
end
unlinked_remaining = @binance_item.binance_accounts
.left_joins(:account_provider)
.where(account_providers: { id: nil })
.count
@binance_item.update!(pending_account_setup: unlinked_remaining > 0)
if created_accounts.any?
flash.now[:notice] = t(".success", count: created_accounts.count)
elsif selected_accounts.empty?
flash.now[:notice] = t(".none_selected")
else
flash.now[:notice] = t(".no_accounts")
end
@binance_item.sync_later if created_accounts.any?
if turbo_frame_request?
@binance_items = Current.family.binance_items.ordered.includes(:syncs)
render turbo_stream: [
turbo_stream.replace(
ActionView::RecordIdentifier.dom_id(@binance_item),
partial: "binance_items/binance_item",
locals: { binance_item: @binance_item }
)
] + Array(flash_notification_stream_items)
else
redirect_to accounts_path, status: :see_other
end
end
private
def set_binance_item
@binance_item = Current.family.binance_items.find(params[:id])
end
def binance_item_params
params.require(:binance_item).permit(:name, :sync_start_date, :api_key, :api_secret)
end
end