diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml index 029eb6f47..deef6acd9 100644 --- a/.github/workflows/pipelock.yml +++ b/.github/workflows/pipelock.yml @@ -28,3 +28,4 @@ jobs: compose.example.ai.yml config/locales/views/reports/ docs/hosting/ai.md + app/models/provider/binance.rb diff --git a/.gitignore b/.gitignore index d2baf4c85..731dd2f9e 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/app/controllers/binance_items_controller.rb b/app/controllers/binance_items_controller.rb new file mode 100644 index 000000000..7f3471e58 --- /dev/null +++ b/app/controllers/binance_items_controller.rb @@ -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 diff --git a/app/models/account.rb b/app/models/account.rb index 2713ad89d..79876c1ec 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/models/binance_account.rb b/app/models/binance_account.rb new file mode 100644 index 000000000..14749894b --- /dev/null +++ b/app/models/binance_account.rb @@ -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 diff --git a/app/models/binance_account/holdings_processor.rb b/app/models/binance_account/holdings_processor.rb new file mode 100644 index 000000000..8eaf0e289 --- /dev/null +++ b/app/models/binance_account/holdings_processor.rb @@ -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 diff --git a/app/models/binance_account/processor.rb b/app/models/binance_account/processor.rb new file mode 100644 index 000000000..540383ec1 --- /dev/null +++ b/app/models/binance_account/processor.rb @@ -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 diff --git a/app/models/binance_account/security_resolver.rb b/app/models/binance_account/security_resolver.rb new file mode 100644 index 000000000..133819c1a --- /dev/null +++ b/app/models/binance_account/security_resolver.rb @@ -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 diff --git a/app/models/binance_account/usd_converter.rb b/app/models/binance_account/usd_converter.rb new file mode 100644 index 000000000..405a94775 --- /dev/null +++ b/app/models/binance_account/usd_converter.rb @@ -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 diff --git a/app/models/binance_item.rb b/app/models/binance_item.rb new file mode 100644 index 000000000..b482cd501 --- /dev/null +++ b/app/models/binance_item.rb @@ -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 diff --git a/app/models/binance_item/earn_importer.rb b/app/models/binance_item/earn_importer.rb new file mode 100644 index 000000000..a6d761ec3 --- /dev/null +++ b/app/models/binance_item/earn_importer.rb @@ -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 diff --git a/app/models/binance_item/importer.rb b/app/models/binance_item/importer.rb new file mode 100644 index 000000000..7d499db70 --- /dev/null +++ b/app/models/binance_item/importer.rb @@ -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 diff --git a/app/models/binance_item/margin_importer.rb b/app/models/binance_item/margin_importer.rb new file mode 100644 index 000000000..3079a6baa --- /dev/null +++ b/app/models/binance_item/margin_importer.rb @@ -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 diff --git a/app/models/binance_item/provided.rb b/app/models/binance_item/provided.rb new file mode 100644 index 000000000..7a5398404 --- /dev/null +++ b/app/models/binance_item/provided.rb @@ -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 diff --git a/app/models/binance_item/spot_importer.rb b/app/models/binance_item/spot_importer.rb new file mode 100644 index 000000000..eb04ab676 --- /dev/null +++ b/app/models/binance_item/spot_importer.rb @@ -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: , 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 diff --git a/app/models/binance_item/sync_complete_event.rb b/app/models/binance_item/sync_complete_event.rb new file mode 100644 index 000000000..d76449a91 --- /dev/null +++ b/app/models/binance_item/sync_complete_event.rb @@ -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 diff --git a/app/models/binance_item/syncer.rb b/app/models/binance_item/syncer.rb new file mode 100644 index 000000000..e98b2fe4d --- /dev/null +++ b/app/models/binance_item/syncer.rb @@ -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 diff --git a/app/models/binance_item/unlinking.rb b/app/models/binance_item/unlinking.rb new file mode 100644 index 000000000..71e855fef --- /dev/null +++ b/app/models/binance_item/unlinking.rb @@ -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 diff --git a/app/models/family.rb b/app/models/family.rb index f5eceb93f..c141ec968 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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 = [ diff --git a/app/models/family/binance_connectable.rb b/app/models/family/binance_connectable.rb new file mode 100644 index 000000000..c72bcf47e --- /dev/null +++ b/app/models/family/binance_connectable.rb @@ -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 diff --git a/app/models/provider/binance.rb b/app/models/provider/binance.rb new file mode 100644 index 000000000..498084882 --- /dev/null +++ b/app/models/provider/binance.rb @@ -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 diff --git a/app/models/provider/binance_adapter.rb b/app/models/provider/binance_adapter.rb new file mode 100644 index 000000000..2d688f7cd --- /dev/null +++ b/app/models/provider/binance_adapter.rb @@ -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 diff --git a/app/views/binance_items/_binance_item.html.erb b/app/views/binance_items/_binance_item.html.erb new file mode 100644 index 000000000..d08e6d34d --- /dev/null +++ b/app/views/binance_items/_binance_item.html.erb @@ -0,0 +1,132 @@ +<%# locals: (binance_item:, unlinked_count: binance_item.unlinked_accounts_count) %> + +<%= tag.div id: dom_id(binance_item) do %> +
+ + <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
+
+ <%= icon "coins", size: "sm", class: "text-[#F0B90B]" %> +
+
+ +
+
+ <%= tag.p binance_item.institution_display_name, class: "font-medium text-primary" %> + <% if binance_item.scheduled_for_deletion? %> +

<%= t(".deletion_in_progress") %>

+ <% end %> +
+

<%= t(".provider_name") %>

+ <% if binance_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif binance_item.requires_update? %> +
+ <%= icon "alert-triangle", size: "sm", color: "warning" %> + <%= tag.span t(".reconnect") %> +
+ <% else %> +

+ <% 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 %> +

+ <% end %> +
+
+ + <% if Current.user&.admin? %> +
+ <% 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 %> +
+ <% end %> + + <% unless binance_item.scheduled_for_deletion? %> +
+ <% if binance_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: binance_item.accounts %> + <% binance_item.stale_rate_accounts.each do |ba| %> +
+ ~ + <%= icon "triangle-alert", size: "sm" %> + + <%= t("binance_items.binance_item.stale_rate_warning", + date: ba.extra.dig("binance", "rate_target_date")) %> + +
+ <% 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? %> +
+

<%= t(".setup_needed") %>

+

<%= t(".setup_description") %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "plus", + variant: "primary", + href: setup_accounts_binance_item_path(binance_item), + frame: :modal + ) %> +
+ <% elsif binance_item.accounts.empty? && binance_item.binance_accounts.none? %> +
+

<%= t(".no_accounts_title") %>

+

<%= t(".no_accounts_message") %>

+
+ <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/binance_items/select_existing_account.html.erb b/app/views/binance_items/select_existing_account.html.erb new file mode 100644 index 000000000..8f12e628a --- /dev/null +++ b/app/views/binance_items/select_existing_account.html.erb @@ -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? %> +
+

<%= t(".no_accounts_found") %>

+ +
+ <% else %> + <%= form_with url: link_existing_account_binance_items_path, method: :post, class: "space-y-4" do %> + <%= hidden_field_tag :account_id, @account.id %> +
+ <% @available_binance_accounts.each do |ba| %> + + <% end %> +
+ +
+ <%= 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" }) %> +
+ <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/binance_items/setup_accounts.html.erb b/app/views/binance_items/setup_accounts.html.erb new file mode 100644 index 000000000..4b7ab0af5 --- /dev/null +++ b/app/views/binance_items/setup_accounts.html.erb @@ -0,0 +1,104 @@ +<% content_for :title, t(".title") %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) do %> +
+ <%= icon "coins", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% 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| %> + +
+
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +
+

+ <%= t(".instructions") %> +

+
+
+
+ + <% if @binance_accounts.empty? %> +
+

<%= t(".no_accounts") %>

+
+ <% else %> +
+
+ + <%= t(".accounts_count", count: @binance_accounts.count) %> + + +
+ +
+ <% @binance_accounts.each do |binance_account| %> + + <% end %> +
+
+ <% end %> +
+ +
+ <%= 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 + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 5df1313ef..bc1022168 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -29,7 +29,6 @@ <% end %> -
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| %> diff --git a/app/views/settings/providers/_binance_panel.html.erb b/app/views/settings/providers/_binance_panel.html.erb new file mode 100644 index 000000000..05378904a --- /dev/null +++ b/app/views/settings/providers/_binance_panel.html.erb @@ -0,0 +1,106 @@ +
+ <% items = local_assigns[:binance_items] || @binance_items || Current.family.binance_items.active.ordered %> + +
+

<%= t("settings.providers.binance_panel.setup_instructions") %>

+
    +
  1. <%= t("settings.providers.binance_panel.step1_html").html_safe %>
  2. +
  3. <%= t("settings.providers.binance_panel.step2") %>
  4. +
  5. <%= t("settings.providers.binance_panel.step3") %>
  6. +
+

<%= t("settings.providers.binance_panel.no_withdraw_warning") %>

+
+ +
+

<%= t("settings.providers.binance_panel.ip_hint_title") %>

+

<%= t("settings.providers.binance_panel.ip_hint_body") %>

+ <% server_ip = ENV["BINANCE_EGRESS_IP"].presence %> + <% if server_ip %> + <%= server_ip %> + <% else %> +

<%= t("settings.providers.binance_panel.ip_hint_contact_admin") %>

+ <% end %> +
+ + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
+

<%= error_msg %>

+
+ <% end %> + + <% if items.any? %> +
+ <% items.each do |item| %> +
+
+
+ <%= icon "coins", size: "md", class: "text-[#F0B90B]" %> +
+
+

<%= item.name %>

+

+ <% if item.syncing? %> + <%= t("settings.providers.binance_panel.syncing") %> + <% else %> + <%= item.sync_status_summary %> + <% end %> +

+
+
+
+ <%= 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 %> +
+
+ <% end %> +
+ <% 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 %> + +
+ <%= 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" %> +
+ <% end %> + <% end %> + +
+ <% if items.any? %> +
+

<%= t("settings.providers.binance_panel.status_connected") %>

+ <% else %> +
+

<%= t("settings.providers.binance_panel.status_not_connected") %>

+ <% end %> +
+
diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 9276e5618..4a9d76bdc 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -67,6 +67,12 @@ <% end %> + <%= settings_section title: "Binance (beta)", collapsible: true, open: false do %> + + <%= render "settings/providers/binance_panel" %> + + <% end %> + <%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %> <%= render "settings/providers/snaptrade_panel" %> diff --git a/app/views/trades/_header.html.erb b/app/views/trades/_header.html.erb index 0f0f6ae94..94e8a2c5a 100644 --- a/app/views/trades/_header.html.erb +++ b/app/views/trades/_header.html.erb @@ -36,16 +36,16 @@ <% end %> <% trade = entry.trade %> - - <% unless trade.security.cash? %> + + <% unless trade.security.cash? %>
<%= render DS::Disclosure.new(title: t(".overview"), open: true) do %>
-
+
<%= t(".symbol_label") %>
<%= trade.security.ticker %>
-
+
<% if trade.qty.positive? %>
diff --git a/config/locales/views/binance_items/en.yml b/config/locales/views/binance_items/en.yml new file mode 100644 index 000000000..ae3bd298a --- /dev/null +++ b/config/locales/views/binance_items/en.yml @@ -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... diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index d590798ca..02eda5228 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -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 Binance API Management' + 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 diff --git a/config/routes.rb b/config/routes.rb index 98895fa3c..8082c1efe 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20260329111830_create_binance_items_and_accounts.rb b/db/migrate/20260329111830_create_binance_items_and_accounts.rb new file mode 100644 index 000000000..949ca46cb --- /dev/null +++ b/db/migrate/20260329111830_create_binance_items_and_accounts.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 90ff71313..3e83968f7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/test/controllers/binance_items_controller_test.rb b/test/controllers/binance_items_controller_test.rb new file mode 100644 index 000000000..91b13d60b --- /dev/null +++ b/test/controllers/binance_items_controller_test.rb @@ -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 diff --git a/test/fixtures/binance_accounts.yml b/test/fixtures/binance_accounts.yml new file mode 100644 index 000000000..84ad80061 --- /dev/null +++ b/test/fixtures/binance_accounts.yml @@ -0,0 +1,6 @@ +one: + binance_item: one + name: Binance + account_type: combined + currency: USD + current_balance: 15000.00 diff --git a/test/fixtures/binance_items.yml b/test/fixtures/binance_items.yml new file mode 100644 index 000000000..d911d9441 --- /dev/null +++ b/test/fixtures/binance_items.yml @@ -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 diff --git a/test/models/binance_account/holdings_processor_test.rb b/test/models/binance_account/holdings_processor_test.rb new file mode 100644 index 000000000..929fccf52 --- /dev/null +++ b/test/models/binance_account/holdings_processor_test.rb @@ -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 diff --git a/test/models/binance_account/processor_test.rb b/test/models/binance_account/processor_test.rb new file mode 100644 index 000000000..9a08ab158 --- /dev/null +++ b/test/models/binance_account/processor_test.rb @@ -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 diff --git a/test/models/binance_account/usd_converter_test.rb b/test/models/binance_account/usd_converter_test.rb new file mode 100644 index 000000000..333056375 --- /dev/null +++ b/test/models/binance_account/usd_converter_test.rb @@ -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 diff --git a/test/models/binance_account_test.rb b/test/models/binance_account_test.rb new file mode 100644 index 000000000..2883c3e8a --- /dev/null +++ b/test/models/binance_account_test.rb @@ -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 diff --git a/test/models/binance_item/earn_importer_test.rb b/test/models/binance_item/earn_importer_test.rb new file mode 100644 index 000000000..c0797ad83 --- /dev/null +++ b/test/models/binance_item/earn_importer_test.rb @@ -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 diff --git a/test/models/binance_item/importer_test.rb b/test/models/binance_item/importer_test.rb new file mode 100644 index 000000000..0e9506155 --- /dev/null +++ b/test/models/binance_item/importer_test.rb @@ -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 diff --git a/test/models/binance_item/margin_importer_test.rb b/test/models/binance_item/margin_importer_test.rb new file mode 100644 index 000000000..58d5e2b83 --- /dev/null +++ b/test/models/binance_item/margin_importer_test.rb @@ -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 diff --git a/test/models/binance_item/spot_importer_test.rb b/test/models/binance_item/spot_importer_test.rb new file mode 100644 index 000000000..47520a9ed --- /dev/null +++ b/test/models/binance_item/spot_importer_test.rb @@ -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 diff --git a/test/models/binance_item_test.rb b/test/models/binance_item_test.rb new file mode 100644 index 000000000..c9a03bf31 --- /dev/null +++ b/test/models/binance_item_test.rb @@ -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 diff --git a/test/models/provider/binance_test.rb b/test/models/provider/binance_test.rb new file mode 100644 index 000000000..3a502db89 --- /dev/null +++ b/test/models/provider/binance_test.rb @@ -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 diff --git a/test/models/simplefin_entry/processor_test.rb b/test/models/simplefin_entry/processor_test.rb index 72a57ee0a..87061d04d 100644 --- a/test/models/simplefin_entry/processor_test.rb +++ b/test/models/simplefin_entry/processor_test.rb @@ -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