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