mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
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>
This commit is contained in:
1
.github/workflows/pipelock.yml
vendored
1
.github/workflows/pipelock.yml
vendored
@@ -28,3 +28,4 @@ jobs:
|
||||
compose.example.ai.yml
|
||||
config/locales/views/reports/
|
||||
docs/hosting/ai.md
|
||||
app/models/provider/binance.rb
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -4,6 +4,9 @@
|
||||
# or operating system, you probably want to add a global ignore instead:
|
||||
# git config --global core.excludesfile '~/.gitignore_global'
|
||||
|
||||
# Git Worktrees
|
||||
.worktrees/
|
||||
|
||||
# Ignore bundler config.
|
||||
/.bundle
|
||||
/vendor/bundle
|
||||
@@ -73,6 +76,10 @@ compose.yml
|
||||
|
||||
plaid_test_accounts/
|
||||
|
||||
# Added by Claude
|
||||
.claude/settings.local.json
|
||||
docs/superpowers/
|
||||
|
||||
# Added by Claude Task Master
|
||||
# Logs
|
||||
logs
|
||||
@@ -108,7 +115,6 @@ scripts/
|
||||
.cursor/rules/dev_workflow.mdc
|
||||
.cursor/rules/taskmaster.mdc
|
||||
|
||||
|
||||
# Auto Claude data directory
|
||||
.auto-claude/
|
||||
|
||||
@@ -116,6 +122,5 @@ scripts/
|
||||
.auto-claude-security.json
|
||||
.auto-claude-status
|
||||
.claude_settings.json
|
||||
.worktrees/
|
||||
.security-key
|
||||
logs/security/
|
||||
logs/security/
|
||||
287
app/controllers/binance_items_controller.rb
Normal file
287
app/controllers/binance_items_controller.rb
Normal file
@@ -0,0 +1,287 @@
|
||||
# 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
|
||||
@@ -247,6 +247,25 @@ class Account < ApplicationRecord
|
||||
create_and_sync(attributes, skip_initial_sync: true)
|
||||
end
|
||||
|
||||
def create_from_binance_account(binance_account)
|
||||
family = binance_account.binance_item.family
|
||||
|
||||
attributes = {
|
||||
family: family,
|
||||
name: binance_account.name,
|
||||
balance: (binance_account.current_balance || 0).to_d,
|
||||
cash_balance: 0,
|
||||
currency: binance_account.currency.presence || family.currency,
|
||||
accountable_type: "Crypto",
|
||||
accountable_attributes: {
|
||||
subtype: "exchange",
|
||||
tax_treatment: "taxable"
|
||||
}
|
||||
}
|
||||
|
||||
create_and_sync(attributes, skip_initial_sync: true)
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
|
||||
39
app/models/binance_account.rb
Normal file
39
app/models/binance_account.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BinanceAccount < ApplicationRecord
|
||||
include CurrencyNormalizable, Encryptable
|
||||
|
||||
STABLECOINS = %w[USDT BUSD FDUSD TUSD USDC DAI].freeze
|
||||
|
||||
if encryption_ready?
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_transactions_payload
|
||||
end
|
||||
|
||||
belongs_to :binance_item
|
||||
|
||||
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
|
||||
|
||||
def current_account
|
||||
account
|
||||
end
|
||||
|
||||
def ensure_account_provider!(linked_account = nil)
|
||||
acct = linked_account || current_account
|
||||
return nil unless acct
|
||||
|
||||
AccountProvider
|
||||
.find_or_initialize_by(provider_type: "BinanceAccount", provider_id: id)
|
||||
.tap do |ap|
|
||||
ap.account = acct
|
||||
ap.save!
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("BinanceAccount #{id}: failed to link account provider — #{e.class}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
112
app/models/binance_account/holdings_processor.rb
Normal file
112
app/models/binance_account/holdings_processor.rb
Normal file
@@ -0,0 +1,112 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Creates/updates Holdings for each asset in the combined BinanceAccount.
|
||||
# One Holding per (symbol, source) pair.
|
||||
class BinanceAccount::HoldingsProcessor
|
||||
include BinanceAccount::UsdConverter
|
||||
|
||||
def initialize(binance_account)
|
||||
@binance_account = binance_account
|
||||
end
|
||||
|
||||
def process
|
||||
unless account&.accountable_type == "Crypto"
|
||||
Rails.logger.info "BinanceAccount::HoldingsProcessor - skipping: not a Crypto account"
|
||||
return
|
||||
end
|
||||
|
||||
assets = raw_assets
|
||||
if assets.empty?
|
||||
Rails.logger.info "BinanceAccount::HoldingsProcessor - no assets in payload"
|
||||
return
|
||||
end
|
||||
|
||||
assets.each { |asset| process_asset(asset) }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "BinanceAccount::HoldingsProcessor - error: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :binance_account
|
||||
|
||||
def target_currency
|
||||
binance_account.binance_item.family.currency
|
||||
end
|
||||
|
||||
def account
|
||||
binance_account.current_account
|
||||
end
|
||||
|
||||
def raw_assets
|
||||
binance_account.raw_payload&.dig("assets") || []
|
||||
end
|
||||
|
||||
def process_asset(asset)
|
||||
symbol = asset["symbol"] || asset[:symbol]
|
||||
return if symbol.blank?
|
||||
|
||||
total = (asset["total"] || asset[:total]).to_d
|
||||
source = asset["source"] || asset[:source]
|
||||
|
||||
return if total.zero?
|
||||
|
||||
ticker = symbol.include?(":") ? symbol : "CRYPTO:#{symbol}"
|
||||
security = resolve_security(ticker, symbol)
|
||||
return unless security
|
||||
|
||||
price_usd = fetch_price(symbol)
|
||||
return if price_usd.nil?
|
||||
|
||||
amount_usd = total * price_usd
|
||||
|
||||
# Stale rate metadata is intentionally discarded here — it is captured and
|
||||
# surfaced at the account level by BinanceAccount::Processor#process_account!.
|
||||
amount, _stale, _rate_date = convert_from_usd(amount_usd, date: Date.current)
|
||||
|
||||
# Also convert per-unit price to target currency
|
||||
price, _, _ = convert_from_usd(price_usd, date: Date.current)
|
||||
|
||||
import_adapter.import_holding(
|
||||
security: security,
|
||||
quantity: total,
|
||||
amount: amount,
|
||||
currency: target_currency,
|
||||
date: Date.current,
|
||||
price: price,
|
||||
cost_basis: nil,
|
||||
external_id: "binance_#{symbol}_#{source}_#{Date.current}",
|
||||
account_provider_id: binance_account.account_provider&.id,
|
||||
source: "binance",
|
||||
delete_future_holdings: false
|
||||
)
|
||||
|
||||
Rails.logger.info "BinanceAccount::HoldingsProcessor - imported #{total} #{symbol} (#{source}) @ #{price_usd} USD → #{amount} #{target_currency}"
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "BinanceAccount::HoldingsProcessor - failed asset #{asset}: #{e.message}"
|
||||
end
|
||||
|
||||
def import_adapter
|
||||
@import_adapter ||= Account::ProviderImportAdapter.new(account)
|
||||
end
|
||||
|
||||
def resolve_security(ticker, symbol)
|
||||
BinanceAccount::SecurityResolver.resolve(ticker, symbol)
|
||||
end
|
||||
|
||||
def fetch_price(symbol)
|
||||
return 1.0 if BinanceAccount::STABLECOINS.include?(symbol)
|
||||
|
||||
provider = binance_account.binance_item&.binance_provider
|
||||
return nil unless provider
|
||||
|
||||
%w[USDT BUSD FDUSD].each do |quote|
|
||||
price_str = provider.get_spot_price("#{symbol}#{quote}")
|
||||
return price_str.to_d if price_str.present?
|
||||
end
|
||||
|
||||
Rails.logger.warn "BinanceAccount::HoldingsProcessor - no price found for #{symbol} across all quote pairs; skipping holding"
|
||||
nil
|
||||
end
|
||||
end
|
||||
260
app/models/binance_account/processor.rb
Normal file
260
app/models/binance_account/processor.rb
Normal file
@@ -0,0 +1,260 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Updates account balance and imports spot trades.
|
||||
class BinanceAccount::Processor
|
||||
include BinanceAccount::UsdConverter
|
||||
|
||||
# Quote currencies probed when fetching trade history. Ordered by prevalence so
|
||||
# the most common pairs are tried first and rate-limit weight is front-loaded.
|
||||
TRADE_QUOTE_CURRENCIES = %w[USDT BUSD FDUSD BTC ETH BNB].freeze
|
||||
|
||||
attr_reader :binance_account
|
||||
|
||||
def initialize(binance_account)
|
||||
@binance_account = binance_account
|
||||
end
|
||||
|
||||
def process
|
||||
unless binance_account.current_account.present?
|
||||
Rails.logger.info "BinanceAccount::Processor - no linked account for #{binance_account.id}, skipping"
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
BinanceAccount::HoldingsProcessor.new(binance_account).process
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "BinanceAccount::Processor - holdings failed for #{binance_account.id}: #{e.message}"
|
||||
end
|
||||
|
||||
begin
|
||||
process_account!
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "BinanceAccount::Processor - account update failed for #{binance_account.id}: #{e.message}"
|
||||
raise
|
||||
end
|
||||
|
||||
fetch_and_process_trades
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def target_currency
|
||||
binance_account.binance_item.family.currency
|
||||
end
|
||||
|
||||
def process_account!
|
||||
account = binance_account.current_account
|
||||
raw_usd = (binance_account.current_balance || 0).to_d
|
||||
amount, stale, rate_date = convert_from_usd(raw_usd, date: Date.current)
|
||||
stale_extra = build_stale_extra(stale, rate_date, Date.current)
|
||||
|
||||
account.update!(
|
||||
balance: amount,
|
||||
cash_balance: 0,
|
||||
currency: target_currency
|
||||
)
|
||||
|
||||
binance_account.update!(extra: binance_account.extra.to_h.deep_merge(stale_extra))
|
||||
end
|
||||
|
||||
def fetch_and_process_trades
|
||||
provider = binance_account.binance_item&.binance_provider
|
||||
return unless provider
|
||||
|
||||
symbols = extract_trade_symbols
|
||||
return if symbols.empty?
|
||||
|
||||
existing_spot = binance_account.raw_transactions_payload&.dig("spot") || {}
|
||||
new_trades_by_symbol = {}
|
||||
|
||||
symbols.each do |symbol|
|
||||
TRADE_QUOTE_CURRENCIES.each do |quote|
|
||||
pair = "#{symbol}#{quote}"
|
||||
begin
|
||||
new_trades = fetch_new_trades(provider, pair, existing_spot[pair])
|
||||
new_trades_by_symbol[pair] = new_trades if new_trades.present?
|
||||
rescue Provider::Binance::InvalidSymbolError => e
|
||||
# Pair doesn't exist on Binance for this quote currency — expected, skip silently
|
||||
Rails.logger.debug "BinanceAccount::Processor - skipping #{pair}: #{e.message}"
|
||||
end
|
||||
# ApiError, AuthenticationError and RateLimitError propagate so the sync is marked failed
|
||||
end
|
||||
end
|
||||
|
||||
merged_spot = existing_spot.merge(new_trades_by_symbol) { |_pair, old, new_t| old + new_t }
|
||||
binance_account.update!(raw_transactions_payload: {
|
||||
"spot" => merged_spot,
|
||||
"fetched_at" => Time.current.iso8601
|
||||
})
|
||||
|
||||
process_trades(new_trades_by_symbol)
|
||||
end
|
||||
|
||||
# Fetches only trades newer than what is already cached for the given pair.
|
||||
# On the first sync (no cached trades) fetches the most recent page.
|
||||
# On subsequent syncs starts from max_cached_id + 1 and paginates forward.
|
||||
def fetch_new_trades(provider, pair, cached_trades)
|
||||
limit = 1000
|
||||
max_cached_id = cached_trades&.map { |t| t["id"].to_i }&.max
|
||||
|
||||
from_id = max_cached_id ? max_cached_id + 1 : nil
|
||||
all_new = []
|
||||
|
||||
loop do
|
||||
page = provider.get_spot_trades(pair, limit: limit, from_id: from_id)
|
||||
break if page.blank?
|
||||
|
||||
all_new.concat(page)
|
||||
break if page.size < limit
|
||||
|
||||
from_id = page.map { |t| t["id"].to_i }.max + 1
|
||||
end
|
||||
|
||||
all_new
|
||||
end
|
||||
|
||||
def extract_trade_symbols
|
||||
stablecoins = BinanceAccount::STABLECOINS
|
||||
quote_re = /(#{TRADE_QUOTE_CURRENCIES.join("|")})$/
|
||||
|
||||
# Base symbols from today's asset snapshot
|
||||
assets = binance_account.raw_payload&.dig("assets") || []
|
||||
current = assets.map { |a| a["symbol"] || a[:symbol] }.compact
|
||||
|
||||
# Base symbols from previously fetched pairs (recovers sold-out assets)
|
||||
prev_pairs = binance_account.raw_transactions_payload&.dig("spot")&.keys || []
|
||||
previous = prev_pairs.map { |pair| pair.gsub(quote_re, "") }
|
||||
|
||||
(current + previous).uniq.compact.reject { |s| s.blank? || stablecoins.include?(s) }
|
||||
end
|
||||
|
||||
def process_trades(trades_by_symbol)
|
||||
trades_by_symbol.each do |pair, trades|
|
||||
trades.each { |trade| process_spot_trade(trade, pair) }
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "BinanceAccount::Processor - trade processing failed: #{e.message}"
|
||||
end
|
||||
|
||||
def process_spot_trade(trade, pair)
|
||||
account = binance_account.current_account
|
||||
return unless account
|
||||
|
||||
quote_suffix = TRADE_QUOTE_CURRENCIES.find { |q| pair.end_with?(q) }
|
||||
base_symbol = quote_suffix ? pair.delete_suffix(quote_suffix) : pair
|
||||
return if base_symbol.blank?
|
||||
|
||||
ticker = "CRYPTO:#{base_symbol}"
|
||||
security = BinanceAccount::SecurityResolver.resolve(ticker, base_symbol)
|
||||
|
||||
return unless security
|
||||
|
||||
external_id = "binance_spot_#{pair}_#{trade["id"]}"
|
||||
return if account.entries.exists?(external_id: external_id)
|
||||
|
||||
date = Time.zone.at(trade["time"].to_i / 1000).to_date
|
||||
qty = trade["qty"].to_d
|
||||
price_raw = trade["price"].to_d
|
||||
quote_qty = trade["quoteQty"].to_d
|
||||
|
||||
# quoteQty and price are denominated in the quote currency (e.g. BTC for ETHBTC).
|
||||
# Convert to USD so all entries and cost-basis calculations share a common currency.
|
||||
quote_symbol = quote_suffix || "USDT"
|
||||
amount_usd_raw = quote_to_usd(quote_qty, quote_symbol, date: date)
|
||||
price_usd = quote_to_usd(price_raw, quote_symbol, date: date)
|
||||
|
||||
if amount_usd_raw.nil? || price_usd.nil?
|
||||
Rails.logger.warn "BinanceAccount::Processor - skipping trade #{trade["id"]} for #{pair}: could not convert #{quote_symbol} to USD"
|
||||
return
|
||||
end
|
||||
|
||||
amount_usd = amount_usd_raw.round(2)
|
||||
commission = commission_in_usd(trade, base_symbol, price_usd, date: date)
|
||||
is_buyer = trade["isBuyer"]
|
||||
|
||||
if is_buyer
|
||||
account.entries.create!(
|
||||
date: date,
|
||||
name: "Buy #{qty.round(8)} #{base_symbol}",
|
||||
amount: -amount_usd,
|
||||
currency: "USD",
|
||||
external_id: external_id,
|
||||
source: "binance",
|
||||
entryable: Trade.new(
|
||||
security: security,
|
||||
qty: qty,
|
||||
price: price_usd,
|
||||
currency: "USD",
|
||||
fee: commission,
|
||||
investment_activity_label: "Buy"
|
||||
)
|
||||
)
|
||||
else
|
||||
account.entries.create!(
|
||||
date: date,
|
||||
name: "Sell #{qty.round(8)} #{base_symbol}",
|
||||
amount: amount_usd,
|
||||
currency: "USD",
|
||||
external_id: external_id,
|
||||
source: "binance",
|
||||
entryable: Trade.new(
|
||||
security: security,
|
||||
qty: -qty,
|
||||
price: price_usd,
|
||||
currency: "USD",
|
||||
fee: commission,
|
||||
investment_activity_label: "Sell"
|
||||
)
|
||||
)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "BinanceAccount::Processor - failed to process trade #{trade["id"]}: #{e.message}"
|
||||
end
|
||||
|
||||
# Converts an amount denominated in quote_symbol to USD.
|
||||
# Stablecoins are treated as 1:1; others use historical price when date is given,
|
||||
# falling back to current USDT spot price.
|
||||
def quote_to_usd(amount, quote_symbol, date: nil)
|
||||
return amount if BinanceAccount::STABLECOINS.include?(quote_symbol)
|
||||
|
||||
provider = binance_account.binance_item&.binance_provider
|
||||
return nil unless provider
|
||||
|
||||
spot = nil
|
||||
spot = provider.get_historical_price("#{quote_symbol}USDT", date) if date.present? && provider.respond_to?(:get_historical_price)
|
||||
spot ||= provider.get_spot_price("#{quote_symbol}USDT")
|
||||
return nil if spot.nil?
|
||||
|
||||
(amount * spot.to_d).round(8)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "BinanceAccount::Processor - could not convert #{quote_symbol} to USD: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
# Converts the trade commission to USD.
|
||||
# commissionAsset can be: a stablecoin (≈ 1 USD), the base asset, or something else (e.g. BNB).
|
||||
def commission_in_usd(trade, base_symbol, trade_price, date: nil)
|
||||
raw = trade["commission"].to_d
|
||||
commission_asset = trade["commissionAsset"].to_s.upcase
|
||||
return 0 if raw.zero? || commission_asset.blank?
|
||||
|
||||
stablecoins = BinanceAccount::STABLECOINS
|
||||
return raw if stablecoins.include?(commission_asset)
|
||||
|
||||
# Fee in base asset (e.g. BTC for BTCUSDT) — convert using trade price
|
||||
return (raw * trade_price).round(8) if commission_asset == base_symbol
|
||||
|
||||
# Fee in another asset (typically BNB) — fetch current USDT spot price as approximation
|
||||
provider = binance_account.binance_item&.binance_provider
|
||||
return 0 unless provider
|
||||
|
||||
spot = nil
|
||||
spot = provider.get_historical_price("#{commission_asset}USDT", date) if date.present? && provider.respond_to?(:get_historical_price)
|
||||
spot ||= provider.get_spot_price("#{commission_asset}USDT")
|
||||
|
||||
(raw * spot.to_d).round(8)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "BinanceAccount::Processor - could not convert commission for #{trade["id"]}: #{e.message}"
|
||||
0
|
||||
end
|
||||
end
|
||||
23
app/models/binance_account/security_resolver.rb
Normal file
23
app/models/binance_account/security_resolver.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Resolves or creates a Security for a given Binance ticker.
|
||||
# First attempts Security::Resolver; on failure, falls back to find_or_initialize_by
|
||||
# and saves an offline security so syncs are not blocked by provider outages.
|
||||
class BinanceAccount::SecurityResolver
|
||||
EXCHANGE_MIC = "XBNC"
|
||||
|
||||
def self.resolve(ticker, symbol)
|
||||
result = Security::Resolver.new(ticker).resolve
|
||||
if result.nil?
|
||||
Rails.logger.debug "BinanceAccount::SecurityResolver - primary resolver returned nil for #{ticker}"
|
||||
end
|
||||
result
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "BinanceAccount::SecurityResolver - resolver failed for #{ticker}: #{e.message}"
|
||||
Security.find_or_initialize_by(ticker: ticker, exchange_operating_mic: EXCHANGE_MIC).tap do |sec|
|
||||
sec.name = symbol if sec.name.blank?
|
||||
sec.offline = true unless sec.offline
|
||||
sec.save! if sec.changed?
|
||||
end
|
||||
end
|
||||
end
|
||||
45
app/models/binance_account/usd_converter.rb
Normal file
45
app/models/binance_account/usd_converter.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Shared currency conversion helpers for Binance processors.
|
||||
# Converts USD amounts to the family's configured base currency using
|
||||
# ExchangeRate.find_or_fetch_rate (which has a built-in 5-day nearest-rate lookback).
|
||||
# When a fallback or no rate is used, sets a stale flag in account.extra["binance"].
|
||||
module BinanceAccount::UsdConverter
|
||||
private
|
||||
|
||||
# Converts a USD amount to target_currency on the given date.
|
||||
# @return [Array(BigDecimal, Boolean, Date|nil)]
|
||||
# [converted_amount, stale, rate_date_used]
|
||||
# stale is false when the exact date rate was found, true otherwise.
|
||||
# rate_date_used is nil when exact rate was used or no rate found.
|
||||
def convert_from_usd(amount, date: Date.current)
|
||||
return [ amount, false, nil ] if target_currency == "USD"
|
||||
|
||||
rate = ExchangeRate.find_or_fetch_rate(from: "USD", to: target_currency, date: date)
|
||||
|
||||
if rate.nil?
|
||||
return [ amount.to_d, true, nil ]
|
||||
end
|
||||
|
||||
converted = Money.new(amount, "USD").exchange_to(target_currency, fallback_rate: rate.rate).amount
|
||||
stale = rate.date != date
|
||||
rate_date = stale ? rate.date : nil
|
||||
|
||||
[ converted, stale, rate_date ]
|
||||
end
|
||||
|
||||
# Builds the hash to deep-merge into account.extra.
|
||||
def build_stale_extra(stale, rate_date, target_date)
|
||||
binance_meta = if stale
|
||||
{
|
||||
"stale_rate" => true,
|
||||
"rate_date_used" => rate_date&.to_s,
|
||||
"rate_target_date" => target_date.to_s
|
||||
}
|
||||
else
|
||||
{ "stale_rate" => false }
|
||||
end
|
||||
|
||||
{ "binance" => binance_meta }
|
||||
end
|
||||
end
|
||||
159
app/models/binance_item.rb
Normal file
159
app/models/binance_item.rb
Normal file
@@ -0,0 +1,159 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BinanceItem < ApplicationRecord
|
||||
include Syncable, Provided, Unlinking, Encryptable
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
# 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, dependent: :purge_later
|
||||
|
||||
has_many :binance_accounts, dependent: :destroy
|
||||
has_many :accounts, through: :binance_accounts
|
||||
|
||||
scope :active, -> { where(scheduled_for_deletion: false) }
|
||||
scope :syncable, -> { active }
|
||||
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_binance_data
|
||||
provider = binance_provider
|
||||
unless provider
|
||||
raise StandardError, "Binance credentials not configured"
|
||||
end
|
||||
|
||||
BinanceItem::Importer.new(self, binance_provider: provider).import
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "BinanceItem #{id} - Failed to import: #{e.message}"
|
||||
raise
|
||||
end
|
||||
|
||||
def process_accounts
|
||||
Rails.logger.info "BinanceItem #{id} - process_accounts: total binance_accounts=#{binance_accounts.count}"
|
||||
|
||||
return [] if binance_accounts.empty?
|
||||
|
||||
binance_accounts.each do |ba|
|
||||
Rails.logger.info(
|
||||
"BinanceItem #{id} - binance_account #{ba.id}: " \
|
||||
"name='#{ba.name}' " \
|
||||
"account_provider=#{ba.account_provider&.id || 'nil'} " \
|
||||
"account=#{ba.account&.id || 'nil'}"
|
||||
)
|
||||
end
|
||||
|
||||
linked = binance_accounts.joins(:account).merge(Account.visible)
|
||||
Rails.logger.info "BinanceItem #{id} - found #{linked.count} linked visible accounts to process"
|
||||
|
||||
results = []
|
||||
|
||||
linked.each do |ba|
|
||||
begin
|
||||
Rails.logger.info "BinanceItem #{id} - processing binance_account #{ba.id}"
|
||||
result = BinanceAccount::Processor.new(ba).process
|
||||
results << { binance_account_id: ba.id, success: true, result: result }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "BinanceItem #{id} - Failed to process account #{ba.id}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.first(5).join("\n")
|
||||
results << { binance_account_id: ba.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 StandardError => e
|
||||
Rails.logger.error "BinanceItem #{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_binance_snapshot!(payload)
|
||||
update!(raw_payload: payload)
|
||||
end
|
||||
|
||||
def has_completed_initial_setup?
|
||||
accounts.any?
|
||||
end
|
||||
|
||||
def sync_status_summary
|
||||
total = total_accounts_count
|
||||
linked = linked_accounts_count
|
||||
unlinked = unlinked_accounts_count
|
||||
|
||||
if total == 0
|
||||
I18n.t("binance_items.binance_item.sync_status.no_accounts")
|
||||
elsif unlinked == 0
|
||||
I18n.t("binance_items.binance_item.sync_status.all_synced", count: linked)
|
||||
else
|
||||
I18n.t("binance_items.binance_item.sync_status.partial_sync", linked_count: linked, unlinked_count: unlinked)
|
||||
end
|
||||
end
|
||||
|
||||
def stale_rate_accounts
|
||||
binance_accounts
|
||||
.joins(:account)
|
||||
.where(accounts: { status: "active" })
|
||||
.where("binance_accounts.extra -> 'binance' ->> 'stale_rate' = 'true'")
|
||||
end
|
||||
|
||||
def linked_accounts_count
|
||||
binance_accounts.joins(:account_provider).count
|
||||
end
|
||||
|
||||
def unlinked_accounts_count
|
||||
binance_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count
|
||||
end
|
||||
|
||||
def total_accounts_count
|
||||
binance_accounts.count
|
||||
end
|
||||
|
||||
def institution_display_name
|
||||
institution_name.presence || institution_domain.presence || name
|
||||
end
|
||||
|
||||
def credentials_configured?
|
||||
api_key.present? && api_secret.present?
|
||||
end
|
||||
|
||||
def set_binance_institution_defaults!
|
||||
update!(
|
||||
institution_name: "Binance",
|
||||
institution_domain: "binance.com",
|
||||
institution_url: "https://www.binance.com",
|
||||
institution_color: "#F0B90B"
|
||||
)
|
||||
end
|
||||
end
|
||||
80
app/models/binance_item/earn_importer.rb
Normal file
80
app/models/binance_item/earn_importer.rb
Normal file
@@ -0,0 +1,80 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Fetches Binance Simple Earn (flexible + locked) positions.
|
||||
# Merges both into a single asset list with source tag "earn".
|
||||
class BinanceItem::EarnImporter
|
||||
attr_reader :binance_item, :provider
|
||||
|
||||
def initialize(binance_item, provider:)
|
||||
@binance_item = binance_item
|
||||
@provider = provider
|
||||
end
|
||||
|
||||
def import
|
||||
flexible_raw = fetch_flexible
|
||||
locked_raw = fetch_locked
|
||||
|
||||
assets = merge_earn_assets(
|
||||
parse_flexible(flexible_raw),
|
||||
parse_locked(locked_raw)
|
||||
)
|
||||
|
||||
{
|
||||
assets: assets,
|
||||
raw: { "flexible" => flexible_raw, "locked" => locked_raw },
|
||||
source: "earn"
|
||||
}
|
||||
rescue => e
|
||||
Rails.logger.error "BinanceItem::EarnImporter #{binance_item.id} - #{e.message}"
|
||||
{ assets: [], raw: nil, source: "earn", error: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_flexible
|
||||
provider.get_simple_earn_flexible
|
||||
rescue => e
|
||||
Rails.logger.warn "BinanceItem::EarnImporter #{binance_item.id} - flexible failed: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def fetch_locked
|
||||
provider.get_simple_earn_locked
|
||||
rescue => e
|
||||
Rails.logger.warn "BinanceItem::EarnImporter #{binance_item.id} - locked failed: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def parse_flexible(raw)
|
||||
return {} unless raw.is_a?(Hash)
|
||||
|
||||
(raw["rows"] || []).each_with_object({}) do |row, acc|
|
||||
symbol = row["asset"]
|
||||
amount = row["totalAmount"].to_d
|
||||
acc[symbol] = (acc[symbol] || 0) + amount
|
||||
end
|
||||
end
|
||||
|
||||
def parse_locked(raw)
|
||||
return {} unless raw.is_a?(Hash)
|
||||
|
||||
(raw["rows"] || []).each_with_object({}) do |row, acc|
|
||||
symbol = row["asset"]
|
||||
amount = row["amount"].to_d
|
||||
acc[symbol] = (acc[symbol] || 0) + amount
|
||||
end
|
||||
end
|
||||
|
||||
# Merge two symbol→amount hashes and emit normalized asset list
|
||||
def merge_earn_assets(flexible_totals, locked_totals)
|
||||
all_symbols = (flexible_totals.keys + locked_totals.keys).uniq
|
||||
all_symbols.filter_map do |symbol|
|
||||
flex = flexible_totals[symbol] || BigDecimal("0")
|
||||
lock = locked_totals[symbol] || BigDecimal("0")
|
||||
total = flex + lock
|
||||
next if total.zero?
|
||||
|
||||
{ symbol: symbol, free: flex.to_s("F"), locked: lock.to_s("F"), total: total.to_s("F") }
|
||||
end
|
||||
end
|
||||
end
|
||||
101
app/models/binance_item/importer.rb
Normal file
101
app/models/binance_item/importer.rb
Normal file
@@ -0,0 +1,101 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Orchestrates all Binance sub-importers and upserts a single combined BinanceAccount.
|
||||
class BinanceItem::Importer
|
||||
attr_reader :binance_item, :binance_provider
|
||||
|
||||
def initialize(binance_item, binance_provider:)
|
||||
@binance_item = binance_item
|
||||
@binance_provider = binance_provider
|
||||
end
|
||||
|
||||
def import
|
||||
Rails.logger.info "BinanceItem::Importer #{binance_item.id} - starting import"
|
||||
|
||||
spot_result = BinanceItem::SpotImporter.new(binance_item, provider: binance_provider).import
|
||||
margin_result = BinanceItem::MarginImporter.new(binance_item, provider: binance_provider).import
|
||||
earn_result = BinanceItem::EarnImporter.new(binance_item, provider: binance_provider).import
|
||||
|
||||
all_assets = tagged_assets(spot_result) + tagged_assets(margin_result) + tagged_assets(earn_result)
|
||||
|
||||
return { success: true, assets_imported: 0, total_usd: 0 } if all_assets.empty?
|
||||
|
||||
total_usd = calculate_total_usd(all_assets)
|
||||
|
||||
upsert_binance_account(
|
||||
all_assets: all_assets,
|
||||
total_usd: total_usd,
|
||||
spot_raw: spot_result[:raw],
|
||||
margin_raw: margin_result[:raw],
|
||||
earn_raw: earn_result[:raw]
|
||||
)
|
||||
|
||||
binance_item.upsert_binance_snapshot!({
|
||||
"spot" => spot_result[:raw],
|
||||
"margin" => margin_result[:raw],
|
||||
"earn" => earn_result[:raw],
|
||||
"imported_at" => Time.current.iso8601
|
||||
})
|
||||
|
||||
Rails.logger.info "BinanceItem::Importer #{binance_item.id} - imported #{all_assets.size} assets, total_usd=#{total_usd}"
|
||||
|
||||
{ success: true, assets_imported: all_assets.size, total_usd: total_usd }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def tagged_assets(result)
|
||||
result[:assets].map { |a| a.merge(source: result[:source]) }
|
||||
end
|
||||
|
||||
def calculate_total_usd(assets)
|
||||
assets.sum do |asset|
|
||||
quantity = asset[:total].to_d
|
||||
next 0 if quantity.zero?
|
||||
|
||||
price = price_for(asset[:symbol])
|
||||
quantity * price
|
||||
end.round(2)
|
||||
end
|
||||
|
||||
def price_for(symbol)
|
||||
return 1.0 if BinanceAccount::STABLECOINS.include?(symbol)
|
||||
|
||||
price = binance_provider.get_spot_price("#{symbol}USDT")
|
||||
price.to_d
|
||||
rescue => e
|
||||
Rails.logger.warn "BinanceItem::Importer - could not get price for #{symbol}: #{e.message}"
|
||||
0
|
||||
end
|
||||
|
||||
def upsert_binance_account(all_assets:, total_usd:, spot_raw:, margin_raw:, earn_raw:)
|
||||
ba = binance_item.binance_accounts.find_or_initialize_by(account_type: "combined")
|
||||
|
||||
ba.assign_attributes(
|
||||
name: binance_item.institution_name.presence || "Binance",
|
||||
currency: "USD",
|
||||
current_balance: total_usd,
|
||||
institution_metadata: build_institution_metadata(all_assets),
|
||||
raw_payload: {
|
||||
"spot" => spot_raw,
|
||||
"margin" => margin_raw,
|
||||
"earn" => earn_raw,
|
||||
"assets" => all_assets.map(&:stringify_keys),
|
||||
"fetched_at" => Time.current.iso8601
|
||||
}
|
||||
)
|
||||
|
||||
ba.save!
|
||||
ba
|
||||
end
|
||||
|
||||
def build_institution_metadata(all_assets)
|
||||
%w[spot margin earn].each_with_object({}) do |source, hash|
|
||||
source_assets = all_assets.select { |a| a[:source] == source }
|
||||
hash[source] = {
|
||||
"asset_count" => source_assets.size,
|
||||
"assets" => source_assets.map { |a| a[:symbol] }
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
36
app/models/binance_item/margin_importer.rb
Normal file
36
app/models/binance_item/margin_importer.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Fetches Binance Margin account balances.
|
||||
# Returns normalized asset list with source tag "margin".
|
||||
class BinanceItem::MarginImporter
|
||||
attr_reader :binance_item, :provider
|
||||
|
||||
def initialize(binance_item, provider:)
|
||||
@binance_item = binance_item
|
||||
@provider = provider
|
||||
end
|
||||
|
||||
def import
|
||||
raw = provider.get_margin_account
|
||||
assets = parse_assets(raw["userAssets"] || [])
|
||||
{ assets: assets, raw: raw, source: "margin" }
|
||||
rescue => e
|
||||
Rails.logger.error "BinanceItem::MarginImporter #{binance_item.id} - #{e.message}"
|
||||
{ assets: [], raw: nil, source: "margin", error: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_assets(user_assets)
|
||||
user_assets.filter_map do |a|
|
||||
# Use netAsset (assets minus borrowed) as the meaningful balance
|
||||
net = a["netAsset"].to_d
|
||||
free = a["free"].to_d
|
||||
locked = a["locked"].to_d
|
||||
total = net
|
||||
next if total.zero?
|
||||
|
||||
{ symbol: a["asset"], free: free.to_s("F"), locked: locked.to_s("F"), total: total.to_s("F"), net: net.to_s("F") }
|
||||
end
|
||||
end
|
||||
end
|
||||
9
app/models/binance_item/provided.rb
Normal file
9
app/models/binance_item/provided.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
module BinanceItem::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def binance_provider
|
||||
return nil unless credentials_configured?
|
||||
|
||||
Provider::Binance.new(api_key: api_key, api_secret: api_secret)
|
||||
end
|
||||
end
|
||||
35
app/models/binance_item/spot_importer.rb
Normal file
35
app/models/binance_item/spot_importer.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Fetches Binance Spot wallet balances.
|
||||
# Returns normalized asset list with source tag "spot".
|
||||
class BinanceItem::SpotImporter
|
||||
attr_reader :binance_item, :provider
|
||||
|
||||
def initialize(binance_item, provider:)
|
||||
@binance_item = binance_item
|
||||
@provider = provider
|
||||
end
|
||||
|
||||
# @return [Hash] { assets: [...], raw: <api_response>, source: "spot" }
|
||||
def import
|
||||
raw = provider.get_spot_account
|
||||
assets = parse_assets(raw["balances"] || [])
|
||||
{ assets: assets, raw: raw, source: "spot" }
|
||||
rescue => e
|
||||
Rails.logger.error "BinanceItem::SpotImporter #{binance_item.id} - #{e.message}"
|
||||
{ assets: [], raw: nil, source: "spot", error: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_assets(balances)
|
||||
balances.filter_map do |b|
|
||||
free = b["free"].to_d
|
||||
locked = b["locked"].to_d
|
||||
total = free + locked
|
||||
next if total.zero?
|
||||
|
||||
{ symbol: b["asset"], free: free.to_s("F"), locked: locked.to_s("F"), total: total.to_s("F") }
|
||||
end
|
||||
end
|
||||
end
|
||||
31
app/models/binance_item/sync_complete_event.rb
Normal file
31
app/models/binance_item/sync_complete_event.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Broadcasts Turbo Stream updates when a Binance sync completes.
|
||||
# Updates account views and notifies the family of sync completion.
|
||||
class BinanceItem::SyncCompleteEvent
|
||||
attr_reader :binance_item
|
||||
|
||||
# @param binance_item [BinanceItem] The item that completed syncing
|
||||
def initialize(binance_item)
|
||||
@binance_item = binance_item
|
||||
end
|
||||
|
||||
# Broadcasts sync completion to update UI components.
|
||||
def broadcast
|
||||
# Update UI with latest account data
|
||||
binance_item.accounts.each do |account|
|
||||
account.broadcast_sync_complete
|
||||
end
|
||||
|
||||
# Update the Binance item view
|
||||
binance_item.broadcast_replace_to(
|
||||
binance_item.family,
|
||||
target: "binance_item_#{binance_item.id}",
|
||||
partial: "binance_items/binance_item",
|
||||
locals: { binance_item: binance_item }
|
||||
)
|
||||
|
||||
# Let family handle sync notifications
|
||||
binance_item.family.broadcast_sync_complete
|
||||
end
|
||||
end
|
||||
93
app/models/binance_item/syncer.rb
Normal file
93
app/models/binance_item/syncer.rb
Normal file
@@ -0,0 +1,93 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Orchestrates the sync process for a Binance connection.
|
||||
class BinanceItem::Syncer
|
||||
include SyncStats::Collector
|
||||
|
||||
attr_reader :binance_item
|
||||
|
||||
def initialize(binance_item)
|
||||
@binance_item = binance_item
|
||||
end
|
||||
|
||||
def perform_sync(sync)
|
||||
# Phase 1: Check credentials
|
||||
sync.update!(status_text: I18n.t("binance_item.syncer.checking_credentials")) if sync.respond_to?(:status_text)
|
||||
unless binance_item.credentials_configured?
|
||||
binance_item.update!(status: :requires_update)
|
||||
mark_failed(sync, I18n.t("binance_item.syncer.credentials_invalid"))
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
# Phase 2: Import from Binance APIs
|
||||
sync.update!(status_text: I18n.t("binance_item.syncer.importing_accounts")) if sync.respond_to?(:status_text)
|
||||
binance_item.import_latest_binance_data
|
||||
|
||||
# Clear error status if import succeeds
|
||||
binance_item.update!(status: :good) if binance_item.status == "requires_update"
|
||||
|
||||
# Phase 3: Check setup status
|
||||
sync.update!(status_text: I18n.t("binance_item.syncer.checking_configuration")) if sync.respond_to?(:status_text)
|
||||
collect_setup_stats(sync, provider_accounts: binance_item.binance_accounts.to_a)
|
||||
|
||||
unlinked = binance_item.binance_accounts.left_joins(:account_provider).where(account_providers: { id: nil })
|
||||
linked = binance_item.binance_accounts.joins(:account_provider).joins(:account).merge(Account.visible)
|
||||
|
||||
if unlinked.any?
|
||||
binance_item.update!(pending_account_setup: true)
|
||||
sync.update!(status_text: I18n.t("binance_item.syncer.accounts_need_setup", count: unlinked.count)) if sync.respond_to?(:status_text)
|
||||
else
|
||||
binance_item.update!(pending_account_setup: false)
|
||||
end
|
||||
|
||||
# Phase 4: Process linked accounts
|
||||
if linked.any?
|
||||
sync.update!(status_text: I18n.t("binance_item.syncer.processing_accounts")) if sync.respond_to?(:status_text)
|
||||
binance_item.process_accounts
|
||||
|
||||
# Phase 5: Schedule balance calculations
|
||||
sync.update!(status_text: I18n.t("binance_item.syncer.calculating_balances")) if sync.respond_to?(:status_text)
|
||||
binance_item.schedule_account_syncs(
|
||||
parent_sync: sync,
|
||||
window_start_date: sync.window_start_date,
|
||||
window_end_date: sync.window_end_date
|
||||
)
|
||||
|
||||
account_ids = linked.map { |ba| ba.current_account&.id }.compact
|
||||
if account_ids.any?
|
||||
collect_transaction_stats(sync, account_ids: account_ids, source: "binance")
|
||||
collect_trades_stats(sync, account_ids: account_ids, source: "binance")
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "BinanceItem::Syncer - unexpected error during sync: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
|
||||
mark_failed(sync, e.message)
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
def perform_post_sync
|
||||
# no-op
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mark_failed(sync, error_message)
|
||||
if sync.respond_to?(:status) && sync.status.to_s == "completed"
|
||||
Rails.logger.warn("BinanceItem::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
|
||||
36
app/models/binance_item/unlinking.rb
Normal file
36
app/models/binance_item/unlinking.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module BinanceItem::Unlinking
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def unlink_all!(dry_run: false)
|
||||
results = []
|
||||
|
||||
binance_accounts.find_each do |provider_account|
|
||||
links = AccountProvider.where(provider_type: BinanceAccount.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
|
||||
if link_ids.any?
|
||||
Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil)
|
||||
end
|
||||
links.each(&:destroy!)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("BinanceItem Unlinker: failed to unlink ##{provider_account.id}: #{e.class} - #{e.message}")
|
||||
result[:error] = e.message
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
class Family < ApplicationRecord
|
||||
include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable
|
||||
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable
|
||||
include CoinbaseConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable
|
||||
include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable
|
||||
include IndexaCapitalConnectable
|
||||
|
||||
DATE_FORMATS = [
|
||||
|
||||
27
app/models/family/binance_connectable.rb
Normal file
27
app/models/family/binance_connectable.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Family::BinanceConnectable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :binance_items, dependent: :destroy
|
||||
end
|
||||
|
||||
def can_connect_binance?
|
||||
true
|
||||
end
|
||||
|
||||
def create_binance_item!(api_key:, api_secret:, item_name: nil)
|
||||
item = binance_items.create!(
|
||||
name: item_name || "Binance",
|
||||
api_key: api_key,
|
||||
api_secret: api_secret
|
||||
)
|
||||
item.sync_later
|
||||
item
|
||||
end
|
||||
|
||||
def has_binance_credentials?
|
||||
binance_items.where.not(api_key: nil).exists?
|
||||
end
|
||||
end
|
||||
141
app/models/provider/binance.rb
Normal file
141
app/models/provider/binance.rb
Normal file
@@ -0,0 +1,141 @@
|
||||
class Provider::Binance
|
||||
include HTTParty
|
||||
extend SslConfigurable
|
||||
|
||||
class Error < StandardError; end
|
||||
class AuthenticationError < Error; end
|
||||
class RateLimitError < Error; end
|
||||
class ApiError < Error; end
|
||||
class InvalidSymbolError < ApiError; end
|
||||
|
||||
# Pipelock false positive: This constant and the base_uri below trigger a "Credential in URL"
|
||||
# warning because of the presence of @api_key and @api_secret variables in this file.
|
||||
# Pipelock incorrectly interprets the '@' in Ruby instance variables as a password delimiter
|
||||
# in an URL (e.g. https://user:password@host).
|
||||
SPOT_BASE_URL = "https://api.binance.com".freeze
|
||||
|
||||
base_uri SPOT_BASE_URL
|
||||
default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options))
|
||||
|
||||
attr_reader :api_key, :api_secret
|
||||
|
||||
def initialize(api_key:, api_secret:)
|
||||
@api_key = api_key
|
||||
@api_secret = api_secret
|
||||
end
|
||||
|
||||
# Spot wallet — requires signed request
|
||||
def get_spot_account
|
||||
signed_get("/api/v3/account")
|
||||
end
|
||||
|
||||
# Margin account — requires signed request
|
||||
def get_margin_account
|
||||
signed_get("/sapi/v1/margin/account")
|
||||
end
|
||||
|
||||
# Simple Earn flexible positions — requires signed request
|
||||
def get_simple_earn_flexible
|
||||
signed_get("/sapi/v1/simple-earn/flexible/position")
|
||||
end
|
||||
|
||||
# Simple Earn locked positions — requires signed request
|
||||
def get_simple_earn_locked
|
||||
signed_get("/sapi/v1/simple-earn/locked/position")
|
||||
end
|
||||
|
||||
# Public endpoint — no auth needed
|
||||
# symbol e.g. "BTCUSDT"
|
||||
# Returns price string or nil on failure
|
||||
def get_spot_price(symbol)
|
||||
response = self.class.get("/api/v3/ticker/price", query: { symbol: symbol })
|
||||
data = handle_response(response)
|
||||
data["price"]
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("Provider::Binance: failed to fetch price for #{symbol}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
# Public endpoint — fetch historical kline close price for a date
|
||||
# symbol e.g. "BTCUSDT", date e.g. Date or Time
|
||||
def get_historical_price(symbol, date)
|
||||
timestamp = date.to_time.utc.beginning_of_day.to_i * 1000
|
||||
|
||||
response = self.class.get("/api/v3/klines", query: {
|
||||
symbol: symbol,
|
||||
interval: "1d",
|
||||
startTime: timestamp,
|
||||
limit: 1
|
||||
})
|
||||
|
||||
data = handle_response(response)
|
||||
|
||||
return nil if data.blank? || data.first.blank?
|
||||
|
||||
# Binance klines format: [ Open time, Open, High, Low, Close (index 4), ... ]
|
||||
data.first[4]
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("Provider::Binance: failed to fetch historical price for #{symbol} on #{date}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
# Signed trade history for a single symbol, e.g. "BTCUSDT".
|
||||
# Pass from_id to fetch only trades with id >= from_id (for incremental sync).
|
||||
def get_spot_trades(symbol, limit: 1000, from_id: nil)
|
||||
params = { "symbol" => symbol, "limit" => limit.to_s }
|
||||
params["fromId"] = from_id.to_s if from_id
|
||||
signed_get("/api/v3/myTrades", extra_params: params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def signed_get(path, extra_params: {})
|
||||
params = timestamp_params.merge(extra_params)
|
||||
params["signature"] = sign(params)
|
||||
|
||||
response = self.class.get(
|
||||
path,
|
||||
query: params,
|
||||
headers: auth_headers
|
||||
)
|
||||
|
||||
handle_response(response)
|
||||
end
|
||||
|
||||
def timestamp_params
|
||||
{ "timestamp" => (Time.current.to_f * 1000).to_i.to_s, "recvWindow" => "5000" }
|
||||
end
|
||||
|
||||
# HMAC-SHA256 of the query string
|
||||
def sign(params)
|
||||
query_string = URI.encode_www_form(params.sort)
|
||||
OpenSSL::HMAC.hexdigest("sha256", api_secret, query_string)
|
||||
end
|
||||
|
||||
def auth_headers
|
||||
{ "X-MBX-APIKEY" => api_key }
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
parsed = response.parsed_response
|
||||
|
||||
case response.code
|
||||
when 200..299
|
||||
parsed
|
||||
when 401
|
||||
raise AuthenticationError, extract_error_message(parsed) || "Unauthorized"
|
||||
when 429
|
||||
raise RateLimitError, "Rate limit exceeded"
|
||||
else
|
||||
msg = extract_error_message(parsed) || "API error: #{response.code}"
|
||||
raise InvalidSymbolError, msg if parsed.is_a?(Hash) && parsed["code"] == -1121
|
||||
raise ApiError, msg
|
||||
end
|
||||
end
|
||||
|
||||
def extract_error_message(parsed)
|
||||
return parsed if parsed.is_a?(String)
|
||||
return nil unless parsed.is_a?(Hash)
|
||||
parsed["msg"] || parsed["message"] || parsed["error"]
|
||||
end
|
||||
end
|
||||
102
app/models/provider/binance_adapter.rb
Normal file
102
app/models/provider/binance_adapter.rb
Normal file
@@ -0,0 +1,102 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Provider::BinanceAdapter < Provider::Base
|
||||
include Provider::Syncable
|
||||
include Provider::InstitutionMetadata
|
||||
|
||||
# Register this adapter with the factory
|
||||
Provider::Factory.register("BinanceAccount", 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_binance?
|
||||
|
||||
[ {
|
||||
key: "binance",
|
||||
name: "Binance",
|
||||
description: "Link to a Binance wallet",
|
||||
can_connect: true,
|
||||
new_account_path: ->(accountable_type, return_to) {
|
||||
Rails.application.routes.url_helpers.select_accounts_binance_items_path(
|
||||
accountable_type: accountable_type,
|
||||
return_to: return_to
|
||||
)
|
||||
},
|
||||
existing_account_path: ->(account_id) {
|
||||
Rails.application.routes.url_helpers.select_existing_account_binance_items_path(
|
||||
account_id: account_id
|
||||
)
|
||||
}
|
||||
} ]
|
||||
end
|
||||
|
||||
def provider_name
|
||||
"binance"
|
||||
end
|
||||
|
||||
# Build a Binance provider instance with family-specific credentials
|
||||
# @param family [Family] The family to get credentials for (required)
|
||||
# @return [Provider::Binance, nil] Returns nil if credentials are not configured
|
||||
def self.build_provider(family: nil)
|
||||
return nil unless family.present?
|
||||
|
||||
# Get family-specific credentials
|
||||
binance_item = family.binance_items.where.not(api_key: nil).order(created_at: :desc).first
|
||||
return nil unless binance_item&.credentials_configured?
|
||||
|
||||
Provider::Binance.new(
|
||||
api_key: binance_item.api_key,
|
||||
api_secret: binance_item.api_secret
|
||||
)
|
||||
end
|
||||
|
||||
def sync_path
|
||||
Rails.application.routes.url_helpers.sync_binance_item_path(item)
|
||||
end
|
||||
|
||||
def item
|
||||
provider_account.binance_item
|
||||
end
|
||||
|
||||
def can_delete_holdings?
|
||||
false
|
||||
end
|
||||
|
||||
def institution_domain
|
||||
metadata = provider_account.institution_metadata || {}
|
||||
|
||||
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 Binance account #{provider_account.id}: #{url}")
|
||||
end
|
||||
end
|
||||
|
||||
domain || item&.institution_domain
|
||||
end
|
||||
|
||||
def institution_name
|
||||
metadata = provider_account.institution_metadata || {}
|
||||
metadata["name"] || item&.institution_name
|
||||
end
|
||||
|
||||
def institution_url
|
||||
metadata = provider_account.institution_metadata || {}
|
||||
metadata["url"] || item&.institution_url
|
||||
end
|
||||
|
||||
def institution_color
|
||||
metadata = provider_account.institution_metadata || {}
|
||||
metadata["color"] || item&.institution_color
|
||||
end
|
||||
end
|
||||
132
app/views/binance_items/_binance_item.html.erb
Normal file
132
app/views/binance_items/_binance_item.html.erb
Normal file
@@ -0,0 +1,132 @@
|
||||
<%# locals: (binance_item:, unlinked_count: binance_item.unlinked_accounts_count) %>
|
||||
|
||||
<%= tag.div id: dom_id(binance_item) do %>
|
||||
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||
<summary 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(240, 185, 11, 0.15);">
|
||||
<div class="flex items-center justify-center">
|
||||
<%= icon "coins", size: "sm", class: "text-[#F0B90B]" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pl-1 text-sm flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= tag.p binance_item.institution_display_name, class: "font-medium text-primary" %>
|
||||
<% if binance_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 binance_item.syncing? %>
|
||||
<div class="text-secondary flex items-center gap-1">
|
||||
<%= icon "loader", size: "sm", class: "animate-spin" %>
|
||||
<%= tag.span t(".syncing") %>
|
||||
</div>
|
||||
<% elsif binance_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 binance_item.last_synced_at %>
|
||||
<% if binance_item.sync_status_summary %>
|
||||
<%= t(".status_with_summary", timestamp: time_ago_in_words(binance_item.last_synced_at), summary: binance_item.sync_status_summary) %>
|
||||
<% else %>
|
||||
<%= t(".status", timestamp: time_ago_in_words(binance_item.last_synced_at)) %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= t(".status_never") %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<% if Current.user&.admin? %>
|
||||
<div class="flex items-center justify-end gap-2 mt-2">
|
||||
<% if binance_item.requires_update? %>
|
||||
<%= render DS::Link.new(
|
||||
text: t(".update_credentials"),
|
||||
icon: "refresh-cw",
|
||||
variant: "secondary",
|
||||
href: settings_providers_path,
|
||||
frame: "_top"
|
||||
) %>
|
||||
<% else %>
|
||||
<%= icon(
|
||||
"refresh-cw",
|
||||
as_button: true,
|
||||
href: sync_binance_item_path(binance_item),
|
||||
disabled: binance_item.syncing?
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
<%= render DS::Menu.new do |menu| %>
|
||||
<% if unlinked_count.to_i > 0 %>
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: t(".import_accounts_menu"),
|
||||
icon: "plus",
|
||||
href: setup_accounts_binance_item_path(binance_item),
|
||||
frame: :modal
|
||||
) %>
|
||||
<% end %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".delete"),
|
||||
icon: "trash-2",
|
||||
href: binance_item_path(binance_item),
|
||||
method: :delete,
|
||||
confirm: CustomConfirm.for_resource_deletion(binance_item.institution_display_name, high_severity: true)
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% unless binance_item.scheduled_for_deletion? %>
|
||||
<div class="space-y-4 mt-4">
|
||||
<% if binance_item.accounts.any? %>
|
||||
<%= render "accounts/index/account_groups", accounts: binance_item.accounts %>
|
||||
<% binance_item.stale_rate_accounts.each do |ba| %>
|
||||
<div class="flex items-center gap-2 text-xs text-warning px-1">
|
||||
<span class="font-mono">~</span>
|
||||
<%= icon "triangle-alert", size: "sm" %>
|
||||
<span>
|
||||
<%= t("binance_items.binance_item.stale_rate_warning",
|
||||
date: ba.extra.dig("binance", "rate_target_date")) %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% stats = binance_item.syncs.ordered.first&.sync_stats || {} %>
|
||||
<%= render ProviderSyncSummary.new(
|
||||
stats: stats,
|
||||
provider_item: binance_item
|
||||
) %>
|
||||
|
||||
<% if unlinked_count.to_i > 0 && binance_item.accounts.empty? %>
|
||||
<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_binance_item_path(binance_item),
|
||||
frame: :modal
|
||||
) %>
|
||||
</div>
|
||||
<% elsif binance_item.accounts.empty? && binance_item.binance_accounts.none? %>
|
||||
<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/binance_items/select_existing_account.html.erb
Normal file
43
app/views/binance_items/select_existing_account.html.erb
Normal file
@@ -0,0 +1,43 @@
|
||||
<%# Modal: Link an existing manual account to a Binance account %>
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title")) %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<% if @available_binance_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_binance_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_binance_accounts.each do |ba| %>
|
||||
<label class="flex items-center gap-3 p-2 rounded border border-surface-inset/50 hover:border-primary cursor-pointer">
|
||||
<%= radio_button_tag :binance_account_id, ba.id, false %>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm text-primary font-medium"><%= ba.name.presence || ba.id %></span>
|
||||
<span class="text-xs text-secondary">
|
||||
<%= ba.currency %> • <%= number_with_delimiter(ba.current_balance || 0, delimiter: ",") %>
|
||||
</span>
|
||||
<% if ba.current_account.present? %>
|
||||
<span class="text-xs text-secondary"><%= t(".currently_linked_to", account_name: ba.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/binance_items/setup_accounts.html.erb
Normal file
104
app/views/binance_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 "coins", class: "text-primary" %>
|
||||
<span class="text-primary"><%= t(".subtitle") %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<%= form_with url: complete_account_setup_binance_item_path(@binance_item),
|
||||
method: :post,
|
||||
local: true,
|
||||
id: "binance-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 @binance_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: @binance_accounts.count) %>
|
||||
</span>
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox"
|
||||
id="binance-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">
|
||||
<% @binance_accounts.each do |binance_account| %>
|
||||
<label for="ba_<%= binance_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[]",
|
||||
binance_account.id,
|
||||
false,
|
||||
id: "ba_#{binance_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">
|
||||
<%= binance_account.name %>
|
||||
</p>
|
||||
<p class="text-xs text-secondary">
|
||||
<%= binance_account.currency %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<p class="text-sm font-medium text-primary">
|
||||
<%= number_with_delimiter(binance_account.current_balance || 0, delimiter: ",") %>
|
||||
</p>
|
||||
<p class="text-xs text-secondary">
|
||||
<%= binance_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 %>
|
||||
@@ -29,7 +29,6 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 <%= "2xl:grid-cols-2" if Current.user.dashboard_two_column? %> gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections">
|
||||
<% if accessible_accounts.any? %>
|
||||
<% @dashboard_sections.each do |section| %>
|
||||
|
||||
106
app/views/settings/providers/_binance_panel.html.erb
Normal file
106
app/views/settings/providers/_binance_panel.html.erb
Normal file
@@ -0,0 +1,106 @@
|
||||
<div class="space-y-4">
|
||||
<% items = local_assigns[:binance_items] || @binance_items || Current.family.binance_items.active.ordered %>
|
||||
|
||||
<div class="prose prose-sm text-secondary">
|
||||
<p class="text-primary font-medium"><%= t("settings.providers.binance_panel.setup_instructions") %></p>
|
||||
<ol>
|
||||
<li><%= t("settings.providers.binance_panel.step1_html").html_safe %></li>
|
||||
<li><%= t("settings.providers.binance_panel.step2") %></li>
|
||||
<li><%= t("settings.providers.binance_panel.step3") %></li>
|
||||
</ol>
|
||||
<p class="text-destructive text-xs font-medium"><%= t("settings.providers.binance_panel.no_withdraw_warning") %></p>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface border border-primary p-3 rounded-lg text-sm">
|
||||
<p class="font-medium text-primary"><%= t("settings.providers.binance_panel.ip_hint_title") %></p>
|
||||
<p class="text-secondary mt-1"><%= t("settings.providers.binance_panel.ip_hint_body") %></p>
|
||||
<% server_ip = ENV["BINANCE_EGRESS_IP"].presence %>
|
||||
<% if server_ip %>
|
||||
<code class="mt-1 block text-xs bg-container-inset px-2 py-1 rounded font-mono text-primary"><%= server_ip %></code>
|
||||
<% else %>
|
||||
<p class="mt-1 text-xs text-secondary italic"><%= t("settings.providers.binance_panel.ip_hint_contact_admin") %></p>
|
||||
<% end %>
|
||||
</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="<%= h(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 flex items-center justify-center" style="background-color: rgba(240, 185, 11, 0.15);">
|
||||
<%= icon "coins", size: "md", class: "text-[#F0B90B]" %>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-primary"><%= item.name %></p>
|
||||
<p class="text-xs text-secondary">
|
||||
<% if item.syncing? %>
|
||||
<%= t("settings.providers.binance_panel.syncing") %>
|
||||
<% else %>
|
||||
<%= item.sync_status_summary %>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= button_to sync_binance_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.binance_panel.sync") %>
|
||||
<% end %>
|
||||
<%= button_to binance_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.binance_panel.disconnect_confirm") } do %>
|
||||
<%= icon "trash-2", size: "sm" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%
|
||||
binance_item = Current.family.binance_items.build(name: "Binance")
|
||||
%>
|
||||
|
||||
<%= styled_form_with model: binance_item,
|
||||
url: binance_items_path,
|
||||
scope: :binance_item,
|
||||
method: :post,
|
||||
data: { turbo: true },
|
||||
class: "space-y-3" do |form| %>
|
||||
<%= form.text_field :api_key,
|
||||
label: t("settings.providers.binance_panel.api_key_label"),
|
||||
placeholder: t("settings.providers.binance_panel.api_key_placeholder"),
|
||||
type: :password %>
|
||||
|
||||
<%= form.text_field :api_secret,
|
||||
label: t("settings.providers.binance_panel.api_secret_label"),
|
||||
placeholder: t("settings.providers.binance_panel.api_secret_placeholder"),
|
||||
type: :password %>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<%= form.submit t("settings.providers.binance_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.binance_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.binance_panel.status_not_connected") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,6 +67,12 @@
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Binance (beta)", collapsible: true, open: false do %>
|
||||
<turbo-frame id="binance-providers-panel">
|
||||
<%= render "settings/providers/binance_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %>
|
||||
<turbo-frame id="snaptrade-providers-panel">
|
||||
<%= render "settings/providers/snaptrade_panel" %>
|
||||
|
||||
@@ -36,16 +36,16 @@
|
||||
<% end %>
|
||||
|
||||
<% trade = entry.trade %>
|
||||
|
||||
<% unless trade.security.cash? %>
|
||||
|
||||
<% unless trade.security.cash? %>
|
||||
<div class="mb-2">
|
||||
<%= render DS::Disclosure.new(title: t(".overview"), open: true) do %>
|
||||
<div class="pb-4">
|
||||
<dl class="space-y-3 px-3 py-2">
|
||||
<dl class="space-y-3 px-3 py-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-secondary"><%= t(".symbol_label") %></dt>
|
||||
<dd class="text-primary"><%= trade.security.ticker %></dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if trade.qty.positive? %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
|
||||
75
config/locales/views/binance_items/en.yml
Normal file
75
config/locales/views/binance_items/en.yml
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
en:
|
||||
binance_items:
|
||||
create:
|
||||
default_name: Binance
|
||||
success: Successfully connected to Binance! Your account is being synced.
|
||||
update:
|
||||
success: Successfully updated Binance configuration.
|
||||
destroy:
|
||||
success: Scheduled Binance connection for deletion.
|
||||
setup_accounts:
|
||||
title: Import Binance Account
|
||||
subtitle: Select which portfolios to track
|
||||
instructions: Select the Binance portfolios you want to import. Only portfolios with balances are shown.
|
||||
no_accounts: All accounts have been imported.
|
||||
accounts_count:
|
||||
one: "%{count} account available"
|
||||
other: "%{count} accounts available"
|
||||
select_all: Select all
|
||||
import_selected: Import Selected
|
||||
cancel: Cancel
|
||||
creating: Importing...
|
||||
complete_account_setup:
|
||||
success:
|
||||
one: "Imported %{count} account"
|
||||
other: "Imported %{count} accounts"
|
||||
none_selected: No accounts selected
|
||||
no_accounts: No accounts to import
|
||||
binance_item:
|
||||
provider_name: Binance
|
||||
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 Binance portfolio will appear here after syncing.
|
||||
setup_needed: Account ready to import
|
||||
setup_description: Select which Binance portfolios you want to track.
|
||||
setup_action: Import Account
|
||||
import_accounts_menu: Import Account
|
||||
stale_rate_warning: "Balance is approximate — the exact exchange rate for %{date} was unavailable. Will update on next sync."
|
||||
select_existing_account:
|
||||
title: Link Binance Account
|
||||
no_accounts_found: No Binance accounts found.
|
||||
wait_for_sync: Wait for Binance to finish syncing
|
||||
check_provider_health: Check that your Binance API credentials are valid
|
||||
currently_linked_to: "Currently linked to: %{account_name}"
|
||||
link: Link
|
||||
cancel: Cancel
|
||||
link_existing_account:
|
||||
success: Successfully linked to Binance account
|
||||
errors:
|
||||
only_manual: Only manual accounts can be linked to Binance
|
||||
invalid_binance_account: Invalid Binance account
|
||||
binance_item:
|
||||
syncer:
|
||||
checking_credentials: Checking credentials...
|
||||
credentials_invalid: Invalid API credentials. Please check your API key and secret.
|
||||
importing_accounts: Importing accounts from Binance...
|
||||
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...
|
||||
@@ -189,6 +189,25 @@ en:
|
||||
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.
|
||||
binance_panel:
|
||||
setup_instructions: "To connect Binance, create a read-only API key:"
|
||||
step1_html: 'Go to <a href="https://www.binance.com/en/my/settings/api-management" target="_blank" class="underline">Binance API Management</a>'
|
||||
step2: "Create a new API key with Enable Reading permission only"
|
||||
step3: "Paste your API Key and Secret below"
|
||||
no_withdraw_warning: "Warning: do NOT enable withdrawal permissions"
|
||||
ip_hint_title: "IP Whitelisting Required"
|
||||
ip_hint_body: "Add the app server's egress IP to the Binance API Key whitelist:"
|
||||
ip_hint_contact_admin: "Contact your administrator to obtain the app server's egress IP address."
|
||||
api_key_label: API Key
|
||||
api_key_placeholder: Paste your Binance API Key
|
||||
api_secret_label: API Secret
|
||||
api_secret_placeholder: Paste your Binance API Secret
|
||||
connect_button: Connect Binance
|
||||
syncing: Syncing...
|
||||
sync: Sync
|
||||
disconnect_confirm: "Are you sure you want to disconnect Binance?"
|
||||
status_connected: Binance connected
|
||||
status_not_connected: Binance not connected
|
||||
enable_banking_panel:
|
||||
callback_url_instruction: "For the callback URL, use %{callback_url}."
|
||||
connection_error: Connection Error
|
||||
|
||||
@@ -49,6 +49,21 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resources :binance_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do
|
||||
collection do
|
||||
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
|
||||
|
||||
resources :snaptrade_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do
|
||||
collection do
|
||||
get :preload_accounts
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
class CreateBinanceItemsAndAccounts < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :binance_items, id: :uuid do |t|
|
||||
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||
t.string :name
|
||||
|
||||
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.text :api_key
|
||||
t.text :api_secret
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :binance_items, :status
|
||||
|
||||
create_table :binance_accounts, id: :uuid do |t|
|
||||
t.references :binance_item, null: false, foreign_key: true, type: :uuid
|
||||
|
||||
t.string :name
|
||||
t.string :account_type
|
||||
t.string :currency
|
||||
t.decimal :current_balance, precision: 19, scale: 4
|
||||
|
||||
t.jsonb :institution_metadata
|
||||
t.jsonb :raw_payload
|
||||
t.jsonb :raw_transactions_payload
|
||||
t.jsonb :extra, default: {}, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :binance_accounts, :account_type
|
||||
add_index :binance_accounts, [ :binance_item_id, :account_type ],
|
||||
unique: true,
|
||||
name: "index_binance_accounts_on_item_and_type"
|
||||
end
|
||||
end
|
||||
45
db/schema.rb
generated
45
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_03_28_120000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_03_30_050801) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -40,7 +40,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do
|
||||
t.index ["account_id"], name: "index_account_shares_on_account_id"
|
||||
t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances"
|
||||
t.index ["user_id"], name: "index_account_shares_on_user_id"
|
||||
t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying, 'read_write'::character varying, 'read_only'::character varying]::text[])", name: "chk_account_shares_permission"
|
||||
t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying::text, 'read_write'::character varying::text, 'read_only'::character varying::text])", name: "chk_account_shares_permission"
|
||||
end
|
||||
|
||||
create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
@@ -177,6 +177,43 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do
|
||||
t.index ["account_id"], name: "index_balances_on_account_id"
|
||||
end
|
||||
|
||||
create_table "binance_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "binance_item_id", null: false
|
||||
t.string "name"
|
||||
t.string "account_type"
|
||||
t.string "currency"
|
||||
t.decimal "current_balance", precision: 19, scale: 4
|
||||
t.jsonb "institution_metadata"
|
||||
t.jsonb "raw_payload"
|
||||
t.jsonb "raw_transactions_payload"
|
||||
t.jsonb "extra", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_type"], name: "index_binance_accounts_on_account_type"
|
||||
t.index ["binance_item_id", "account_type"], name: "index_binance_accounts_on_item_and_type", unique: true
|
||||
t.index ["binance_item_id"], name: "index_binance_accounts_on_binance_item_id"
|
||||
end
|
||||
|
||||
create_table "binance_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.string "name"
|
||||
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.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_binance_items_on_family_id"
|
||||
t.index ["status"], name: "index_binance_items_on_status"
|
||||
end
|
||||
|
||||
create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "budget_id", null: false
|
||||
t.uuid "category_id", null: false
|
||||
@@ -537,7 +574,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do
|
||||
t.string "moniker", default: "Family", null: false
|
||||
t.string "assistant_type", default: "builtin", null: false
|
||||
t.string "default_account_sharing", default: "shared", null: false
|
||||
t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying, 'private'::character varying]::text[])", name: "chk_families_default_account_sharing"
|
||||
t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing"
|
||||
t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range"
|
||||
end
|
||||
|
||||
@@ -1536,6 +1573,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "api_keys", "users"
|
||||
add_foreign_key "balances", "accounts", on_delete: :cascade
|
||||
add_foreign_key "binance_accounts", "binance_items"
|
||||
add_foreign_key "binance_items", "families"
|
||||
add_foreign_key "budget_categories", "budgets"
|
||||
add_foreign_key "budget_categories", "categories"
|
||||
add_foreign_key "budgets", "families"
|
||||
|
||||
184
test/controllers/binance_items_controller_test.rb
Normal file
184
test/controllers/binance_items_controller_test.rb
Normal file
@@ -0,0 +1,184 @@
|
||||
require "test_helper"
|
||||
|
||||
class BinanceItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
setup do
|
||||
sign_in users(:family_admin)
|
||||
@family = families(:dylan_family)
|
||||
@binance_item = BinanceItem.create!(
|
||||
family: @family,
|
||||
name: "Test Binance",
|
||||
api_key: "test_key",
|
||||
api_secret: "test_secret"
|
||||
)
|
||||
end
|
||||
|
||||
test "should destroy binance item" do
|
||||
assert_difference("BinanceItem.count", 0) do # doesn't delete immediately
|
||||
delete binance_item_url(@binance_item)
|
||||
end
|
||||
|
||||
assert_redirected_to settings_providers_path
|
||||
@binance_item.reload
|
||||
assert @binance_item.scheduled_for_deletion?
|
||||
end
|
||||
|
||||
test "should sync binance item" do
|
||||
post sync_binance_item_url(@binance_item)
|
||||
assert_response :redirect
|
||||
end
|
||||
|
||||
test "should show setup_accounts page" do
|
||||
get setup_accounts_binance_item_url(@binance_item)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "complete_account_setup creates accounts for selected binance_accounts" do
|
||||
binance_account = @binance_item.binance_accounts.create!(
|
||||
name: "Spot Portfolio",
|
||||
account_type: "spot",
|
||||
currency: "USD",
|
||||
current_balance: 1000.0
|
||||
)
|
||||
|
||||
assert_difference "Account.count", 1 do
|
||||
post complete_account_setup_binance_item_url(@binance_item), params: {
|
||||
selected_accounts: [ binance_account.id ]
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :redirect
|
||||
|
||||
binance_account.reload
|
||||
assert_not_nil binance_account.current_account
|
||||
assert_equal "Crypto", binance_account.current_account.accountable_type
|
||||
end
|
||||
|
||||
test "complete_account_setup with no selection shows message" do
|
||||
@binance_item.binance_accounts.create!(
|
||||
name: "Spot Portfolio",
|
||||
account_type: "spot",
|
||||
currency: "USD",
|
||||
current_balance: 1000.0
|
||||
)
|
||||
|
||||
assert_no_difference "Account.count" do
|
||||
post complete_account_setup_binance_item_url(@binance_item), params: {
|
||||
selected_accounts: []
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :redirect
|
||||
end
|
||||
|
||||
test "complete_account_setup skips already linked accounts" do
|
||||
binance_account = @binance_item.binance_accounts.create!(
|
||||
name: "Spot Portfolio",
|
||||
account_type: "spot",
|
||||
currency: "USD",
|
||||
current_balance: 1000.0
|
||||
)
|
||||
|
||||
# Pre-link the account
|
||||
account = Account.create!(
|
||||
family: @family,
|
||||
name: "Existing Binance",
|
||||
balance: 1000,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: binance_account)
|
||||
|
||||
assert_no_difference "Account.count" do
|
||||
post complete_account_setup_binance_item_url(@binance_item), params: {
|
||||
selected_accounts: [ binance_account.id ]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot access other family's binance_item" do
|
||||
other_family = families(:empty)
|
||||
other_item = BinanceItem.create!(
|
||||
family: other_family,
|
||||
name: "Other Binance",
|
||||
api_key: "other_test_key",
|
||||
api_secret: "other_test_secret"
|
||||
)
|
||||
|
||||
get setup_accounts_binance_item_url(other_item)
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "link_existing_account links manual account to binance_account" do
|
||||
manual_account = Account.create!(
|
||||
family: @family,
|
||||
name: "Manual Crypto",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
|
||||
binance_account = @binance_item.binance_accounts.create!(
|
||||
name: "Spot Portfolio",
|
||||
account_type: "spot",
|
||||
currency: "USD",
|
||||
current_balance: 1000.0
|
||||
)
|
||||
|
||||
assert_difference "AccountProvider.count", 1 do
|
||||
post link_existing_account_binance_items_url, params: {
|
||||
account_id: manual_account.id,
|
||||
binance_account_id: binance_account.id
|
||||
}
|
||||
end
|
||||
|
||||
binance_account.reload
|
||||
assert_equal manual_account, binance_account.current_account
|
||||
end
|
||||
|
||||
test "link_existing_account rejects account with existing provider" do
|
||||
linked_account = Account.create!(
|
||||
family: @family,
|
||||
name: "Already Linked",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
|
||||
other_binance_account = @binance_item.binance_accounts.create!(
|
||||
name: "Other Account",
|
||||
account_type: "margin",
|
||||
currency: "USD",
|
||||
current_balance: 500.0
|
||||
)
|
||||
AccountProvider.create!(account: linked_account, provider: other_binance_account)
|
||||
|
||||
binance_account = @binance_item.binance_accounts.create!(
|
||||
name: "Spot Portfolio",
|
||||
account_type: "spot",
|
||||
currency: "USD",
|
||||
current_balance: 1000.0
|
||||
)
|
||||
|
||||
assert_no_difference "AccountProvider.count" do
|
||||
post link_existing_account_binance_items_url, params: {
|
||||
account_id: linked_account.id,
|
||||
binance_account_id: binance_account.id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
test "select_existing_account renders without layout" do
|
||||
account = Account.create!(
|
||||
family: @family,
|
||||
name: "Manual Account",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
|
||||
get select_existing_account_binance_items_url, params: { account_id: account.id }
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
6
test/fixtures/binance_accounts.yml
vendored
Normal file
6
test/fixtures/binance_accounts.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
one:
|
||||
binance_item: one
|
||||
name: Binance
|
||||
account_type: combined
|
||||
currency: USD
|
||||
current_balance: 15000.00
|
||||
18
test/fixtures/binance_items.yml
vendored
Normal file
18
test/fixtures/binance_items.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
one:
|
||||
family: dylan_family
|
||||
name: My Binance
|
||||
api_key: test_api_key_123
|
||||
api_secret: test_api_secret_456
|
||||
status: good
|
||||
institution_name: Binance
|
||||
institution_domain: binance.com
|
||||
institution_url: https://www.binance.com
|
||||
institution_color: "#F0B90B"
|
||||
|
||||
requires_update:
|
||||
family: dylan_family
|
||||
name: Stale Binance
|
||||
api_key: old_key
|
||||
api_secret: old_secret
|
||||
status: requires_update
|
||||
institution_name: Binance
|
||||
72
test/models/binance_account/holdings_processor_test.rb
Normal file
72
test/models/binance_account/holdings_processor_test.rb
Normal file
@@ -0,0 +1,72 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceAccount::HoldingsProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@family.update!(currency: "EUR")
|
||||
|
||||
@item = BinanceItem.create!(
|
||||
family: @family, name: "Binance", api_key: "k", api_secret: "s"
|
||||
)
|
||||
@ba = @item.binance_accounts.create!(
|
||||
name: "Binance",
|
||||
account_type: "combined",
|
||||
currency: "USD",
|
||||
current_balance: 1000,
|
||||
raw_payload: {
|
||||
"assets" => [ { "symbol" => "BTC", "total" => "0.5", "source" => "spot" } ]
|
||||
}
|
||||
)
|
||||
@account = Account.create!(
|
||||
family: @family,
|
||||
name: "Binance",
|
||||
balance: 0,
|
||||
currency: "EUR",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: @account, provider: @ba)
|
||||
end
|
||||
|
||||
test "converts holding amount to family currency when exact rate exists" do
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
|
||||
date: Date.current, rate: 0.92)
|
||||
|
||||
Security.find_or_create_by!(ticker: "CRYPTO:BTC") do |s|
|
||||
s.name = "BTC"
|
||||
s.exchange_operating_mic = "XBNC"
|
||||
end
|
||||
|
||||
BinanceAccount::HoldingsProcessor.any_instance
|
||||
.stubs(:fetch_price).with("BTC").returns(60_000.0)
|
||||
|
||||
import_adapter = mock
|
||||
import_adapter.expects(:import_holding).with(
|
||||
has_entries(currency: "EUR", amount: 27_600.0)
|
||||
)
|
||||
Account::ProviderImportAdapter.stubs(:new).returns(import_adapter)
|
||||
|
||||
BinanceAccount::HoldingsProcessor.new(@ba).process
|
||||
end
|
||||
|
||||
test "uses raw USD amount when no rate is available" do
|
||||
ExchangeRate.stubs(:find_or_fetch_rate).returns(nil)
|
||||
|
||||
Security.find_or_create_by!(ticker: "CRYPTO:BTC") do |s|
|
||||
s.name = "BTC"
|
||||
s.exchange_operating_mic = "XBNC"
|
||||
end
|
||||
|
||||
BinanceAccount::HoldingsProcessor.any_instance
|
||||
.stubs(:fetch_price).with("BTC").returns(60_000.0)
|
||||
|
||||
import_adapter = mock
|
||||
import_adapter.expects(:import_holding).with(
|
||||
has_entries(currency: "EUR", amount: 30_000.0)
|
||||
)
|
||||
Account::ProviderImportAdapter.stubs(:new).returns(import_adapter)
|
||||
|
||||
BinanceAccount::HoldingsProcessor.new(@ba).process
|
||||
end
|
||||
end
|
||||
89
test/models/binance_account/processor_test.rb
Normal file
89
test/models/binance_account/processor_test.rb
Normal file
@@ -0,0 +1,89 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceAccount::ProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@family.update!(currency: "EUR")
|
||||
|
||||
@item = BinanceItem.create!(
|
||||
family: @family, name: "Binance", api_key: "k", api_secret: "s"
|
||||
)
|
||||
@ba = @item.binance_accounts.create!(
|
||||
name: "Binance", account_type: "combined", currency: "USD", current_balance: 1000
|
||||
)
|
||||
@account = Account.create!(
|
||||
family: @family,
|
||||
name: "Binance",
|
||||
balance: 0,
|
||||
currency: "EUR",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: @account, provider: @ba)
|
||||
|
||||
BinanceAccount::HoldingsProcessor.any_instance.stubs(:process).returns(nil)
|
||||
@ba.stubs(:binance_item).returns(
|
||||
stub(binance_provider: nil, family: @family)
|
||||
)
|
||||
end
|
||||
|
||||
test "converts USD balance to family currency when exact rate exists" do
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
|
||||
date: Date.current, rate: 0.92)
|
||||
|
||||
BinanceAccount::Processor.new(@ba).process
|
||||
|
||||
@account.reload
|
||||
@ba.reload
|
||||
assert_equal "EUR", @account.currency
|
||||
assert_in_delta 920.0, @account.balance, 0.01
|
||||
assert_equal false, @ba.extra.dig("binance", "stale_rate")
|
||||
end
|
||||
|
||||
test "uses nearest rate and sets stale flag when exact rate missing" do
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
|
||||
date: Date.current - 3, rate: 0.90)
|
||||
|
||||
BinanceAccount::Processor.new(@ba).process
|
||||
|
||||
@account.reload
|
||||
@ba.reload
|
||||
assert_equal "EUR", @account.currency
|
||||
assert_in_delta 900.0, @account.balance, 0.01
|
||||
assert_equal true, @ba.extra.dig("binance", "stale_rate")
|
||||
end
|
||||
|
||||
test "falls back to USD amount and sets stale flag when no rate available" do
|
||||
ExchangeRate.expects(:find_or_fetch_rate).returns(nil)
|
||||
|
||||
BinanceAccount::Processor.new(@ba).process
|
||||
|
||||
@account.reload
|
||||
@ba.reload
|
||||
assert_in_delta 1000.0, @account.balance, 0.01
|
||||
assert_equal true, @ba.extra.dig("binance", "stale_rate")
|
||||
end
|
||||
|
||||
test "clears stale flag on subsequent sync when exact rate found" do
|
||||
@ba.update!(extra: { "binance" => { "stale_rate" => true } })
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
|
||||
date: Date.current, rate: 0.92)
|
||||
|
||||
BinanceAccount::Processor.new(@ba).process
|
||||
|
||||
@account.reload
|
||||
@ba.reload
|
||||
assert_equal false, @ba.extra.dig("binance", "stale_rate")
|
||||
end
|
||||
|
||||
test "does not convert when family uses USD" do
|
||||
@family.update!(currency: "USD")
|
||||
|
||||
BinanceAccount::Processor.new(@ba).process
|
||||
|
||||
@account.reload
|
||||
assert_equal "USD", @account.currency
|
||||
assert_in_delta 1000.0, @account.balance, 0.01
|
||||
end
|
||||
end
|
||||
75
test/models/binance_account/usd_converter_test.rb
Normal file
75
test/models/binance_account/usd_converter_test.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceAccount::UsdConverterTest < ActiveSupport::TestCase
|
||||
# A minimal host class that includes the concern so we can test it in isolation
|
||||
class Host
|
||||
include BinanceAccount::UsdConverter
|
||||
|
||||
def initialize(family_currency)
|
||||
@family_currency = family_currency
|
||||
end
|
||||
|
||||
def target_currency
|
||||
@family_currency
|
||||
end
|
||||
end
|
||||
|
||||
test "returns original amount unchanged when target is USD" do
|
||||
host = Host.new("USD")
|
||||
amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: Date.current)
|
||||
assert_equal 1000.0, amount
|
||||
assert_equal false, stale
|
||||
assert_nil rate_date
|
||||
end
|
||||
|
||||
test "returns converted amount when exact rate exists" do
|
||||
date = Date.new(2026, 3, 28)
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: date, rate: 0.92)
|
||||
|
||||
host = Host.new("EUR")
|
||||
amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: date)
|
||||
|
||||
assert_in_delta 920.0, amount, 0.01
|
||||
assert_equal false, stale
|
||||
assert_nil rate_date
|
||||
end
|
||||
|
||||
test "marks stale and returns converted amount when nearest rate used" do
|
||||
old_date = Date.new(2026, 3, 25)
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: old_date, rate: 0.91)
|
||||
|
||||
host = Host.new("EUR")
|
||||
amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: Date.new(2026, 3, 28))
|
||||
|
||||
assert_in_delta 910.0, amount, 0.01
|
||||
assert_equal true, stale
|
||||
assert_equal old_date, rate_date
|
||||
end
|
||||
|
||||
test "returns raw USD amount with stale flag when no rate available" do
|
||||
host = Host.new("EUR")
|
||||
ExchangeRate.expects(:find_or_fetch_rate).returns(nil)
|
||||
|
||||
amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: Date.new(2026, 3, 28))
|
||||
|
||||
assert_equal 1000.0, amount
|
||||
assert_equal true, stale
|
||||
assert_nil rate_date
|
||||
end
|
||||
|
||||
test "build_stale_extra returns correct hash when stale" do
|
||||
host = Host.new("EUR")
|
||||
result = host.send(:build_stale_extra, true, Date.new(2026, 3, 25), Date.new(2026, 3, 28))
|
||||
|
||||
assert_equal({ "binance" => { "stale_rate" => true, "rate_date_used" => "2026-03-25", "rate_target_date" => "2026-03-28" } }, result)
|
||||
end
|
||||
|
||||
test "build_stale_extra returns cleared hash when not stale" do
|
||||
host = Host.new("EUR")
|
||||
result = host.send(:build_stale_extra, false, nil, Date.new(2026, 3, 28))
|
||||
|
||||
assert_equal({ "binance" => { "stale_rate" => false } }, result)
|
||||
end
|
||||
end
|
||||
64
test/models/binance_account_test.rb
Normal file
64
test/models/binance_account_test.rb
Normal file
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceAccountTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@item = binance_items(:one)
|
||||
@ba = binance_accounts(:one)
|
||||
end
|
||||
|
||||
test "belongs to binance_item" do
|
||||
assert_equal @item, @ba.binance_item
|
||||
end
|
||||
|
||||
test "validates presence of name" do
|
||||
ba = @item.binance_accounts.build(account_type: "combined", currency: "USD")
|
||||
assert_not ba.valid?
|
||||
assert_includes ba.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates presence of currency" do
|
||||
ba = @item.binance_accounts.build(name: "Binance", account_type: "combined")
|
||||
assert_not ba.valid?
|
||||
assert_includes ba.errors[:currency], "can't be blank"
|
||||
end
|
||||
|
||||
test "ensure_account_provider! creates AccountProvider" do
|
||||
account = Account.create!(
|
||||
family: @family, name: "Binance", balance: 0, currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
|
||||
@ba.ensure_account_provider!(account)
|
||||
|
||||
ap = AccountProvider.find_by(provider: @ba)
|
||||
assert_not_nil ap
|
||||
assert_equal account, ap.account
|
||||
end
|
||||
|
||||
test "ensure_account_provider! is idempotent" do
|
||||
account = Account.create!(
|
||||
family: @family, name: "Binance", balance: 0, currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
|
||||
@ba.ensure_account_provider!(account)
|
||||
@ba.ensure_account_provider!(account)
|
||||
|
||||
assert_equal 1, AccountProvider.where(provider: @ba).count
|
||||
end
|
||||
|
||||
test "current_account returns linked account" do
|
||||
assert_nil @ba.current_account
|
||||
|
||||
account = Account.create!(
|
||||
family: @family, name: "Binance", balance: 0, currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: @ba)
|
||||
|
||||
assert_equal account, @ba.reload.current_account
|
||||
end
|
||||
end
|
||||
58
test/models/binance_item/earn_importer_test.rb
Normal file
58
test/models/binance_item/earn_importer_test.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceItem::EarnImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@provider = mock
|
||||
@family = families(:dylan_family)
|
||||
@item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s")
|
||||
end
|
||||
|
||||
test "merges flexible and locked positions with source=earn" do
|
||||
@provider.stubs(:get_simple_earn_flexible).returns({
|
||||
"rows" => [ { "asset" => "USDT", "totalAmount" => "500.0" } ]
|
||||
})
|
||||
@provider.stubs(:get_simple_earn_locked).returns({
|
||||
"rows" => [ { "asset" => "BNB", "amount" => "10.0" } ]
|
||||
})
|
||||
|
||||
result = BinanceItem::EarnImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal "earn", result[:source]
|
||||
assert_equal 2, result[:assets].size
|
||||
usdt = result[:assets].find { |a| a[:symbol] == "USDT" }
|
||||
assert_equal "500.0", usdt[:total]
|
||||
assert_equal "500.0", usdt[:free]
|
||||
assert_equal "0.0", usdt[:locked]
|
||||
bnb = result[:assets].find { |a| a[:symbol] == "BNB" }
|
||||
assert_equal "10.0", bnb[:total]
|
||||
assert_equal "0.0", bnb[:free]
|
||||
assert_equal "10.0", bnb[:locked]
|
||||
end
|
||||
|
||||
test "deduplicates assets from flexible and locked by summing" do
|
||||
@provider.stubs(:get_simple_earn_flexible).returns({
|
||||
"rows" => [ { "asset" => "BTC", "totalAmount" => "1.0" } ]
|
||||
})
|
||||
@provider.stubs(:get_simple_earn_locked).returns({
|
||||
"rows" => [ { "asset" => "BTC", "amount" => "0.5" } ]
|
||||
})
|
||||
|
||||
result = BinanceItem::EarnImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal 1, result[:assets].size
|
||||
assert_equal "1.5", result[:assets].first[:total]
|
||||
end
|
||||
|
||||
test "returns empty assets when both APIs fail" do
|
||||
@provider.stubs(:get_simple_earn_flexible).raises(Provider::Binance::ApiError, "error")
|
||||
@provider.stubs(:get_simple_earn_locked).raises(Provider::Binance::ApiError, "error")
|
||||
|
||||
result = BinanceItem::EarnImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal "earn", result[:source]
|
||||
assert_equal [], result[:assets]
|
||||
assert_equal({ "flexible" => nil, "locked" => nil }, result[:raw])
|
||||
end
|
||||
end
|
||||
85
test/models/binance_item/importer_test.rb
Normal file
85
test/models/binance_item/importer_test.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceItem::ImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s")
|
||||
@provider = mock
|
||||
@provider.stubs(:get_spot_price).returns("50000.0")
|
||||
|
||||
stub_spot_result([ { symbol: "BTC", free: "1.0", locked: "0.0", total: "1.0" } ])
|
||||
stub_margin_result([])
|
||||
stub_earn_result([])
|
||||
end
|
||||
|
||||
test "creates a binance_account of type combined" do
|
||||
assert_difference "@item.binance_accounts.count", 1 do
|
||||
BinanceItem::Importer.new(@item, binance_provider: @provider).import
|
||||
end
|
||||
|
||||
ba = @item.binance_accounts.first
|
||||
assert_equal "combined", ba.account_type
|
||||
assert_equal "USD", ba.currency
|
||||
end
|
||||
|
||||
test "calculates combined USD balance" do
|
||||
@provider.stubs(:get_spot_price).with("BTCUSDT").returns("50000.0")
|
||||
|
||||
BinanceItem::Importer.new(@item, binance_provider: @provider).import
|
||||
|
||||
ba = @item.binance_accounts.first
|
||||
assert_in_delta 50000.0, ba.current_balance.to_f, 0.01
|
||||
end
|
||||
|
||||
test "stablecoins counted at 1.0 without API call" do
|
||||
stub_spot_result([ { symbol: "USDT", free: "1000.0", locked: "0.0", total: "1000.0" } ])
|
||||
|
||||
@provider.expects(:get_spot_price).never
|
||||
|
||||
BinanceItem::Importer.new(@item, binance_provider: @provider).import
|
||||
|
||||
ba = @item.binance_accounts.first
|
||||
assert_in_delta 1000.0, ba.current_balance.to_f, 0.01
|
||||
end
|
||||
|
||||
test "skips BinanceAccount creation when all sources empty" do
|
||||
stub_spot_result([])
|
||||
stub_margin_result([])
|
||||
stub_earn_result([])
|
||||
|
||||
assert_no_difference "@item.binance_accounts.count" do
|
||||
BinanceItem::Importer.new(@item, binance_provider: @provider).import
|
||||
end
|
||||
end
|
||||
|
||||
test "stores source breakdown in raw_payload" do
|
||||
BinanceItem::Importer.new(@item, binance_provider: @provider).import
|
||||
|
||||
ba = @item.binance_accounts.first
|
||||
assert ba.raw_payload.key?("spot")
|
||||
assert ba.raw_payload.key?("margin")
|
||||
assert ba.raw_payload.key?("earn")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stub_spot_result(assets)
|
||||
BinanceItem::SpotImporter.any_instance.stubs(:import).returns(
|
||||
{ assets: assets, raw: {}, source: "spot" }
|
||||
)
|
||||
end
|
||||
|
||||
def stub_margin_result(assets)
|
||||
BinanceItem::MarginImporter.any_instance.stubs(:import).returns(
|
||||
{ assets: assets, raw: {}, source: "margin" }
|
||||
)
|
||||
end
|
||||
|
||||
def stub_earn_result(assets)
|
||||
BinanceItem::EarnImporter.any_instance.stubs(:import).returns(
|
||||
{ assets: assets, raw: {}, source: "earn" }
|
||||
)
|
||||
end
|
||||
end
|
||||
37
test/models/binance_item/margin_importer_test.rb
Normal file
37
test/models/binance_item/margin_importer_test.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceItem::MarginImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@provider = mock
|
||||
@family = families(:dylan_family)
|
||||
@item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s")
|
||||
end
|
||||
|
||||
test "returns normalized assets from userAssets with source=margin" do
|
||||
@provider.stubs(:get_margin_account).returns({
|
||||
"userAssets" => [
|
||||
{ "asset" => "BTC", "free" => "0.1", "locked" => "0.0", "netAsset" => "0.1" },
|
||||
{ "asset" => "ETH", "free" => "0.0", "locked" => "0.0", "netAsset" => "0.0" }
|
||||
]
|
||||
})
|
||||
|
||||
result = BinanceItem::MarginImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal "margin", result[:source]
|
||||
assert_equal 1, result[:assets].size
|
||||
btc = result[:assets].first
|
||||
assert_equal "BTC", btc[:symbol]
|
||||
assert_equal "0.1", btc[:total]
|
||||
end
|
||||
|
||||
test "returns empty on API error" do
|
||||
@provider.stubs(:get_margin_account).raises(Provider::Binance::ApiError, "WAF")
|
||||
|
||||
result = BinanceItem::MarginImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal "margin", result[:source]
|
||||
assert_equal [], result[:assets]
|
||||
end
|
||||
end
|
||||
53
test/models/binance_item/spot_importer_test.rb
Normal file
53
test/models/binance_item/spot_importer_test.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceItem::SpotImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@provider = mock
|
||||
@family = families(:dylan_family)
|
||||
@item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s")
|
||||
end
|
||||
|
||||
test "returns normalized assets with source=spot" do
|
||||
@provider.stubs(:get_spot_account).returns({
|
||||
"balances" => [
|
||||
{ "asset" => "BTC", "free" => "1.5", "locked" => "0.5" },
|
||||
{ "asset" => "ETH", "free" => "10.0", "locked" => "0.0" },
|
||||
{ "asset" => "SHIB", "free" => "0.0", "locked" => "0.0" }
|
||||
]
|
||||
})
|
||||
|
||||
result = BinanceItem::SpotImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal "spot", result[:source]
|
||||
assert_equal 2, result[:assets].size # SHIB filtered out (zero balance)
|
||||
btc = result[:assets].find { |a| a[:symbol] == "BTC" }
|
||||
assert_equal "1.5", btc[:free]
|
||||
assert_equal "0.5", btc[:locked]
|
||||
assert_equal "2.0", btc[:total]
|
||||
end
|
||||
|
||||
test "returns empty assets on API error" do
|
||||
@provider.stubs(:get_spot_account).raises(Provider::Binance::AuthenticationError, "Invalid key")
|
||||
|
||||
result = BinanceItem::SpotImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal "spot", result[:source]
|
||||
assert_equal [], result[:assets]
|
||||
assert_nil result[:raw]
|
||||
end
|
||||
|
||||
test "filters out zero-balance assets" do
|
||||
@provider.stubs(:get_spot_account).returns({
|
||||
"balances" => [
|
||||
{ "asset" => "BTC", "free" => "0.0", "locked" => "0.0" },
|
||||
{ "asset" => "ETH", "free" => "0.0", "locked" => "0.0" }
|
||||
]
|
||||
})
|
||||
|
||||
result = BinanceItem::SpotImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal [], result[:assets]
|
||||
end
|
||||
end
|
||||
111
test/models/binance_item_test.rb
Normal file
111
test/models/binance_item_test.rb
Normal file
@@ -0,0 +1,111 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceItemTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@item = BinanceItem.create!(
|
||||
family: @family,
|
||||
name: "My Binance",
|
||||
api_key: "test_key",
|
||||
api_secret: "test_secret"
|
||||
)
|
||||
end
|
||||
|
||||
test "belongs to family" do
|
||||
assert_equal @family, @item.family
|
||||
end
|
||||
|
||||
test "has good status by default" do
|
||||
assert_equal "good", @item.status
|
||||
end
|
||||
|
||||
test "validates presence of name" do
|
||||
item = BinanceItem.new(family: @family, api_key: "k", api_secret: "s")
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates presence of api_key" do
|
||||
item = BinanceItem.new(family: @family, name: "B", api_secret: "s")
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:api_key], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates presence of api_secret" do
|
||||
item = BinanceItem.new(family: @family, name: "B", api_key: "k")
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:api_secret], "can't be blank"
|
||||
end
|
||||
|
||||
test "active scope excludes scheduled for deletion" do
|
||||
@item.update!(scheduled_for_deletion: true)
|
||||
refute_includes BinanceItem.active.to_a, @item
|
||||
end
|
||||
|
||||
test "credentials_configured? returns true when both keys present" do
|
||||
assert @item.credentials_configured?
|
||||
end
|
||||
|
||||
test "credentials_configured? returns false when api_key nil" do
|
||||
@item.api_key = nil
|
||||
refute @item.credentials_configured?
|
||||
end
|
||||
|
||||
test "destroy_later marks for deletion" do
|
||||
@item.destroy_later
|
||||
assert @item.scheduled_for_deletion?
|
||||
end
|
||||
|
||||
test "set_binance_institution_defaults! sets metadata" do
|
||||
@item.set_binance_institution_defaults!
|
||||
assert_equal "Binance", @item.institution_name
|
||||
assert_equal "binance.com", @item.institution_domain
|
||||
assert_equal "https://www.binance.com", @item.institution_url
|
||||
assert_equal "#F0B90B", @item.institution_color
|
||||
end
|
||||
|
||||
test "sync_status_summary with no accounts" do
|
||||
assert_equal I18n.t("binance_items.binance_item.sync_status.no_accounts"), @item.sync_status_summary
|
||||
end
|
||||
|
||||
test "sync_status_summary with all accounts linked" do
|
||||
ba = @item.binance_accounts.create!(name: "Binance Combined", account_type: "combined", currency: "USD")
|
||||
account = Account.create!(
|
||||
family: @family, name: "Binance", balance: 0, currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: ba)
|
||||
|
||||
assert_equal I18n.t("binance_items.binance_item.sync_status.all_synced", count: 1), @item.sync_status_summary
|
||||
end
|
||||
|
||||
test "sync_status_summary with partial sync" do
|
||||
# Linked account
|
||||
ba1 = @item.binance_accounts.create!(name: "Binance Spot", account_type: "spot", currency: "USD")
|
||||
account = Account.create!(
|
||||
family: @family, name: "Binance Spot", balance: 0, currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: ba1)
|
||||
|
||||
# Unlinked account
|
||||
@item.binance_accounts.create!(name: "Binance Earn", account_type: "earn", currency: "USD")
|
||||
|
||||
assert_equal I18n.t("binance_items.binance_item.sync_status.partial_sync", linked_count: 1, unlinked_count: 1), @item.sync_status_summary
|
||||
end
|
||||
|
||||
test "linked_accounts_count returns correct count" do
|
||||
ba = @item.binance_accounts.create!(name: "Binance", account_type: "combined", currency: "USD")
|
||||
assert_equal 0, @item.linked_accounts_count
|
||||
|
||||
account = Account.create!(
|
||||
family: @family, name: "Binance", balance: 0, currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: ba)
|
||||
|
||||
assert_equal 1, @item.linked_accounts_count
|
||||
end
|
||||
end
|
||||
62
test/models/provider/binance_test.rb
Normal file
62
test/models/provider/binance_test.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
require "test_helper"
|
||||
|
||||
class Provider::BinanceTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@provider = Provider::Binance.new(api_key: "test_key", api_secret: "test_secret")
|
||||
end
|
||||
|
||||
test "sign produces HMAC-SHA256 hex digest" do
|
||||
params = { "timestamp" => "1000", "recvWindow" => "5000" }
|
||||
sig = @provider.send(:sign, params)
|
||||
expected = OpenSSL::HMAC.hexdigest("sha256", "test_secret", "recvWindow=5000×tamp=1000")
|
||||
assert_equal expected, sig
|
||||
end
|
||||
|
||||
test "auth_headers include X-MBX-APIKEY" do
|
||||
headers = @provider.send(:auth_headers)
|
||||
assert_equal "test_key", headers["X-MBX-APIKEY"]
|
||||
end
|
||||
|
||||
test "timestamp_params returns hash with timestamp and recvWindow" do
|
||||
params = @provider.send(:timestamp_params)
|
||||
assert params["timestamp"].present?
|
||||
assert_in_delta Time.current.to_i * 1000, params["timestamp"].to_i, 5000
|
||||
assert_equal "5000", params["recvWindow"]
|
||||
end
|
||||
|
||||
test "handle_response raises AuthenticationError on 401" do
|
||||
response = mock_httparty_response(401, { "msg" => "Invalid API-key" })
|
||||
assert_raises(Provider::Binance::AuthenticationError) do
|
||||
@provider.send(:handle_response, response)
|
||||
end
|
||||
end
|
||||
|
||||
test "handle_response raises RateLimitError on 429" do
|
||||
response = mock_httparty_response(429, {})
|
||||
assert_raises(Provider::Binance::RateLimitError) do
|
||||
@provider.send(:handle_response, response)
|
||||
end
|
||||
end
|
||||
|
||||
test "handle_response raises ApiError on other non-2xx" do
|
||||
response = mock_httparty_response(403, { "msg" => "WAF Limit" })
|
||||
assert_raises(Provider::Binance::ApiError) do
|
||||
@provider.send(:handle_response, response)
|
||||
end
|
||||
end
|
||||
|
||||
test "handle_response returns parsed body on 200" do
|
||||
response = mock_httparty_response(200, { "balances" => [] })
|
||||
result = @provider.send(:handle_response, response)
|
||||
assert_equal({ "balances" => [] }, result)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mock_httparty_response(code, body)
|
||||
response = mock
|
||||
response.stubs(:code).returns(code)
|
||||
response.stubs(:parsed_response).returns(body)
|
||||
response
|
||||
end
|
||||
end
|
||||
@@ -137,7 +137,7 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase
|
||||
|
||||
entry = @account.entries.find_by!(external_id: "simplefin_tx_pending_zero_posted_1", source: "simplefin")
|
||||
# For depository accounts, processor prefers posted, then transacted; posted==0 should be treated as missing
|
||||
assert_equal Time.at(t_epoch).to_date, entry.date, "expected entry.date to use transacted_at when posted==0"
|
||||
assert_equal Time.at(t_epoch).utc.to_date, entry.date, "expected entry.date to use transacted_at when posted==0"
|
||||
sf = entry.transaction.extra.fetch("simplefin")
|
||||
assert_equal true, sf["pending"], "expected pending flag to be true when posted==0 and/or pending=true"
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user