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
config/locales/views/reports/
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:
# git config --global core.excludesfile '~/.gitignore_global'
# Git Worktrees
.worktrees/
# Ignore bundler config.
/.bundle
/vendor/bundle
@@ -73,6 +76,10 @@ compose.yml
plaid_test_accounts/
# Added by Claude
.claude/settings.local.json
docs/superpowers/
# Added by Claude Task Master
# Logs
logs
@@ -108,7 +115,6 @@ scripts/
.cursor/rules/dev_workflow.mdc
.cursor/rules/taskmaster.mdc
# Auto Claude data directory
.auto-claude/
@@ -116,6 +122,5 @@ scripts/
.auto-claude-security.json
.auto-claude-status
.claude_settings.json
.worktrees/
.security-key
logs/security/
logs/security/

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)
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

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
include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable
include CoinbaseConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable
include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable
include IndexaCapitalConnectable
DATE_FORMATS = [

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

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>
<% end %>
<%= settings_section title: "Binance (beta)", collapsible: true, open: false do %>
<turbo-frame id="binance-providers-panel">
<%= render "settings/providers/binance_panel" %>
</turbo-frame>
<% end %>
<%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %>
<turbo-frame id="snaptrade-providers-panel">
<%= render "settings/providers/snaptrade_panel" %>

View File

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

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

View File

@@ -49,6 +49,21 @@ Rails.application.routes.draw do
end
end
resources :binance_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do
collection do
get :select_accounts
post :link_accounts
get :select_existing_account
post :link_existing_account
end
member do
post :sync
get :setup_accounts
post :complete_account_setup
end
end
resources :snaptrade_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do
collection do
get :preload_accounts

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.
ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do
ActiveRecord::Schema[7.2].define(version: 2026_03_30_050801) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -40,7 +40,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do
t.index ["account_id"], name: "index_account_shares_on_account_id"
t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances"
t.index ["user_id"], name: "index_account_shares_on_user_id"
t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying, 'read_write'::character varying, 'read_only'::character varying]::text[])", name: "chk_account_shares_permission"
t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying::text, 'read_write'::character varying::text, 'read_only'::character varying::text])", name: "chk_account_shares_permission"
end
create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -177,6 +177,43 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do
t.index ["account_id"], name: "index_balances_on_account_id"
end
create_table "binance_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "binance_item_id", null: false
t.string "name"
t.string "account_type"
t.string "currency"
t.decimal "current_balance", precision: 19, scale: 4
t.jsonb "institution_metadata"
t.jsonb "raw_payload"
t.jsonb "raw_transactions_payload"
t.jsonb "extra", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_type"], name: "index_binance_accounts_on_account_type"
t.index ["binance_item_id", "account_type"], name: "index_binance_accounts_on_item_and_type", unique: true
t.index ["binance_item_id"], name: "index_binance_accounts_on_binance_item_id"
end
create_table "binance_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false
t.string "name"
t.string "institution_name"
t.string "institution_domain"
t.string "institution_url"
t.string "institution_color"
t.string "status", default: "good"
t.boolean "scheduled_for_deletion", default: false
t.boolean "pending_account_setup", default: false
t.datetime "sync_start_date"
t.jsonb "raw_payload"
t.text "api_key"
t.text "api_secret"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["family_id"], name: "index_binance_items_on_family_id"
t.index ["status"], name: "index_binance_items_on_status"
end
create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "budget_id", null: false
t.uuid "category_id", null: false
@@ -537,7 +574,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do
t.string "moniker", default: "Family", null: false
t.string "assistant_type", default: "builtin", null: false
t.string "default_account_sharing", default: "shared", null: false
t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying, 'private'::character varying]::text[])", name: "chk_families_default_account_sharing"
t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing"
t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range"
end
@@ -1536,6 +1573,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "api_keys", "users"
add_foreign_key "balances", "accounts", on_delete: :cascade
add_foreign_key "binance_accounts", "binance_items"
add_foreign_key "binance_items", "families"
add_foreign_key "budget_categories", "budgets"
add_foreign_key "budget_categories", "categories"
add_foreign_key "budgets", "families"

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")
# For depository accounts, processor prefers posted, then transacted; posted==0 should be treated as missing
assert_equal Time.at(t_epoch).to_date, entry.date, "expected entry.date to use transacted_at when posted==0"
assert_equal Time.at(t_epoch).utc.to_date, entry.date, "expected entry.date to use transacted_at when posted==0"
sf = entry.transaction.extra.fetch("simplefin")
assert_equal true, sf["pending"], "expected pending flag to be true when posted==0 and/or pending=true"
end