Add Binance support, heavily inspired by the Coinbase one (#1317)

* feat: add Binance support (Items, Accounts, Importers, Processor, and Sync)

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

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Louis
2026-04-07 14:43:17 +02:00
committed by GitHub
parent 762bbaec6b
commit 455c74dcfa
48 changed files with 3154 additions and 13 deletions

View File

@@ -28,3 +28,4 @@ jobs:
compose.example.ai.yml compose.example.ai.yml
config/locales/views/reports/ config/locales/views/reports/
docs/hosting/ai.md docs/hosting/ai.md
app/models/provider/binance.rb

11
.gitignore vendored
View File

@@ -4,6 +4,9 @@
# or operating system, you probably want to add a global ignore instead: # or operating system, you probably want to add a global ignore instead:
# git config --global core.excludesfile '~/.gitignore_global' # git config --global core.excludesfile '~/.gitignore_global'
# Git Worktrees
.worktrees/
# Ignore bundler config. # Ignore bundler config.
/.bundle /.bundle
/vendor/bundle /vendor/bundle
@@ -73,6 +76,10 @@ compose.yml
plaid_test_accounts/ plaid_test_accounts/
# Added by Claude
.claude/settings.local.json
docs/superpowers/
# Added by Claude Task Master # Added by Claude Task Master
# Logs # Logs
logs logs
@@ -108,7 +115,6 @@ scripts/
.cursor/rules/dev_workflow.mdc .cursor/rules/dev_workflow.mdc
.cursor/rules/taskmaster.mdc .cursor/rules/taskmaster.mdc
# Auto Claude data directory # Auto Claude data directory
.auto-claude/ .auto-claude/
@@ -116,6 +122,5 @@ scripts/
.auto-claude-security.json .auto-claude-security.json
.auto-claude-status .auto-claude-status
.claude_settings.json .claude_settings.json
.worktrees/
.security-key .security-key
logs/security/ logs/security/

View File

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

View File

@@ -247,6 +247,25 @@ class Account < ApplicationRecord
create_and_sync(attributes, skip_initial_sync: true) create_and_sync(attributes, skip_initial_sync: true)
end 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 private

View File

@@ -0,0 +1,39 @@
# frozen_string_literal: true
class BinanceAccount < ApplicationRecord
include CurrencyNormalizable, Encryptable
STABLECOINS = %w[USDT BUSD FDUSD TUSD USDC DAI].freeze
if encryption_ready?
encrypts :raw_payload
encrypts :raw_transactions_payload
end
belongs_to :binance_item
has_one :account_provider, as: :provider, dependent: :destroy
has_one :account, through: :account_provider, source: :account
has_one :linked_account, through: :account_provider, source: :account
validates :name, :currency, presence: true
def current_account
account
end
def ensure_account_provider!(linked_account = nil)
acct = linked_account || current_account
return nil unless acct
AccountProvider
.find_or_initialize_by(provider_type: "BinanceAccount", provider_id: id)
.tap do |ap|
ap.account = acct
ap.save!
end
rescue StandardError => e
Rails.logger.warn("BinanceAccount #{id}: failed to link account provider — #{e.class}: #{e.message}")
nil
end
end

View File

@@ -0,0 +1,112 @@
# frozen_string_literal: true
# Creates/updates Holdings for each asset in the combined BinanceAccount.
# One Holding per (symbol, source) pair.
class BinanceAccount::HoldingsProcessor
include BinanceAccount::UsdConverter
def initialize(binance_account)
@binance_account = binance_account
end
def process
unless account&.accountable_type == "Crypto"
Rails.logger.info "BinanceAccount::HoldingsProcessor - skipping: not a Crypto account"
return
end
assets = raw_assets
if assets.empty?
Rails.logger.info "BinanceAccount::HoldingsProcessor - no assets in payload"
return
end
assets.each { |asset| process_asset(asset) }
rescue StandardError => e
Rails.logger.error "BinanceAccount::HoldingsProcessor - error: #{e.message}"
nil
end
private
attr_reader :binance_account
def target_currency
binance_account.binance_item.family.currency
end
def account
binance_account.current_account
end
def raw_assets
binance_account.raw_payload&.dig("assets") || []
end
def process_asset(asset)
symbol = asset["symbol"] || asset[:symbol]
return if symbol.blank?
total = (asset["total"] || asset[:total]).to_d
source = asset["source"] || asset[:source]
return if total.zero?
ticker = symbol.include?(":") ? symbol : "CRYPTO:#{symbol}"
security = resolve_security(ticker, symbol)
return unless security
price_usd = fetch_price(symbol)
return if price_usd.nil?
amount_usd = total * price_usd
# Stale rate metadata is intentionally discarded here — it is captured and
# surfaced at the account level by BinanceAccount::Processor#process_account!.
amount, _stale, _rate_date = convert_from_usd(amount_usd, date: Date.current)
# Also convert per-unit price to target currency
price, _, _ = convert_from_usd(price_usd, date: Date.current)
import_adapter.import_holding(
security: security,
quantity: total,
amount: amount,
currency: target_currency,
date: Date.current,
price: price,
cost_basis: nil,
external_id: "binance_#{symbol}_#{source}_#{Date.current}",
account_provider_id: binance_account.account_provider&.id,
source: "binance",
delete_future_holdings: false
)
Rails.logger.info "BinanceAccount::HoldingsProcessor - imported #{total} #{symbol} (#{source}) @ #{price_usd} USD → #{amount} #{target_currency}"
rescue StandardError => e
Rails.logger.error "BinanceAccount::HoldingsProcessor - failed asset #{asset}: #{e.message}"
end
def import_adapter
@import_adapter ||= Account::ProviderImportAdapter.new(account)
end
def resolve_security(ticker, symbol)
BinanceAccount::SecurityResolver.resolve(ticker, symbol)
end
def fetch_price(symbol)
return 1.0 if BinanceAccount::STABLECOINS.include?(symbol)
provider = binance_account.binance_item&.binance_provider
return nil unless provider
%w[USDT BUSD FDUSD].each do |quote|
price_str = provider.get_spot_price("#{symbol}#{quote}")
return price_str.to_d if price_str.present?
end
Rails.logger.warn "BinanceAccount::HoldingsProcessor - no price found for #{symbol} across all quote pairs; skipping holding"
nil
end
end

View File

@@ -0,0 +1,260 @@
# frozen_string_literal: true
# Updates account balance and imports spot trades.
class BinanceAccount::Processor
include BinanceAccount::UsdConverter
# Quote currencies probed when fetching trade history. Ordered by prevalence so
# the most common pairs are tried first and rate-limit weight is front-loaded.
TRADE_QUOTE_CURRENCIES = %w[USDT BUSD FDUSD BTC ETH BNB].freeze
attr_reader :binance_account
def initialize(binance_account)
@binance_account = binance_account
end
def process
unless binance_account.current_account.present?
Rails.logger.info "BinanceAccount::Processor - no linked account for #{binance_account.id}, skipping"
return
end
begin
BinanceAccount::HoldingsProcessor.new(binance_account).process
rescue StandardError => e
Rails.logger.error "BinanceAccount::Processor - holdings failed for #{binance_account.id}: #{e.message}"
end
begin
process_account!
rescue StandardError => e
Rails.logger.error "BinanceAccount::Processor - account update failed for #{binance_account.id}: #{e.message}"
raise
end
fetch_and_process_trades
end
private
def target_currency
binance_account.binance_item.family.currency
end
def process_account!
account = binance_account.current_account
raw_usd = (binance_account.current_balance || 0).to_d
amount, stale, rate_date = convert_from_usd(raw_usd, date: Date.current)
stale_extra = build_stale_extra(stale, rate_date, Date.current)
account.update!(
balance: amount,
cash_balance: 0,
currency: target_currency
)
binance_account.update!(extra: binance_account.extra.to_h.deep_merge(stale_extra))
end
def fetch_and_process_trades
provider = binance_account.binance_item&.binance_provider
return unless provider
symbols = extract_trade_symbols
return if symbols.empty?
existing_spot = binance_account.raw_transactions_payload&.dig("spot") || {}
new_trades_by_symbol = {}
symbols.each do |symbol|
TRADE_QUOTE_CURRENCIES.each do |quote|
pair = "#{symbol}#{quote}"
begin
new_trades = fetch_new_trades(provider, pair, existing_spot[pair])
new_trades_by_symbol[pair] = new_trades if new_trades.present?
rescue Provider::Binance::InvalidSymbolError => e
# Pair doesn't exist on Binance for this quote currency — expected, skip silently
Rails.logger.debug "BinanceAccount::Processor - skipping #{pair}: #{e.message}"
end
# ApiError, AuthenticationError and RateLimitError propagate so the sync is marked failed
end
end
merged_spot = existing_spot.merge(new_trades_by_symbol) { |_pair, old, new_t| old + new_t }
binance_account.update!(raw_transactions_payload: {
"spot" => merged_spot,
"fetched_at" => Time.current.iso8601
})
process_trades(new_trades_by_symbol)
end
# Fetches only trades newer than what is already cached for the given pair.
# On the first sync (no cached trades) fetches the most recent page.
# On subsequent syncs starts from max_cached_id + 1 and paginates forward.
def fetch_new_trades(provider, pair, cached_trades)
limit = 1000
max_cached_id = cached_trades&.map { |t| t["id"].to_i }&.max
from_id = max_cached_id ? max_cached_id + 1 : nil
all_new = []
loop do
page = provider.get_spot_trades(pair, limit: limit, from_id: from_id)
break if page.blank?
all_new.concat(page)
break if page.size < limit
from_id = page.map { |t| t["id"].to_i }.max + 1
end
all_new
end
def extract_trade_symbols
stablecoins = BinanceAccount::STABLECOINS
quote_re = /(#{TRADE_QUOTE_CURRENCIES.join("|")})$/
# Base symbols from today's asset snapshot
assets = binance_account.raw_payload&.dig("assets") || []
current = assets.map { |a| a["symbol"] || a[:symbol] }.compact
# Base symbols from previously fetched pairs (recovers sold-out assets)
prev_pairs = binance_account.raw_transactions_payload&.dig("spot")&.keys || []
previous = prev_pairs.map { |pair| pair.gsub(quote_re, "") }
(current + previous).uniq.compact.reject { |s| s.blank? || stablecoins.include?(s) }
end
def process_trades(trades_by_symbol)
trades_by_symbol.each do |pair, trades|
trades.each { |trade| process_spot_trade(trade, pair) }
end
rescue StandardError => e
Rails.logger.error "BinanceAccount::Processor - trade processing failed: #{e.message}"
end
def process_spot_trade(trade, pair)
account = binance_account.current_account
return unless account
quote_suffix = TRADE_QUOTE_CURRENCIES.find { |q| pair.end_with?(q) }
base_symbol = quote_suffix ? pair.delete_suffix(quote_suffix) : pair
return if base_symbol.blank?
ticker = "CRYPTO:#{base_symbol}"
security = BinanceAccount::SecurityResolver.resolve(ticker, base_symbol)
return unless security
external_id = "binance_spot_#{pair}_#{trade["id"]}"
return if account.entries.exists?(external_id: external_id)
date = Time.zone.at(trade["time"].to_i / 1000).to_date
qty = trade["qty"].to_d
price_raw = trade["price"].to_d
quote_qty = trade["quoteQty"].to_d
# quoteQty and price are denominated in the quote currency (e.g. BTC for ETHBTC).
# Convert to USD so all entries and cost-basis calculations share a common currency.
quote_symbol = quote_suffix || "USDT"
amount_usd_raw = quote_to_usd(quote_qty, quote_symbol, date: date)
price_usd = quote_to_usd(price_raw, quote_symbol, date: date)
if amount_usd_raw.nil? || price_usd.nil?
Rails.logger.warn "BinanceAccount::Processor - skipping trade #{trade["id"]} for #{pair}: could not convert #{quote_symbol} to USD"
return
end
amount_usd = amount_usd_raw.round(2)
commission = commission_in_usd(trade, base_symbol, price_usd, date: date)
is_buyer = trade["isBuyer"]
if is_buyer
account.entries.create!(
date: date,
name: "Buy #{qty.round(8)} #{base_symbol}",
amount: -amount_usd,
currency: "USD",
external_id: external_id,
source: "binance",
entryable: Trade.new(
security: security,
qty: qty,
price: price_usd,
currency: "USD",
fee: commission,
investment_activity_label: "Buy"
)
)
else
account.entries.create!(
date: date,
name: "Sell #{qty.round(8)} #{base_symbol}",
amount: amount_usd,
currency: "USD",
external_id: external_id,
source: "binance",
entryable: Trade.new(
security: security,
qty: -qty,
price: price_usd,
currency: "USD",
fee: commission,
investment_activity_label: "Sell"
)
)
end
rescue StandardError => e
Rails.logger.error "BinanceAccount::Processor - failed to process trade #{trade["id"]}: #{e.message}"
end
# Converts an amount denominated in quote_symbol to USD.
# Stablecoins are treated as 1:1; others use historical price when date is given,
# falling back to current USDT spot price.
def quote_to_usd(amount, quote_symbol, date: nil)
return amount if BinanceAccount::STABLECOINS.include?(quote_symbol)
provider = binance_account.binance_item&.binance_provider
return nil unless provider
spot = nil
spot = provider.get_historical_price("#{quote_symbol}USDT", date) if date.present? && provider.respond_to?(:get_historical_price)
spot ||= provider.get_spot_price("#{quote_symbol}USDT")
return nil if spot.nil?
(amount * spot.to_d).round(8)
rescue StandardError => e
Rails.logger.warn "BinanceAccount::Processor - could not convert #{quote_symbol} to USD: #{e.message}"
nil
end
# Converts the trade commission to USD.
# commissionAsset can be: a stablecoin (≈ 1 USD), the base asset, or something else (e.g. BNB).
def commission_in_usd(trade, base_symbol, trade_price, date: nil)
raw = trade["commission"].to_d
commission_asset = trade["commissionAsset"].to_s.upcase
return 0 if raw.zero? || commission_asset.blank?
stablecoins = BinanceAccount::STABLECOINS
return raw if stablecoins.include?(commission_asset)
# Fee in base asset (e.g. BTC for BTCUSDT) — convert using trade price
return (raw * trade_price).round(8) if commission_asset == base_symbol
# Fee in another asset (typically BNB) — fetch current USDT spot price as approximation
provider = binance_account.binance_item&.binance_provider
return 0 unless provider
spot = nil
spot = provider.get_historical_price("#{commission_asset}USDT", date) if date.present? && provider.respond_to?(:get_historical_price)
spot ||= provider.get_spot_price("#{commission_asset}USDT")
(raw * spot.to_d).round(8)
rescue StandardError => e
Rails.logger.warn "BinanceAccount::Processor - could not convert commission for #{trade["id"]}: #{e.message}"
0
end
end

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
# Resolves or creates a Security for a given Binance ticker.
# First attempts Security::Resolver; on failure, falls back to find_or_initialize_by
# and saves an offline security so syncs are not blocked by provider outages.
class BinanceAccount::SecurityResolver
EXCHANGE_MIC = "XBNC"
def self.resolve(ticker, symbol)
result = Security::Resolver.new(ticker).resolve
if result.nil?
Rails.logger.debug "BinanceAccount::SecurityResolver - primary resolver returned nil for #{ticker}"
end
result
rescue StandardError => e
Rails.logger.warn "BinanceAccount::SecurityResolver - resolver failed for #{ticker}: #{e.message}"
Security.find_or_initialize_by(ticker: ticker, exchange_operating_mic: EXCHANGE_MIC).tap do |sec|
sec.name = symbol if sec.name.blank?
sec.offline = true unless sec.offline
sec.save! if sec.changed?
end
end
end

View File

@@ -0,0 +1,45 @@
# frozen_string_literal: true
# Shared currency conversion helpers for Binance processors.
# Converts USD amounts to the family's configured base currency using
# ExchangeRate.find_or_fetch_rate (which has a built-in 5-day nearest-rate lookback).
# When a fallback or no rate is used, sets a stale flag in account.extra["binance"].
module BinanceAccount::UsdConverter
private
# Converts a USD amount to target_currency on the given date.
# @return [Array(BigDecimal, Boolean, Date|nil)]
# [converted_amount, stale, rate_date_used]
# stale is false when the exact date rate was found, true otherwise.
# rate_date_used is nil when exact rate was used or no rate found.
def convert_from_usd(amount, date: Date.current)
return [ amount, false, nil ] if target_currency == "USD"
rate = ExchangeRate.find_or_fetch_rate(from: "USD", to: target_currency, date: date)
if rate.nil?
return [ amount.to_d, true, nil ]
end
converted = Money.new(amount, "USD").exchange_to(target_currency, fallback_rate: rate.rate).amount
stale = rate.date != date
rate_date = stale ? rate.date : nil
[ converted, stale, rate_date ]
end
# Builds the hash to deep-merge into account.extra.
def build_stale_extra(stale, rate_date, target_date)
binance_meta = if stale
{
"stale_rate" => true,
"rate_date_used" => rate_date&.to_s,
"rate_target_date" => target_date.to_s
}
else
{ "stale_rate" => false }
end
{ "binance" => binance_meta }
end
end

159
app/models/binance_item.rb Normal file
View File

@@ -0,0 +1,159 @@
# frozen_string_literal: true
class BinanceItem < ApplicationRecord
include Syncable, Provided, Unlinking, Encryptable
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
# Encrypt sensitive credentials if ActiveRecord encryption is configured
# api_key uses deterministic encryption for querying, api_secret uses standard encryption
if encryption_ready?
encrypts :api_key, deterministic: true
encrypts :api_secret
end
validates :name, presence: true
validates :api_key, presence: true
validates :api_secret, presence: true
belongs_to :family
has_one_attached :logo, dependent: :purge_later
has_many :binance_accounts, dependent: :destroy
has_many :accounts, through: :binance_accounts
scope :active, -> { where(scheduled_for_deletion: false) }
scope :syncable, -> { active }
scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) }
def destroy_later
update!(scheduled_for_deletion: true)
DestroyJob.perform_later(self)
end
def import_latest_binance_data
provider = binance_provider
unless provider
raise StandardError, "Binance credentials not configured"
end
BinanceItem::Importer.new(self, binance_provider: provider).import
rescue StandardError => e
Rails.logger.error "BinanceItem #{id} - Failed to import: #{e.message}"
raise
end
def process_accounts
Rails.logger.info "BinanceItem #{id} - process_accounts: total binance_accounts=#{binance_accounts.count}"
return [] if binance_accounts.empty?
binance_accounts.each do |ba|
Rails.logger.info(
"BinanceItem #{id} - binance_account #{ba.id}: " \
"name='#{ba.name}' " \
"account_provider=#{ba.account_provider&.id || 'nil'} " \
"account=#{ba.account&.id || 'nil'}"
)
end
linked = binance_accounts.joins(:account).merge(Account.visible)
Rails.logger.info "BinanceItem #{id} - found #{linked.count} linked visible accounts to process"
results = []
linked.each do |ba|
begin
Rails.logger.info "BinanceItem #{id} - processing binance_account #{ba.id}"
result = BinanceAccount::Processor.new(ba).process
results << { binance_account_id: ba.id, success: true, result: result }
rescue StandardError => e
Rails.logger.error "BinanceItem #{id} - Failed to process account #{ba.id}: #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n")
results << { binance_account_id: ba.id, success: false, error: e.message }
end
end
results
end
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
return [] if accounts.empty?
results = []
accounts.visible.each do |account|
begin
account.sync_later(
parent_sync: parent_sync,
window_start_date: window_start_date,
window_end_date: window_end_date
)
results << { account_id: account.id, success: true }
rescue StandardError => e
Rails.logger.error "BinanceItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}"
results << { account_id: account.id, success: false, error: e.message }
end
end
results
end
def upsert_binance_snapshot!(payload)
update!(raw_payload: payload)
end
def has_completed_initial_setup?
accounts.any?
end
def sync_status_summary
total = total_accounts_count
linked = linked_accounts_count
unlinked = unlinked_accounts_count
if total == 0
I18n.t("binance_items.binance_item.sync_status.no_accounts")
elsif unlinked == 0
I18n.t("binance_items.binance_item.sync_status.all_synced", count: linked)
else
I18n.t("binance_items.binance_item.sync_status.partial_sync", linked_count: linked, unlinked_count: unlinked)
end
end
def stale_rate_accounts
binance_accounts
.joins(:account)
.where(accounts: { status: "active" })
.where("binance_accounts.extra -> 'binance' ->> 'stale_rate' = 'true'")
end
def linked_accounts_count
binance_accounts.joins(:account_provider).count
end
def unlinked_accounts_count
binance_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count
end
def total_accounts_count
binance_accounts.count
end
def institution_display_name
institution_name.presence || institution_domain.presence || name
end
def credentials_configured?
api_key.present? && api_secret.present?
end
def set_binance_institution_defaults!
update!(
institution_name: "Binance",
institution_domain: "binance.com",
institution_url: "https://www.binance.com",
institution_color: "#F0B90B"
)
end
end

View File

@@ -0,0 +1,80 @@
# frozen_string_literal: true
# Fetches Binance Simple Earn (flexible + locked) positions.
# Merges both into a single asset list with source tag "earn".
class BinanceItem::EarnImporter
attr_reader :binance_item, :provider
def initialize(binance_item, provider:)
@binance_item = binance_item
@provider = provider
end
def import
flexible_raw = fetch_flexible
locked_raw = fetch_locked
assets = merge_earn_assets(
parse_flexible(flexible_raw),
parse_locked(locked_raw)
)
{
assets: assets,
raw: { "flexible" => flexible_raw, "locked" => locked_raw },
source: "earn"
}
rescue => e
Rails.logger.error "BinanceItem::EarnImporter #{binance_item.id} - #{e.message}"
{ assets: [], raw: nil, source: "earn", error: e.message }
end
private
def fetch_flexible
provider.get_simple_earn_flexible
rescue => e
Rails.logger.warn "BinanceItem::EarnImporter #{binance_item.id} - flexible failed: #{e.message}"
nil
end
def fetch_locked
provider.get_simple_earn_locked
rescue => e
Rails.logger.warn "BinanceItem::EarnImporter #{binance_item.id} - locked failed: #{e.message}"
nil
end
def parse_flexible(raw)
return {} unless raw.is_a?(Hash)
(raw["rows"] || []).each_with_object({}) do |row, acc|
symbol = row["asset"]
amount = row["totalAmount"].to_d
acc[symbol] = (acc[symbol] || 0) + amount
end
end
def parse_locked(raw)
return {} unless raw.is_a?(Hash)
(raw["rows"] || []).each_with_object({}) do |row, acc|
symbol = row["asset"]
amount = row["amount"].to_d
acc[symbol] = (acc[symbol] || 0) + amount
end
end
# Merge two symbol→amount hashes and emit normalized asset list
def merge_earn_assets(flexible_totals, locked_totals)
all_symbols = (flexible_totals.keys + locked_totals.keys).uniq
all_symbols.filter_map do |symbol|
flex = flexible_totals[symbol] || BigDecimal("0")
lock = locked_totals[symbol] || BigDecimal("0")
total = flex + lock
next if total.zero?
{ symbol: symbol, free: flex.to_s("F"), locked: lock.to_s("F"), total: total.to_s("F") }
end
end
end

View File

@@ -0,0 +1,101 @@
# frozen_string_literal: true
# Orchestrates all Binance sub-importers and upserts a single combined BinanceAccount.
class BinanceItem::Importer
attr_reader :binance_item, :binance_provider
def initialize(binance_item, binance_provider:)
@binance_item = binance_item
@binance_provider = binance_provider
end
def import
Rails.logger.info "BinanceItem::Importer #{binance_item.id} - starting import"
spot_result = BinanceItem::SpotImporter.new(binance_item, provider: binance_provider).import
margin_result = BinanceItem::MarginImporter.new(binance_item, provider: binance_provider).import
earn_result = BinanceItem::EarnImporter.new(binance_item, provider: binance_provider).import
all_assets = tagged_assets(spot_result) + tagged_assets(margin_result) + tagged_assets(earn_result)
return { success: true, assets_imported: 0, total_usd: 0 } if all_assets.empty?
total_usd = calculate_total_usd(all_assets)
upsert_binance_account(
all_assets: all_assets,
total_usd: total_usd,
spot_raw: spot_result[:raw],
margin_raw: margin_result[:raw],
earn_raw: earn_result[:raw]
)
binance_item.upsert_binance_snapshot!({
"spot" => spot_result[:raw],
"margin" => margin_result[:raw],
"earn" => earn_result[:raw],
"imported_at" => Time.current.iso8601
})
Rails.logger.info "BinanceItem::Importer #{binance_item.id} - imported #{all_assets.size} assets, total_usd=#{total_usd}"
{ success: true, assets_imported: all_assets.size, total_usd: total_usd }
end
private
def tagged_assets(result)
result[:assets].map { |a| a.merge(source: result[:source]) }
end
def calculate_total_usd(assets)
assets.sum do |asset|
quantity = asset[:total].to_d
next 0 if quantity.zero?
price = price_for(asset[:symbol])
quantity * price
end.round(2)
end
def price_for(symbol)
return 1.0 if BinanceAccount::STABLECOINS.include?(symbol)
price = binance_provider.get_spot_price("#{symbol}USDT")
price.to_d
rescue => e
Rails.logger.warn "BinanceItem::Importer - could not get price for #{symbol}: #{e.message}"
0
end
def upsert_binance_account(all_assets:, total_usd:, spot_raw:, margin_raw:, earn_raw:)
ba = binance_item.binance_accounts.find_or_initialize_by(account_type: "combined")
ba.assign_attributes(
name: binance_item.institution_name.presence || "Binance",
currency: "USD",
current_balance: total_usd,
institution_metadata: build_institution_metadata(all_assets),
raw_payload: {
"spot" => spot_raw,
"margin" => margin_raw,
"earn" => earn_raw,
"assets" => all_assets.map(&:stringify_keys),
"fetched_at" => Time.current.iso8601
}
)
ba.save!
ba
end
def build_institution_metadata(all_assets)
%w[spot margin earn].each_with_object({}) do |source, hash|
source_assets = all_assets.select { |a| a[:source] == source }
hash[source] = {
"asset_count" => source_assets.size,
"assets" => source_assets.map { |a| a[:symbol] }
}
end
end
end

View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true
# Fetches Binance Margin account balances.
# Returns normalized asset list with source tag "margin".
class BinanceItem::MarginImporter
attr_reader :binance_item, :provider
def initialize(binance_item, provider:)
@binance_item = binance_item
@provider = provider
end
def import
raw = provider.get_margin_account
assets = parse_assets(raw["userAssets"] || [])
{ assets: assets, raw: raw, source: "margin" }
rescue => e
Rails.logger.error "BinanceItem::MarginImporter #{binance_item.id} - #{e.message}"
{ assets: [], raw: nil, source: "margin", error: e.message }
end
private
def parse_assets(user_assets)
user_assets.filter_map do |a|
# Use netAsset (assets minus borrowed) as the meaningful balance
net = a["netAsset"].to_d
free = a["free"].to_d
locked = a["locked"].to_d
total = net
next if total.zero?
{ symbol: a["asset"], free: free.to_s("F"), locked: locked.to_s("F"), total: total.to_s("F"), net: net.to_s("F") }
end
end
end

View File

@@ -0,0 +1,9 @@
module BinanceItem::Provided
extend ActiveSupport::Concern
def binance_provider
return nil unless credentials_configured?
Provider::Binance.new(api_key: api_key, api_secret: api_secret)
end
end

View File

@@ -0,0 +1,35 @@
# frozen_string_literal: true
# Fetches Binance Spot wallet balances.
# Returns normalized asset list with source tag "spot".
class BinanceItem::SpotImporter
attr_reader :binance_item, :provider
def initialize(binance_item, provider:)
@binance_item = binance_item
@provider = provider
end
# @return [Hash] { assets: [...], raw: <api_response>, source: "spot" }
def import
raw = provider.get_spot_account
assets = parse_assets(raw["balances"] || [])
{ assets: assets, raw: raw, source: "spot" }
rescue => e
Rails.logger.error "BinanceItem::SpotImporter #{binance_item.id} - #{e.message}"
{ assets: [], raw: nil, source: "spot", error: e.message }
end
private
def parse_assets(balances)
balances.filter_map do |b|
free = b["free"].to_d
locked = b["locked"].to_d
total = free + locked
next if total.zero?
{ symbol: b["asset"], free: free.to_s("F"), locked: locked.to_s("F"), total: total.to_s("F") }
end
end
end

View File

@@ -0,0 +1,31 @@
# frozen_string_literal: true
# Broadcasts Turbo Stream updates when a Binance sync completes.
# Updates account views and notifies the family of sync completion.
class BinanceItem::SyncCompleteEvent
attr_reader :binance_item
# @param binance_item [BinanceItem] The item that completed syncing
def initialize(binance_item)
@binance_item = binance_item
end
# Broadcasts sync completion to update UI components.
def broadcast
# Update UI with latest account data
binance_item.accounts.each do |account|
account.broadcast_sync_complete
end
# Update the Binance item view
binance_item.broadcast_replace_to(
binance_item.family,
target: "binance_item_#{binance_item.id}",
partial: "binance_items/binance_item",
locals: { binance_item: binance_item }
)
# Let family handle sync notifications
binance_item.family.broadcast_sync_complete
end
end

View File

@@ -0,0 +1,93 @@
# frozen_string_literal: true
# Orchestrates the sync process for a Binance connection.
class BinanceItem::Syncer
include SyncStats::Collector
attr_reader :binance_item
def initialize(binance_item)
@binance_item = binance_item
end
def perform_sync(sync)
# Phase 1: Check credentials
sync.update!(status_text: I18n.t("binance_item.syncer.checking_credentials")) if sync.respond_to?(:status_text)
unless binance_item.credentials_configured?
binance_item.update!(status: :requires_update)
mark_failed(sync, I18n.t("binance_item.syncer.credentials_invalid"))
return
end
begin
# Phase 2: Import from Binance APIs
sync.update!(status_text: I18n.t("binance_item.syncer.importing_accounts")) if sync.respond_to?(:status_text)
binance_item.import_latest_binance_data
# Clear error status if import succeeds
binance_item.update!(status: :good) if binance_item.status == "requires_update"
# Phase 3: Check setup status
sync.update!(status_text: I18n.t("binance_item.syncer.checking_configuration")) if sync.respond_to?(:status_text)
collect_setup_stats(sync, provider_accounts: binance_item.binance_accounts.to_a)
unlinked = binance_item.binance_accounts.left_joins(:account_provider).where(account_providers: { id: nil })
linked = binance_item.binance_accounts.joins(:account_provider).joins(:account).merge(Account.visible)
if unlinked.any?
binance_item.update!(pending_account_setup: true)
sync.update!(status_text: I18n.t("binance_item.syncer.accounts_need_setup", count: unlinked.count)) if sync.respond_to?(:status_text)
else
binance_item.update!(pending_account_setup: false)
end
# Phase 4: Process linked accounts
if linked.any?
sync.update!(status_text: I18n.t("binance_item.syncer.processing_accounts")) if sync.respond_to?(:status_text)
binance_item.process_accounts
# Phase 5: Schedule balance calculations
sync.update!(status_text: I18n.t("binance_item.syncer.calculating_balances")) if sync.respond_to?(:status_text)
binance_item.schedule_account_syncs(
parent_sync: sync,
window_start_date: sync.window_start_date,
window_end_date: sync.window_end_date
)
account_ids = linked.map { |ba| ba.current_account&.id }.compact
if account_ids.any?
collect_transaction_stats(sync, account_ids: account_ids, source: "binance")
collect_trades_stats(sync, account_ids: account_ids, source: "binance")
end
end
rescue StandardError => e
Rails.logger.error "BinanceItem::Syncer - unexpected error during sync: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
mark_failed(sync, e.message)
raise
end
end
def perform_post_sync
# no-op
end
private
def mark_failed(sync, error_message)
if sync.respond_to?(:status) && sync.status.to_s == "completed"
Rails.logger.warn("BinanceItem::Syncer#mark_failed called after completion: #{error_message}")
return
end
sync.start! if sync.respond_to?(:may_start?) && sync.may_start?
if sync.respond_to?(:may_fail?) && sync.may_fail?
sync.fail!
elsif sync.respond_to?(:status)
sync.update!(status: :failed)
end
sync.update!(error: error_message) if sync.respond_to?(:error)
sync.update!(status_text: error_message) if sync.respond_to?(:status_text)
end
end

View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true
module BinanceItem::Unlinking
extend ActiveSupport::Concern
def unlink_all!(dry_run: false)
results = []
binance_accounts.find_each do |provider_account|
links = AccountProvider.where(provider_type: BinanceAccount.name, provider_id: provider_account.id).to_a
link_ids = links.map(&:id)
result = {
provider_account_id: provider_account.id,
name: provider_account.name,
provider_link_ids: link_ids
}
results << result
next if dry_run
begin
ActiveRecord::Base.transaction do
if link_ids.any?
Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil)
end
links.each(&:destroy!)
end
rescue StandardError => e
Rails.logger.warn("BinanceItem Unlinker: failed to unlink ##{provider_account.id}: #{e.class} - #{e.message}")
result[:error] = e.message
end
end
results
end
end

View File

@@ -1,7 +1,7 @@
class Family < ApplicationRecord class Family < ApplicationRecord
include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable
include CoinbaseConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable
include IndexaCapitalConnectable include IndexaCapitalConnectable
DATE_FORMATS = [ DATE_FORMATS = [

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
module Family::BinanceConnectable
extend ActiveSupport::Concern
included do
has_many :binance_items, dependent: :destroy
end
def can_connect_binance?
true
end
def create_binance_item!(api_key:, api_secret:, item_name: nil)
item = binance_items.create!(
name: item_name || "Binance",
api_key: api_key,
api_secret: api_secret
)
item.sync_later
item
end
def has_binance_credentials?
binance_items.where.not(api_key: nil).exists?
end
end

View File

@@ -0,0 +1,141 @@
class Provider::Binance
include HTTParty
extend SslConfigurable
class Error < StandardError; end
class AuthenticationError < Error; end
class RateLimitError < Error; end
class ApiError < Error; end
class InvalidSymbolError < ApiError; end
# Pipelock false positive: This constant and the base_uri below trigger a "Credential in URL"
# warning because of the presence of @api_key and @api_secret variables in this file.
# Pipelock incorrectly interprets the '@' in Ruby instance variables as a password delimiter
# in an URL (e.g. https://user:password@host).
SPOT_BASE_URL = "https://api.binance.com".freeze
base_uri SPOT_BASE_URL
default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options))
attr_reader :api_key, :api_secret
def initialize(api_key:, api_secret:)
@api_key = api_key
@api_secret = api_secret
end
# Spot wallet — requires signed request
def get_spot_account
signed_get("/api/v3/account")
end
# Margin account — requires signed request
def get_margin_account
signed_get("/sapi/v1/margin/account")
end
# Simple Earn flexible positions — requires signed request
def get_simple_earn_flexible
signed_get("/sapi/v1/simple-earn/flexible/position")
end
# Simple Earn locked positions — requires signed request
def get_simple_earn_locked
signed_get("/sapi/v1/simple-earn/locked/position")
end
# Public endpoint — no auth needed
# symbol e.g. "BTCUSDT"
# Returns price string or nil on failure
def get_spot_price(symbol)
response = self.class.get("/api/v3/ticker/price", query: { symbol: symbol })
data = handle_response(response)
data["price"]
rescue StandardError => e
Rails.logger.warn("Provider::Binance: failed to fetch price for #{symbol}: #{e.message}")
nil
end
# Public endpoint — fetch historical kline close price for a date
# symbol e.g. "BTCUSDT", date e.g. Date or Time
def get_historical_price(symbol, date)
timestamp = date.to_time.utc.beginning_of_day.to_i * 1000
response = self.class.get("/api/v3/klines", query: {
symbol: symbol,
interval: "1d",
startTime: timestamp,
limit: 1
})
data = handle_response(response)
return nil if data.blank? || data.first.blank?
# Binance klines format: [ Open time, Open, High, Low, Close (index 4), ... ]
data.first[4]
rescue StandardError => e
Rails.logger.warn("Provider::Binance: failed to fetch historical price for #{symbol} on #{date}: #{e.message}")
nil
end
# Signed trade history for a single symbol, e.g. "BTCUSDT".
# Pass from_id to fetch only trades with id >= from_id (for incremental sync).
def get_spot_trades(symbol, limit: 1000, from_id: nil)
params = { "symbol" => symbol, "limit" => limit.to_s }
params["fromId"] = from_id.to_s if from_id
signed_get("/api/v3/myTrades", extra_params: params)
end
private
def signed_get(path, extra_params: {})
params = timestamp_params.merge(extra_params)
params["signature"] = sign(params)
response = self.class.get(
path,
query: params,
headers: auth_headers
)
handle_response(response)
end
def timestamp_params
{ "timestamp" => (Time.current.to_f * 1000).to_i.to_s, "recvWindow" => "5000" }
end
# HMAC-SHA256 of the query string
def sign(params)
query_string = URI.encode_www_form(params.sort)
OpenSSL::HMAC.hexdigest("sha256", api_secret, query_string)
end
def auth_headers
{ "X-MBX-APIKEY" => api_key }
end
def handle_response(response)
parsed = response.parsed_response
case response.code
when 200..299
parsed
when 401
raise AuthenticationError, extract_error_message(parsed) || "Unauthorized"
when 429
raise RateLimitError, "Rate limit exceeded"
else
msg = extract_error_message(parsed) || "API error: #{response.code}"
raise InvalidSymbolError, msg if parsed.is_a?(Hash) && parsed["code"] == -1121
raise ApiError, msg
end
end
def extract_error_message(parsed)
return parsed if parsed.is_a?(String)
return nil unless parsed.is_a?(Hash)
parsed["msg"] || parsed["message"] || parsed["error"]
end
end

View File

@@ -0,0 +1,102 @@
# frozen_string_literal: true
class Provider::BinanceAdapter < Provider::Base
include Provider::Syncable
include Provider::InstitutionMetadata
# Register this adapter with the factory
Provider::Factory.register("BinanceAccount", self)
# Define which account types this provider supports
def self.supported_account_types
%w[Crypto]
end
# Returns connection configurations for this provider
def self.connection_configs(family:)
return [] unless family.can_connect_binance?
[ {
key: "binance",
name: "Binance",
description: "Link to a Binance wallet",
can_connect: true,
new_account_path: ->(accountable_type, return_to) {
Rails.application.routes.url_helpers.select_accounts_binance_items_path(
accountable_type: accountable_type,
return_to: return_to
)
},
existing_account_path: ->(account_id) {
Rails.application.routes.url_helpers.select_existing_account_binance_items_path(
account_id: account_id
)
}
} ]
end
def provider_name
"binance"
end
# Build a Binance provider instance with family-specific credentials
# @param family [Family] The family to get credentials for (required)
# @return [Provider::Binance, nil] Returns nil if credentials are not configured
def self.build_provider(family: nil)
return nil unless family.present?
# Get family-specific credentials
binance_item = family.binance_items.where.not(api_key: nil).order(created_at: :desc).first
return nil unless binance_item&.credentials_configured?
Provider::Binance.new(
api_key: binance_item.api_key,
api_secret: binance_item.api_secret
)
end
def sync_path
Rails.application.routes.url_helpers.sync_binance_item_path(item)
end
def item
provider_account.binance_item
end
def can_delete_holdings?
false
end
def institution_domain
metadata = provider_account.institution_metadata || {}
domain = metadata["domain"]
url = metadata["url"]
# Derive domain from URL if missing
if domain.blank? && url.present?
begin
domain = URI.parse(url).host&.gsub(/^www\./, "")
rescue URI::InvalidURIError
Rails.logger.warn("Invalid institution URL for Binance account #{provider_account.id}: #{url}")
end
end
domain || item&.institution_domain
end
def institution_name
metadata = provider_account.institution_metadata || {}
metadata["name"] || item&.institution_name
end
def institution_url
metadata = provider_account.institution_metadata || {}
metadata["url"] || item&.institution_url
end
def institution_color
metadata = provider_account.institution_metadata || {}
metadata["color"] || item&.institution_color
end
end

View File

@@ -0,0 +1,132 @@
<%# locals: (binance_item:, unlinked_count: binance_item.unlinked_accounts_count) %>
<%= tag.div id: dom_id(binance_item) do %>
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center gap-2">
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<div class="flex items-center justify-center h-8 w-8 rounded-full" style="background-color: rgba(240, 185, 11, 0.15);">
<div class="flex items-center justify-center">
<%= icon "coins", size: "sm", class: "text-[#F0B90B]" %>
</div>
</div>
<div class="pl-1 text-sm flex-1">
<div class="flex items-center gap-2">
<%= tag.p binance_item.institution_display_name, class: "font-medium text-primary" %>
<% if binance_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
</div>
<p class="text-xs text-secondary"><%= t(".provider_name") %></p>
<% if binance_item.syncing? %>
<div class="text-secondary flex items-center gap-1">
<%= icon "loader", size: "sm", class: "animate-spin" %>
<%= tag.span t(".syncing") %>
</div>
<% elsif binance_item.requires_update? %>
<div class="text-warning flex items-center gap-1">
<%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span t(".reconnect") %>
</div>
<% else %>
<p class="text-secondary">
<% if binance_item.last_synced_at %>
<% if binance_item.sync_status_summary %>
<%= t(".status_with_summary", timestamp: time_ago_in_words(binance_item.last_synced_at), summary: binance_item.sync_status_summary) %>
<% else %>
<%= t(".status", timestamp: time_ago_in_words(binance_item.last_synced_at)) %>
<% end %>
<% else %>
<%= t(".status_never") %>
<% end %>
</p>
<% end %>
</div>
</summary>
<% if Current.user&.admin? %>
<div class="flex items-center justify-end gap-2 mt-2">
<% if binance_item.requires_update? %>
<%= render DS::Link.new(
text: t(".update_credentials"),
icon: "refresh-cw",
variant: "secondary",
href: settings_providers_path,
frame: "_top"
) %>
<% else %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_binance_item_path(binance_item),
disabled: binance_item.syncing?
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% if unlinked_count.to_i > 0 %>
<% menu.with_item(
variant: "link",
text: t(".import_accounts_menu"),
icon: "plus",
href: setup_accounts_binance_item_path(binance_item),
frame: :modal
) %>
<% end %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: binance_item_path(binance_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(binance_item.institution_display_name, high_severity: true)
) %>
<% end %>
</div>
<% end %>
<% unless binance_item.scheduled_for_deletion? %>
<div class="space-y-4 mt-4">
<% if binance_item.accounts.any? %>
<%= render "accounts/index/account_groups", accounts: binance_item.accounts %>
<% binance_item.stale_rate_accounts.each do |ba| %>
<div class="flex items-center gap-2 text-xs text-warning px-1">
<span class="font-mono">~</span>
<%= icon "triangle-alert", size: "sm" %>
<span>
<%= t("binance_items.binance_item.stale_rate_warning",
date: ba.extra.dig("binance", "rate_target_date")) %>
</span>
</div>
<% end %>
<% end %>
<% stats = binance_item.syncs.ordered.first&.sync_stats || {} %>
<%= render ProviderSyncSummary.new(
stats: stats,
provider_item: binance_item
) %>
<% if unlinked_count.to_i > 0 && binance_item.accounts.empty? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
<p class="text-secondary text-sm"><%= t(".setup_description") %></p>
<%= render DS::Link.new(
text: t(".setup_action"),
icon: "plus",
variant: "primary",
href: setup_accounts_binance_item_path(binance_item),
frame: :modal
) %>
</div>
<% elsif binance_item.accounts.empty? && binance_item.binance_accounts.none? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_title") %></p>
<p class="text-secondary text-sm"><%= t(".no_accounts_message") %></p>
</div>
<% end %>
</div>
<% end %>
</details>
<% end %>

View File

@@ -0,0 +1,43 @@
<%# Modal: Link an existing manual account to a Binance account %>
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
<% if @available_binance_accounts.blank? %>
<div class="p-4 text-sm text-secondary">
<p class="mb-2"><%= t(".no_accounts_found") %></p>
<ul class="list-disc list-inside space-y-1">
<li><%= t(".wait_for_sync") %></li>
<li><%= t(".check_provider_health") %></li>
</ul>
</div>
<% else %>
<%= form_with url: link_existing_account_binance_items_path, method: :post, class: "space-y-4" do %>
<%= hidden_field_tag :account_id, @account.id %>
<div class="space-y-2 max-h-64 overflow-auto">
<% @available_binance_accounts.each do |ba| %>
<label class="flex items-center gap-3 p-2 rounded border border-surface-inset/50 hover:border-primary cursor-pointer">
<%= radio_button_tag :binance_account_id, ba.id, false %>
<div class="flex flex-col">
<span class="text-sm text-primary font-medium"><%= ba.name.presence || ba.id %></span>
<span class="text-xs text-secondary">
<%= ba.currency %> &bull; <%= number_with_delimiter(ba.current_balance || 0, delimiter: ",") %>
</span>
<% if ba.current_account.present? %>
<span class="text-xs text-secondary"><%= t(".currently_linked_to", account_name: ba.current_account.name) %></span>
<% end %>
</div>
</label>
<% end %>
</div>
<div class="flex items-center justify-end gap-2">
<%= render DS::Button.new(text: t(".link"), variant: :primary, icon: "link-2", type: :submit) %>
<%= render DS::Link.new(text: t(".cancel"), variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %>
</div>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,104 @@
<% content_for :title, t(".title") %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title")) do %>
<div class="flex items-center gap-2">
<%= icon "coins", class: "text-primary" %>
<span class="text-primary"><%= t(".subtitle") %></span>
</div>
<% end %>
<% dialog.with_body do %>
<%= form_with url: complete_account_setup_binance_item_path(@binance_item),
method: :post,
local: true,
id: "binance-setup-form",
data: {
controller: "loading-button",
action: "submit->loading-button#showLoading",
loading_button_loading_text_value: t(".creating"),
turbo_frame: "_top"
},
class: "space-y-6" do |form| %>
<div class="space-y-4">
<div class="bg-surface border border-primary p-4 rounded-lg">
<div class="flex items-start gap-3">
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<div>
<p class="text-sm text-primary">
<%= t(".instructions") %>
</p>
</div>
</div>
</div>
<% if @binance_accounts.empty? %>
<div class="text-center py-8">
<p class="text-secondary"><%= t(".no_accounts") %></p>
</div>
<% else %>
<div data-controller="select-all">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-secondary">
<%= t(".accounts_count", count: @binance_accounts.count) %>
</span>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox"
id="binance-select-all"
data-action="change->select-all#toggle"
class="checkbox checkbox--dark">
<span class="text-secondary"><%= t(".select_all") %></span>
</label>
</div>
<div class="space-y-2 max-h-96 overflow-y-auto">
<% @binance_accounts.each do |binance_account| %>
<label for="ba_<%= binance_account.id %>" class="flex items-center gap-3 p-3 border border-primary rounded-lg hover:bg-surface transition-colors cursor-pointer">
<%= check_box_tag "selected_accounts[]",
binance_account.id,
false,
id: "ba_#{binance_account.id}",
class: "checkbox checkbox--dark",
data: { select_all_target: "checkbox" } %>
<div class="flex-1 min-w-0">
<p class="font-medium text-primary truncate">
<%= binance_account.name %>
</p>
<p class="text-xs text-secondary">
<%= binance_account.currency %>
</p>
</div>
<div class="text-right flex-shrink-0">
<p class="text-sm font-medium text-primary">
<%= number_with_delimiter(binance_account.current_balance || 0, delimiter: ",") %>
</p>
<p class="text-xs text-secondary">
<%= binance_account.currency %>
</p>
</div>
</label>
<% end %>
</div>
</div>
<% end %>
</div>
<div class="flex gap-3">
<%= render DS::Button.new(
text: t(".import_selected"),
variant: "primary",
icon: "plus",
type: "submit",
class: "flex-1",
data: { loading_button_target: "button" }
) %>
<%= render DS::Link.new(
text: t(".cancel"),
variant: "secondary",
href: accounts_path
) %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -29,7 +29,6 @@
</div> </div>
<% end %> <% end %>
<div class="grid grid-cols-1 <%= "2xl:grid-cols-2" if Current.user.dashboard_two_column? %> gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections"> <div class="grid grid-cols-1 <%= "2xl:grid-cols-2" if Current.user.dashboard_two_column? %> gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections">
<% if accessible_accounts.any? %> <% if accessible_accounts.any? %>
<% @dashboard_sections.each do |section| %> <% @dashboard_sections.each do |section| %>

View File

@@ -0,0 +1,106 @@
<div class="space-y-4">
<% items = local_assigns[:binance_items] || @binance_items || Current.family.binance_items.active.ordered %>
<div class="prose prose-sm text-secondary">
<p class="text-primary font-medium"><%= t("settings.providers.binance_panel.setup_instructions") %></p>
<ol>
<li><%= t("settings.providers.binance_panel.step1_html").html_safe %></li>
<li><%= t("settings.providers.binance_panel.step2") %></li>
<li><%= t("settings.providers.binance_panel.step3") %></li>
</ol>
<p class="text-destructive text-xs font-medium"><%= t("settings.providers.binance_panel.no_withdraw_warning") %></p>
</div>
<div class="bg-surface border border-primary p-3 rounded-lg text-sm">
<p class="font-medium text-primary"><%= t("settings.providers.binance_panel.ip_hint_title") %></p>
<p class="text-secondary mt-1"><%= t("settings.providers.binance_panel.ip_hint_body") %></p>
<% server_ip = ENV["BINANCE_EGRESS_IP"].presence %>
<% if server_ip %>
<code class="mt-1 block text-xs bg-container-inset px-2 py-1 rounded font-mono text-primary"><%= server_ip %></code>
<% else %>
<p class="mt-1 text-xs text-secondary italic"><%= t("settings.providers.binance_panel.ip_hint_contact_admin") %></p>
<% end %>
</div>
<% error_msg = local_assigns[:error_message] || @error_message %>
<% if error_msg.present? %>
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
<p class="line-clamp-3" title="<%= h(error_msg) %>"><%= error_msg %></p>
</div>
<% end %>
<% if items.any? %>
<div class="space-y-3">
<% items.each do |item| %>
<div class="flex items-center justify-between p-3 bg-container-inset rounded-lg border border-primary">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full flex items-center justify-center" style="background-color: rgba(240, 185, 11, 0.15);">
<%= icon "coins", size: "md", class: "text-[#F0B90B]" %>
</div>
<div>
<p class="font-medium text-primary"><%= item.name %></p>
<p class="text-xs text-secondary">
<% if item.syncing? %>
<%= t("settings.providers.binance_panel.syncing") %>
<% else %>
<%= item.sync_status_summary %>
<% end %>
</p>
</div>
</div>
<div class="flex items-center gap-2">
<%= button_to sync_binance_item_path(item),
method: :post,
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg hover:border-primary",
disabled: item.syncing? do %>
<%= icon "refresh-cw", size: "sm" %>
<%= t("settings.providers.binance_panel.sync") %>
<% end %>
<%= button_to binance_item_path(item),
method: :delete,
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-destructive hover:bg-destructive/10 rounded-lg",
data: { turbo_confirm: t("settings.providers.binance_panel.disconnect_confirm") } do %>
<%= icon "trash-2", size: "sm" %>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<%
binance_item = Current.family.binance_items.build(name: "Binance")
%>
<%= styled_form_with model: binance_item,
url: binance_items_path,
scope: :binance_item,
method: :post,
data: { turbo: true },
class: "space-y-3" do |form| %>
<%= form.text_field :api_key,
label: t("settings.providers.binance_panel.api_key_label"),
placeholder: t("settings.providers.binance_panel.api_key_placeholder"),
type: :password %>
<%= form.text_field :api_secret,
label: t("settings.providers.binance_panel.api_secret_label"),
placeholder: t("settings.providers.binance_panel.api_secret_placeholder"),
type: :password %>
<div class="flex justify-end">
<%= form.submit t("settings.providers.binance_panel.connect_button"),
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %>
</div>
<% end %>
<% end %>
<div class="flex items-center gap-2">
<% if items.any? %>
<div class="w-2 h-2 bg-success rounded-full"></div>
<p class="text-sm text-secondary"><%= t("settings.providers.binance_panel.status_connected") %></p>
<% else %>
<div class="w-2 h-2 bg-tertiary rounded-full"></div>
<p class="text-sm text-secondary"><%= t("settings.providers.binance_panel.status_not_connected") %></p>
<% end %>
</div>
</div>

View File

@@ -67,6 +67,12 @@
</turbo-frame> </turbo-frame>
<% end %> <% end %>
<%= settings_section title: "Binance (beta)", collapsible: true, open: false do %>
<turbo-frame id="binance-providers-panel">
<%= render "settings/providers/binance_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %> <%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %>
<turbo-frame id="snaptrade-providers-panel"> <turbo-frame id="snaptrade-providers-panel">
<%= render "settings/providers/snaptrade_panel" %> <%= render "settings/providers/snaptrade_panel" %>

View File

@@ -36,16 +36,16 @@
<% end %> <% end %>
<% trade = entry.trade %> <% trade = entry.trade %>
<% unless trade.security.cash? %> <% unless trade.security.cash? %>
<div class="mb-2"> <div class="mb-2">
<%= render DS::Disclosure.new(title: t(".overview"), open: true) do %> <%= render DS::Disclosure.new(title: t(".overview"), open: true) do %>
<div class="pb-4"> <div class="pb-4">
<dl class="space-y-3 px-3 py-2"> <dl class="space-y-3 px-3 py-2">
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<dt class="text-secondary"><%= t(".symbol_label") %></dt> <dt class="text-secondary"><%= t(".symbol_label") %></dt>
<dd class="text-primary"><%= trade.security.ticker %></dd> <dd class="text-primary"><%= trade.security.ticker %></dd>
</div> </div>
<% if trade.qty.positive? %> <% if trade.qty.positive? %>
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">

View File

@@ -0,0 +1,75 @@
---
en:
binance_items:
create:
default_name: Binance
success: Successfully connected to Binance! Your account is being synced.
update:
success: Successfully updated Binance configuration.
destroy:
success: Scheduled Binance connection for deletion.
setup_accounts:
title: Import Binance Account
subtitle: Select which portfolios to track
instructions: Select the Binance portfolios you want to import. Only portfolios with balances are shown.
no_accounts: All accounts have been imported.
accounts_count:
one: "%{count} account available"
other: "%{count} accounts available"
select_all: Select all
import_selected: Import Selected
cancel: Cancel
creating: Importing...
complete_account_setup:
success:
one: "Imported %{count} account"
other: "Imported %{count} accounts"
none_selected: No accounts selected
no_accounts: No accounts to import
binance_item:
provider_name: Binance
syncing: Syncing...
reconnect: Credentials need updating
deletion_in_progress: Deleting...
sync_status:
no_accounts: No accounts found
all_synced:
one: "%{count} account synced"
other: "%{count} accounts synced"
partial_sync: "%{linked_count} synced, %{unlinked_count} need setup"
status: "Last synced %{timestamp} ago"
status_with_summary: "Last synced %{timestamp} ago - %{summary}"
status_never: Never synced
update_credentials: Update credentials
delete: Delete
no_accounts_title: No accounts found
no_accounts_message: Your Binance portfolio will appear here after syncing.
setup_needed: Account ready to import
setup_description: Select which Binance portfolios you want to track.
setup_action: Import Account
import_accounts_menu: Import Account
stale_rate_warning: "Balance is approximate — the exact exchange rate for %{date} was unavailable. Will update on next sync."
select_existing_account:
title: Link Binance Account
no_accounts_found: No Binance accounts found.
wait_for_sync: Wait for Binance to finish syncing
check_provider_health: Check that your Binance API credentials are valid
currently_linked_to: "Currently linked to: %{account_name}"
link: Link
cancel: Cancel
link_existing_account:
success: Successfully linked to Binance account
errors:
only_manual: Only manual accounts can be linked to Binance
invalid_binance_account: Invalid Binance account
binance_item:
syncer:
checking_credentials: Checking credentials...
credentials_invalid: Invalid API credentials. Please check your API key and secret.
importing_accounts: Importing accounts from Binance...
checking_configuration: Checking account configuration...
accounts_need_setup:
one: "%{count} account needs setup"
other: "%{count} accounts need setup"
processing_accounts: Processing account data...
calculating_balances: Calculating balances...

View File

@@ -189,6 +189,25 @@ en:
disconnect_confirm: Are you sure you want to disconnect this Coinbase connection? Your synced accounts will become manual accounts. 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_connected: Coinbase is connected and syncing your crypto holdings.
status_not_connected: Not connected. Enter your API credentials above to get started. status_not_connected: Not connected. Enter your API credentials above to get started.
binance_panel:
setup_instructions: "To connect Binance, create a read-only API key:"
step1_html: 'Go to <a href="https://www.binance.com/en/my/settings/api-management" target="_blank" class="underline">Binance API Management</a>'
step2: "Create a new API key with Enable Reading permission only"
step3: "Paste your API Key and Secret below"
no_withdraw_warning: "Warning: do NOT enable withdrawal permissions"
ip_hint_title: "IP Whitelisting Required"
ip_hint_body: "Add the app server's egress IP to the Binance API Key whitelist:"
ip_hint_contact_admin: "Contact your administrator to obtain the app server's egress IP address."
api_key_label: API Key
api_key_placeholder: Paste your Binance API Key
api_secret_label: API Secret
api_secret_placeholder: Paste your Binance API Secret
connect_button: Connect Binance
syncing: Syncing...
sync: Sync
disconnect_confirm: "Are you sure you want to disconnect Binance?"
status_connected: Binance connected
status_not_connected: Binance not connected
enable_banking_panel: enable_banking_panel:
callback_url_instruction: "For the callback URL, use %{callback_url}." callback_url_instruction: "For the callback URL, use %{callback_url}."
connection_error: Connection Error connection_error: Connection Error

View File

@@ -49,6 +49,21 @@ Rails.application.routes.draw do
end end
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 resources :snaptrade_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do
collection do collection do
get :preload_accounts get :preload_accounts

View File

@@ -0,0 +1,48 @@
class CreateBinanceItemsAndAccounts < ActiveRecord::Migration[7.2]
def change
create_table :binance_items, id: :uuid do |t|
t.references :family, null: false, foreign_key: true, type: :uuid
t.string :name
t.string :institution_name
t.string :institution_domain
t.string :institution_url
t.string :institution_color
t.string :status, default: "good"
t.boolean :scheduled_for_deletion, default: false
t.boolean :pending_account_setup, default: false
t.datetime :sync_start_date
t.jsonb :raw_payload
t.text :api_key
t.text :api_secret
t.timestamps
end
add_index :binance_items, :status
create_table :binance_accounts, id: :uuid do |t|
t.references :binance_item, null: false, foreign_key: true, type: :uuid
t.string :name
t.string :account_type
t.string :currency
t.decimal :current_balance, precision: 19, scale: 4
t.jsonb :institution_metadata
t.jsonb :raw_payload
t.jsonb :raw_transactions_payload
t.jsonb :extra, default: {}, null: false
t.timestamps
end
add_index :binance_accounts, :account_type
add_index :binance_accounts, [ :binance_item_id, :account_type ],
unique: true,
name: "index_binance_accounts_on_item_and_type"
end
end

45
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" 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 ["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", "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.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 end
create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| 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" t.index ["account_id"], name: "index_balances_on_account_id"
end 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| create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "budget_id", null: false t.uuid "budget_id", null: false
t.uuid "category_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 "moniker", default: "Family", null: false
t.string "assistant_type", default: "builtin", null: false t.string "assistant_type", default: "builtin", null: false
t.string "default_account_sharing", default: "shared", 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" t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range"
end 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 "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "api_keys", "users" add_foreign_key "api_keys", "users"
add_foreign_key "balances", "accounts", on_delete: :cascade 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", "budgets"
add_foreign_key "budget_categories", "categories" add_foreign_key "budget_categories", "categories"
add_foreign_key "budgets", "families" add_foreign_key "budgets", "families"

View File

@@ -0,0 +1,184 @@
require "test_helper"
class BinanceItemsControllerTest < ActionDispatch::IntegrationTest
include ActiveJob::TestHelper
setup do
sign_in users(:family_admin)
@family = families(:dylan_family)
@binance_item = BinanceItem.create!(
family: @family,
name: "Test Binance",
api_key: "test_key",
api_secret: "test_secret"
)
end
test "should destroy binance item" do
assert_difference("BinanceItem.count", 0) do # doesn't delete immediately
delete binance_item_url(@binance_item)
end
assert_redirected_to settings_providers_path
@binance_item.reload
assert @binance_item.scheduled_for_deletion?
end
test "should sync binance item" do
post sync_binance_item_url(@binance_item)
assert_response :redirect
end
test "should show setup_accounts page" do
get setup_accounts_binance_item_url(@binance_item)
assert_response :success
end
test "complete_account_setup creates accounts for selected binance_accounts" do
binance_account = @binance_item.binance_accounts.create!(
name: "Spot Portfolio",
account_type: "spot",
currency: "USD",
current_balance: 1000.0
)
assert_difference "Account.count", 1 do
post complete_account_setup_binance_item_url(@binance_item), params: {
selected_accounts: [ binance_account.id ]
}
end
assert_response :redirect
binance_account.reload
assert_not_nil binance_account.current_account
assert_equal "Crypto", binance_account.current_account.accountable_type
end
test "complete_account_setup with no selection shows message" do
@binance_item.binance_accounts.create!(
name: "Spot Portfolio",
account_type: "spot",
currency: "USD",
current_balance: 1000.0
)
assert_no_difference "Account.count" do
post complete_account_setup_binance_item_url(@binance_item), params: {
selected_accounts: []
}
end
assert_response :redirect
end
test "complete_account_setup skips already linked accounts" do
binance_account = @binance_item.binance_accounts.create!(
name: "Spot Portfolio",
account_type: "spot",
currency: "USD",
current_balance: 1000.0
)
# Pre-link the account
account = Account.create!(
family: @family,
name: "Existing Binance",
balance: 1000,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: account, provider: binance_account)
assert_no_difference "Account.count" do
post complete_account_setup_binance_item_url(@binance_item), params: {
selected_accounts: [ binance_account.id ]
}
end
end
test "cannot access other family's binance_item" do
other_family = families(:empty)
other_item = BinanceItem.create!(
family: other_family,
name: "Other Binance",
api_key: "other_test_key",
api_secret: "other_test_secret"
)
get setup_accounts_binance_item_url(other_item)
assert_response :not_found
end
test "link_existing_account links manual account to binance_account" do
manual_account = Account.create!(
family: @family,
name: "Manual Crypto",
balance: 0,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
binance_account = @binance_item.binance_accounts.create!(
name: "Spot Portfolio",
account_type: "spot",
currency: "USD",
current_balance: 1000.0
)
assert_difference "AccountProvider.count", 1 do
post link_existing_account_binance_items_url, params: {
account_id: manual_account.id,
binance_account_id: binance_account.id
}
end
binance_account.reload
assert_equal manual_account, binance_account.current_account
end
test "link_existing_account rejects account with existing provider" do
linked_account = Account.create!(
family: @family,
name: "Already Linked",
balance: 0,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
other_binance_account = @binance_item.binance_accounts.create!(
name: "Other Account",
account_type: "margin",
currency: "USD",
current_balance: 500.0
)
AccountProvider.create!(account: linked_account, provider: other_binance_account)
binance_account = @binance_item.binance_accounts.create!(
name: "Spot Portfolio",
account_type: "spot",
currency: "USD",
current_balance: 1000.0
)
assert_no_difference "AccountProvider.count" do
post link_existing_account_binance_items_url, params: {
account_id: linked_account.id,
binance_account_id: binance_account.id
}
end
end
test "select_existing_account renders without layout" do
account = Account.create!(
family: @family,
name: "Manual Account",
balance: 0,
currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
get select_existing_account_binance_items_url, params: { account_id: account.id }
assert_response :success
end
end

6
test/fixtures/binance_accounts.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
one:
binance_item: one
name: Binance
account_type: combined
currency: USD
current_balance: 15000.00

18
test/fixtures/binance_items.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
one:
family: dylan_family
name: My Binance
api_key: test_api_key_123
api_secret: test_api_secret_456
status: good
institution_name: Binance
institution_domain: binance.com
institution_url: https://www.binance.com
institution_color: "#F0B90B"
requires_update:
family: dylan_family
name: Stale Binance
api_key: old_key
api_secret: old_secret
status: requires_update
institution_name: Binance

View File

@@ -0,0 +1,72 @@
# frozen_string_literal: true
require "test_helper"
class BinanceAccount::HoldingsProcessorTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@family.update!(currency: "EUR")
@item = BinanceItem.create!(
family: @family, name: "Binance", api_key: "k", api_secret: "s"
)
@ba = @item.binance_accounts.create!(
name: "Binance",
account_type: "combined",
currency: "USD",
current_balance: 1000,
raw_payload: {
"assets" => [ { "symbol" => "BTC", "total" => "0.5", "source" => "spot" } ]
}
)
@account = Account.create!(
family: @family,
name: "Binance",
balance: 0,
currency: "EUR",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: @account, provider: @ba)
end
test "converts holding amount to family currency when exact rate exists" do
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
date: Date.current, rate: 0.92)
Security.find_or_create_by!(ticker: "CRYPTO:BTC") do |s|
s.name = "BTC"
s.exchange_operating_mic = "XBNC"
end
BinanceAccount::HoldingsProcessor.any_instance
.stubs(:fetch_price).with("BTC").returns(60_000.0)
import_adapter = mock
import_adapter.expects(:import_holding).with(
has_entries(currency: "EUR", amount: 27_600.0)
)
Account::ProviderImportAdapter.stubs(:new).returns(import_adapter)
BinanceAccount::HoldingsProcessor.new(@ba).process
end
test "uses raw USD amount when no rate is available" do
ExchangeRate.stubs(:find_or_fetch_rate).returns(nil)
Security.find_or_create_by!(ticker: "CRYPTO:BTC") do |s|
s.name = "BTC"
s.exchange_operating_mic = "XBNC"
end
BinanceAccount::HoldingsProcessor.any_instance
.stubs(:fetch_price).with("BTC").returns(60_000.0)
import_adapter = mock
import_adapter.expects(:import_holding).with(
has_entries(currency: "EUR", amount: 30_000.0)
)
Account::ProviderImportAdapter.stubs(:new).returns(import_adapter)
BinanceAccount::HoldingsProcessor.new(@ba).process
end
end

View File

@@ -0,0 +1,89 @@
# frozen_string_literal: true
require "test_helper"
class BinanceAccount::ProcessorTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@family.update!(currency: "EUR")
@item = BinanceItem.create!(
family: @family, name: "Binance", api_key: "k", api_secret: "s"
)
@ba = @item.binance_accounts.create!(
name: "Binance", account_type: "combined", currency: "USD", current_balance: 1000
)
@account = Account.create!(
family: @family,
name: "Binance",
balance: 0,
currency: "EUR",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: @account, provider: @ba)
BinanceAccount::HoldingsProcessor.any_instance.stubs(:process).returns(nil)
@ba.stubs(:binance_item).returns(
stub(binance_provider: nil, family: @family)
)
end
test "converts USD balance to family currency when exact rate exists" do
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
date: Date.current, rate: 0.92)
BinanceAccount::Processor.new(@ba).process
@account.reload
@ba.reload
assert_equal "EUR", @account.currency
assert_in_delta 920.0, @account.balance, 0.01
assert_equal false, @ba.extra.dig("binance", "stale_rate")
end
test "uses nearest rate and sets stale flag when exact rate missing" do
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
date: Date.current - 3, rate: 0.90)
BinanceAccount::Processor.new(@ba).process
@account.reload
@ba.reload
assert_equal "EUR", @account.currency
assert_in_delta 900.0, @account.balance, 0.01
assert_equal true, @ba.extra.dig("binance", "stale_rate")
end
test "falls back to USD amount and sets stale flag when no rate available" do
ExchangeRate.expects(:find_or_fetch_rate).returns(nil)
BinanceAccount::Processor.new(@ba).process
@account.reload
@ba.reload
assert_in_delta 1000.0, @account.balance, 0.01
assert_equal true, @ba.extra.dig("binance", "stale_rate")
end
test "clears stale flag on subsequent sync when exact rate found" do
@ba.update!(extra: { "binance" => { "stale_rate" => true } })
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
date: Date.current, rate: 0.92)
BinanceAccount::Processor.new(@ba).process
@account.reload
@ba.reload
assert_equal false, @ba.extra.dig("binance", "stale_rate")
end
test "does not convert when family uses USD" do
@family.update!(currency: "USD")
BinanceAccount::Processor.new(@ba).process
@account.reload
assert_equal "USD", @account.currency
assert_in_delta 1000.0, @account.balance, 0.01
end
end

View File

@@ -0,0 +1,75 @@
# frozen_string_literal: true
require "test_helper"
class BinanceAccount::UsdConverterTest < ActiveSupport::TestCase
# A minimal host class that includes the concern so we can test it in isolation
class Host
include BinanceAccount::UsdConverter
def initialize(family_currency)
@family_currency = family_currency
end
def target_currency
@family_currency
end
end
test "returns original amount unchanged when target is USD" do
host = Host.new("USD")
amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: Date.current)
assert_equal 1000.0, amount
assert_equal false, stale
assert_nil rate_date
end
test "returns converted amount when exact rate exists" do
date = Date.new(2026, 3, 28)
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: date, rate: 0.92)
host = Host.new("EUR")
amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: date)
assert_in_delta 920.0, amount, 0.01
assert_equal false, stale
assert_nil rate_date
end
test "marks stale and returns converted amount when nearest rate used" do
old_date = Date.new(2026, 3, 25)
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: old_date, rate: 0.91)
host = Host.new("EUR")
amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: Date.new(2026, 3, 28))
assert_in_delta 910.0, amount, 0.01
assert_equal true, stale
assert_equal old_date, rate_date
end
test "returns raw USD amount with stale flag when no rate available" do
host = Host.new("EUR")
ExchangeRate.expects(:find_or_fetch_rate).returns(nil)
amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: Date.new(2026, 3, 28))
assert_equal 1000.0, amount
assert_equal true, stale
assert_nil rate_date
end
test "build_stale_extra returns correct hash when stale" do
host = Host.new("EUR")
result = host.send(:build_stale_extra, true, Date.new(2026, 3, 25), Date.new(2026, 3, 28))
assert_equal({ "binance" => { "stale_rate" => true, "rate_date_used" => "2026-03-25", "rate_target_date" => "2026-03-28" } }, result)
end
test "build_stale_extra returns cleared hash when not stale" do
host = Host.new("EUR")
result = host.send(:build_stale_extra, false, nil, Date.new(2026, 3, 28))
assert_equal({ "binance" => { "stale_rate" => false } }, result)
end
end

View File

@@ -0,0 +1,64 @@
# frozen_string_literal: true
require "test_helper"
class BinanceAccountTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@item = binance_items(:one)
@ba = binance_accounts(:one)
end
test "belongs to binance_item" do
assert_equal @item, @ba.binance_item
end
test "validates presence of name" do
ba = @item.binance_accounts.build(account_type: "combined", currency: "USD")
assert_not ba.valid?
assert_includes ba.errors[:name], "can't be blank"
end
test "validates presence of currency" do
ba = @item.binance_accounts.build(name: "Binance", account_type: "combined")
assert_not ba.valid?
assert_includes ba.errors[:currency], "can't be blank"
end
test "ensure_account_provider! creates AccountProvider" do
account = Account.create!(
family: @family, name: "Binance", balance: 0, currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
@ba.ensure_account_provider!(account)
ap = AccountProvider.find_by(provider: @ba)
assert_not_nil ap
assert_equal account, ap.account
end
test "ensure_account_provider! is idempotent" do
account = Account.create!(
family: @family, name: "Binance", balance: 0, currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
@ba.ensure_account_provider!(account)
@ba.ensure_account_provider!(account)
assert_equal 1, AccountProvider.where(provider: @ba).count
end
test "current_account returns linked account" do
assert_nil @ba.current_account
account = Account.create!(
family: @family, name: "Binance", balance: 0, currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: account, provider: @ba)
assert_equal account, @ba.reload.current_account
end
end

View File

@@ -0,0 +1,58 @@
# frozen_string_literal: true
require "test_helper"
class BinanceItem::EarnImporterTest < ActiveSupport::TestCase
setup do
@provider = mock
@family = families(:dylan_family)
@item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s")
end
test "merges flexible and locked positions with source=earn" do
@provider.stubs(:get_simple_earn_flexible).returns({
"rows" => [ { "asset" => "USDT", "totalAmount" => "500.0" } ]
})
@provider.stubs(:get_simple_earn_locked).returns({
"rows" => [ { "asset" => "BNB", "amount" => "10.0" } ]
})
result = BinanceItem::EarnImporter.new(@item, provider: @provider).import
assert_equal "earn", result[:source]
assert_equal 2, result[:assets].size
usdt = result[:assets].find { |a| a[:symbol] == "USDT" }
assert_equal "500.0", usdt[:total]
assert_equal "500.0", usdt[:free]
assert_equal "0.0", usdt[:locked]
bnb = result[:assets].find { |a| a[:symbol] == "BNB" }
assert_equal "10.0", bnb[:total]
assert_equal "0.0", bnb[:free]
assert_equal "10.0", bnb[:locked]
end
test "deduplicates assets from flexible and locked by summing" do
@provider.stubs(:get_simple_earn_flexible).returns({
"rows" => [ { "asset" => "BTC", "totalAmount" => "1.0" } ]
})
@provider.stubs(:get_simple_earn_locked).returns({
"rows" => [ { "asset" => "BTC", "amount" => "0.5" } ]
})
result = BinanceItem::EarnImporter.new(@item, provider: @provider).import
assert_equal 1, result[:assets].size
assert_equal "1.5", result[:assets].first[:total]
end
test "returns empty assets when both APIs fail" do
@provider.stubs(:get_simple_earn_flexible).raises(Provider::Binance::ApiError, "error")
@provider.stubs(:get_simple_earn_locked).raises(Provider::Binance::ApiError, "error")
result = BinanceItem::EarnImporter.new(@item, provider: @provider).import
assert_equal "earn", result[:source]
assert_equal [], result[:assets]
assert_equal({ "flexible" => nil, "locked" => nil }, result[:raw])
end
end

View File

@@ -0,0 +1,85 @@
# frozen_string_literal: true
require "test_helper"
class BinanceItem::ImporterTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s")
@provider = mock
@provider.stubs(:get_spot_price).returns("50000.0")
stub_spot_result([ { symbol: "BTC", free: "1.0", locked: "0.0", total: "1.0" } ])
stub_margin_result([])
stub_earn_result([])
end
test "creates a binance_account of type combined" do
assert_difference "@item.binance_accounts.count", 1 do
BinanceItem::Importer.new(@item, binance_provider: @provider).import
end
ba = @item.binance_accounts.first
assert_equal "combined", ba.account_type
assert_equal "USD", ba.currency
end
test "calculates combined USD balance" do
@provider.stubs(:get_spot_price).with("BTCUSDT").returns("50000.0")
BinanceItem::Importer.new(@item, binance_provider: @provider).import
ba = @item.binance_accounts.first
assert_in_delta 50000.0, ba.current_balance.to_f, 0.01
end
test "stablecoins counted at 1.0 without API call" do
stub_spot_result([ { symbol: "USDT", free: "1000.0", locked: "0.0", total: "1000.0" } ])
@provider.expects(:get_spot_price).never
BinanceItem::Importer.new(@item, binance_provider: @provider).import
ba = @item.binance_accounts.first
assert_in_delta 1000.0, ba.current_balance.to_f, 0.01
end
test "skips BinanceAccount creation when all sources empty" do
stub_spot_result([])
stub_margin_result([])
stub_earn_result([])
assert_no_difference "@item.binance_accounts.count" do
BinanceItem::Importer.new(@item, binance_provider: @provider).import
end
end
test "stores source breakdown in raw_payload" do
BinanceItem::Importer.new(@item, binance_provider: @provider).import
ba = @item.binance_accounts.first
assert ba.raw_payload.key?("spot")
assert ba.raw_payload.key?("margin")
assert ba.raw_payload.key?("earn")
end
private
def stub_spot_result(assets)
BinanceItem::SpotImporter.any_instance.stubs(:import).returns(
{ assets: assets, raw: {}, source: "spot" }
)
end
def stub_margin_result(assets)
BinanceItem::MarginImporter.any_instance.stubs(:import).returns(
{ assets: assets, raw: {}, source: "margin" }
)
end
def stub_earn_result(assets)
BinanceItem::EarnImporter.any_instance.stubs(:import).returns(
{ assets: assets, raw: {}, source: "earn" }
)
end
end

View File

@@ -0,0 +1,37 @@
# frozen_string_literal: true
require "test_helper"
class BinanceItem::MarginImporterTest < ActiveSupport::TestCase
setup do
@provider = mock
@family = families(:dylan_family)
@item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s")
end
test "returns normalized assets from userAssets with source=margin" do
@provider.stubs(:get_margin_account).returns({
"userAssets" => [
{ "asset" => "BTC", "free" => "0.1", "locked" => "0.0", "netAsset" => "0.1" },
{ "asset" => "ETH", "free" => "0.0", "locked" => "0.0", "netAsset" => "0.0" }
]
})
result = BinanceItem::MarginImporter.new(@item, provider: @provider).import
assert_equal "margin", result[:source]
assert_equal 1, result[:assets].size
btc = result[:assets].first
assert_equal "BTC", btc[:symbol]
assert_equal "0.1", btc[:total]
end
test "returns empty on API error" do
@provider.stubs(:get_margin_account).raises(Provider::Binance::ApiError, "WAF")
result = BinanceItem::MarginImporter.new(@item, provider: @provider).import
assert_equal "margin", result[:source]
assert_equal [], result[:assets]
end
end

View File

@@ -0,0 +1,53 @@
# frozen_string_literal: true
require "test_helper"
class BinanceItem::SpotImporterTest < ActiveSupport::TestCase
setup do
@provider = mock
@family = families(:dylan_family)
@item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s")
end
test "returns normalized assets with source=spot" do
@provider.stubs(:get_spot_account).returns({
"balances" => [
{ "asset" => "BTC", "free" => "1.5", "locked" => "0.5" },
{ "asset" => "ETH", "free" => "10.0", "locked" => "0.0" },
{ "asset" => "SHIB", "free" => "0.0", "locked" => "0.0" }
]
})
result = BinanceItem::SpotImporter.new(@item, provider: @provider).import
assert_equal "spot", result[:source]
assert_equal 2, result[:assets].size # SHIB filtered out (zero balance)
btc = result[:assets].find { |a| a[:symbol] == "BTC" }
assert_equal "1.5", btc[:free]
assert_equal "0.5", btc[:locked]
assert_equal "2.0", btc[:total]
end
test "returns empty assets on API error" do
@provider.stubs(:get_spot_account).raises(Provider::Binance::AuthenticationError, "Invalid key")
result = BinanceItem::SpotImporter.new(@item, provider: @provider).import
assert_equal "spot", result[:source]
assert_equal [], result[:assets]
assert_nil result[:raw]
end
test "filters out zero-balance assets" do
@provider.stubs(:get_spot_account).returns({
"balances" => [
{ "asset" => "BTC", "free" => "0.0", "locked" => "0.0" },
{ "asset" => "ETH", "free" => "0.0", "locked" => "0.0" }
]
})
result = BinanceItem::SpotImporter.new(@item, provider: @provider).import
assert_equal [], result[:assets]
end
end

View File

@@ -0,0 +1,111 @@
# frozen_string_literal: true
require "test_helper"
class BinanceItemTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@item = BinanceItem.create!(
family: @family,
name: "My Binance",
api_key: "test_key",
api_secret: "test_secret"
)
end
test "belongs to family" do
assert_equal @family, @item.family
end
test "has good status by default" do
assert_equal "good", @item.status
end
test "validates presence of name" do
item = BinanceItem.new(family: @family, api_key: "k", api_secret: "s")
assert_not item.valid?
assert_includes item.errors[:name], "can't be blank"
end
test "validates presence of api_key" do
item = BinanceItem.new(family: @family, name: "B", api_secret: "s")
assert_not item.valid?
assert_includes item.errors[:api_key], "can't be blank"
end
test "validates presence of api_secret" do
item = BinanceItem.new(family: @family, name: "B", api_key: "k")
assert_not item.valid?
assert_includes item.errors[:api_secret], "can't be blank"
end
test "active scope excludes scheduled for deletion" do
@item.update!(scheduled_for_deletion: true)
refute_includes BinanceItem.active.to_a, @item
end
test "credentials_configured? returns true when both keys present" do
assert @item.credentials_configured?
end
test "credentials_configured? returns false when api_key nil" do
@item.api_key = nil
refute @item.credentials_configured?
end
test "destroy_later marks for deletion" do
@item.destroy_later
assert @item.scheduled_for_deletion?
end
test "set_binance_institution_defaults! sets metadata" do
@item.set_binance_institution_defaults!
assert_equal "Binance", @item.institution_name
assert_equal "binance.com", @item.institution_domain
assert_equal "https://www.binance.com", @item.institution_url
assert_equal "#F0B90B", @item.institution_color
end
test "sync_status_summary with no accounts" do
assert_equal I18n.t("binance_items.binance_item.sync_status.no_accounts"), @item.sync_status_summary
end
test "sync_status_summary with all accounts linked" do
ba = @item.binance_accounts.create!(name: "Binance Combined", account_type: "combined", currency: "USD")
account = Account.create!(
family: @family, name: "Binance", balance: 0, currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: account, provider: ba)
assert_equal I18n.t("binance_items.binance_item.sync_status.all_synced", count: 1), @item.sync_status_summary
end
test "sync_status_summary with partial sync" do
# Linked account
ba1 = @item.binance_accounts.create!(name: "Binance Spot", account_type: "spot", currency: "USD")
account = Account.create!(
family: @family, name: "Binance Spot", balance: 0, currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: account, provider: ba1)
# Unlinked account
@item.binance_accounts.create!(name: "Binance Earn", account_type: "earn", currency: "USD")
assert_equal I18n.t("binance_items.binance_item.sync_status.partial_sync", linked_count: 1, unlinked_count: 1), @item.sync_status_summary
end
test "linked_accounts_count returns correct count" do
ba = @item.binance_accounts.create!(name: "Binance", account_type: "combined", currency: "USD")
assert_equal 0, @item.linked_accounts_count
account = Account.create!(
family: @family, name: "Binance", balance: 0, currency: "USD",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: account, provider: ba)
assert_equal 1, @item.linked_accounts_count
end
end

View File

@@ -0,0 +1,62 @@
require "test_helper"
class Provider::BinanceTest < ActiveSupport::TestCase
setup do
@provider = Provider::Binance.new(api_key: "test_key", api_secret: "test_secret")
end
test "sign produces HMAC-SHA256 hex digest" do
params = { "timestamp" => "1000", "recvWindow" => "5000" }
sig = @provider.send(:sign, params)
expected = OpenSSL::HMAC.hexdigest("sha256", "test_secret", "recvWindow=5000&timestamp=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

View File

@@ -137,7 +137,7 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase
entry = @account.entries.find_by!(external_id: "simplefin_tx_pending_zero_posted_1", source: "simplefin") 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 # 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") sf = entry.transaction.extra.fetch("simplefin")
assert_equal true, sf["pending"], "expected pending flag to be true when posted==0 and/or pending=true" assert_equal true, sf["pending"], "expected pending flag to be true when posted==0 and/or pending=true"
end end