Files
sure/app/models/binance_item.rb
T
buzzromainandClaude Opus 5 843e91c257 fix(binance): let go of an asset the wallet no longer holds (#3234)
* fix(binance): let go of an asset the wallet no longer holds

Reported: a coin that had been sold stayed in the crypto account, showing up
alongside the ones still held. Two separate causes, both of which had to go.

The holdings processor only ever wrote what Binance returned. Nothing removed
what it stopped returning, and the account page reads one day's rows — so a
coin sold between two syncs kept its place for the rest of the day. Of the nine
provider holdings processors, only CoinstatsAccount's removes anything; this
follows it.

The removal is keyed on what the payload contains rather than on what was
successfully imported. An asset whose price cannot be fetched is skipped, and
deleting on that basis would turn a price outage into a vanished holding.

The importer made it permanent. Every sub-importer swallows its own error and
answers with an empty asset list, so a total outage reached the upsert looking
exactly like an emptied wallet — and the upsert was skipped, leaving the
previous payload in place for the holdings processor to re-import as today's
holdings. An asset already sold came back on every sync, indefinitely. A
complete outage now raises, so the sync fails instead of reporting a successful
import of nothing, and an emptied wallet is written down rather than skipped
whenever there is an account to correct.

A blank payload and a missing one are no longer the same thing: a wallet
nothing has reported on yet keeps its holdings, since removing them would be
deleting on the strength of missing information.

The import failure is also recorded through DebugLogEntry now, so support can
see it against the connection rather than only in the application log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): do not remove what an unavailable source never reported

Review on #3234, two ways the new cleanup could delete live positions.

A source that fails tells us nothing about what it holds, but the importer
wrote only the sources that answered — and the holdings processor removes what
is missing from that list. A transient margin error, or a key without margin
permission, would therefore have deleted every margin position. Their last
known assets are carried instead, and only while the source stays silent: once
it answers, what it says is what stands, including an asset it has stopped
reporting.

Worse, the guard against a total outage could not fire. EarnImporter's two
sub-requests each rescue to nil, so a double failure returned an empty asset
list with no error at all — indistinguishable from an account holding no Earn
positions. With spot, margin and futures all failing, `results.all?` was still
false, the importer wrote an empty wallet, and the cleanup emptied the
portfolio. Earn reports the double failure now, carrying both messages.

Also from review: the account_provider lookup added for the debug entry walked
a lazy has_one per account. It eager-loads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): keep the Earn side that went silent

Review on #3234. The carry-over works per source, and Earn is one source made
of two calls — so a single failing call slipped through it. With flexible
answering and locked failing, the result carried no error, nothing was carried,
and the cleanup removed every locked position. A key without locked permission
would have deleted them on the first sync.

Reporting the whole source as failed would have been wrong the other way: it
would discard the side that did answer, and freeze Earn entirely for anyone
whose key can only read one of the two.

Every asset already keeps its flexible and locked amounts apart, so the side
that went silent is refilled from what it last reported while the working side
stays fresh. Three tests: the silent side keeps its position, an asset held
only on the silent side survives, and a single failure is still not reported as
an outage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): record a partly-read wallet where support can see it

Review on #3234. A source failing on its own returns normally, so the import
never reaches the rescue in BinanceItem#import_latest_binance_data that writes
the debug entry. The sync reported success, and the only trace that part of the
wallet went unread was an application log line — and none at all when there was
nothing to carry.

It goes through DebugLogEntry now, with the sources that were unavailable and
what each of them said, per the repo's provider-sync guidance. The warning
stays, since it names how many assets were carried, which the entry does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): record an Earn endpoint that stopped answering

Review on #3234. One Earn side failing does not fail the import, so the parent
never reaches record_partial_failure — and /settings/debug showed nothing about
an endpoint that had stopped answering, while positions were being carried
precisely because of it.

Same shape as the parent's entry: a provider_sync warning naming the endpoints
that went silent and what each of them said. The per-endpoint errors were
already collected for the double-failure message; they are keyed by endpoint
now so the entry can say which one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:14:42 +02:00

170 lines
5.1 KiB
Ruby

# 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
DebugLogEntry.capture(
category: "provider_sync",
level: "error",
message: "Binance import failed",
source: self.class.name,
provider_key: "binance",
family: family,
account_provider: binance_accounts.includes(:account_provider).filter_map(&:account_provider).first,
metadata: { binance_item_id: id, error_class: e.class.name, error: e.message }
)
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