Files
sure/test/models/binance_account/processor_test.rb
Brian Richard 174f7e6be6 feat(binance): add full account sync and transaction processing (#1822)
* feat(binance): add full account sync and transaction processing

- Fixed a bug that hindered Account setup
- Wire up Binance accounts, sync statistics, and unlinked account tracking in the accounts dashboard.
- Support setting a sync_start_date during Binance account setup.
- Set Binance accounts' opening balance to zero to ensure the ledger builds cleanly from the actual trade history.
- Expand the Binance importer and processor to handle Spot, Margin, Earn, P2P, and Futures trades and assets.
- Implement TransactionBuilder to parse raw Binance trades, accurately calculating fees, base/quote asset amounts, and market values for proper ledger integration.
- Update Binance API timeout (`recvWindow`) to 60,000ms to prevent connection drops.

These changes provide comprehensive support for tracking Binance portfolios, ensuring accurate historical ledgers and proper visibility of sync statuses in the frontend dashboard.

* refactor(binance): enforce strong params, double-entry safety, and native fiat currency support

- Implement strong parameters in BinanceItemsController#complete_account_setup to satisfy Rails security guidelines.
- Add robust date parsing with a grace fallback to prevent controller crashes on malformed sync start dates.
- Wrap P2P transaction creations inside a database transaction block to guarantee ledger integrity and prevent orphan records.
- Optimize P2P deduplication queries by batching checks for both transaction and funding external IDs.
- Shift P2P entry persistence from forced USD tracking to native fiat values extracted directly from the Binance API payload.
- Update BinanceAccount::ProcessorTest assertions and fixtures to validate native fiat and fee calculation logic.

* fix(binance): process sync trades before caching transaction payload

- Reorder Binance processor execution to insert trade records into the database prior to updating the `raw_transactions_payload` cache. This guarantees that if a database insertion fails, the cache won't prematurely mark the sync as successful, ensuring the data is retried on the next run.
- Move `set_opening_anchor_balance(balance: 0)` out of the generic crypto exchange account builder and apply it specifically during Binance account creation.
- Refactor date parsing in BinanceItemsController to explicitly catch `ArgumentError` via a block instead of using a blanket inline `rescue`.
- Clean up the `setup_accounts` view template by removing hardcoded default translation strings.

* fix(binance): enhance trade sync logic and error propagation

- Pass `startTime` (from `sync_start_date`) to spot and futures trade endpoints on initial sync to optimize data fetching.
- Include previously synced futures pairs alongside spot pairs when resolving relevant symbols to properly recover sold-out assets.
- Re-raise exceptions in processor rescue blocks to prevent silent failures and ensure errors are correctly propagated to background jobs.
- Decrease Binance API `recvWindow` from 60000ms to 5000ms to align with recommended default timeout values.
2026-05-27 23:58:00 +02:00

206 lines
7.0 KiB
Ruby

# 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
test "processes futures trades correctly" do
@family.update!(currency: "USD")
@ba.update!(raw_payload: { "assets" => [ { "symbol" => "BTC", "total" => "1.0" } ] })
provider = mock
@item.stubs(:binance_provider).returns(provider)
@ba.stubs(:binance_item).returns(@item)
provider.stubs(:get_spot_trades).returns([])
provider.stubs(:get_spot_price).returns("50000.0")
provider.stubs(:get_all_p2p_trades).returns([]) # Skip P2P
# Mock futures trades
provider.stubs(:get_futures_trades).returns([])
provider.stubs(:get_futures_trades).with("BTCUSDT", limit: 1000, from_id: nil, startTime: nil).returns([
{ "id" => 1, "time" => 1610000000000, "qty" => "0.1", "price" => "40000.0", "quoteQty" => "4000.0", "commission" => "0.0", "commissionAsset" => "USDT", "buyer" => true }
])
Security.create!(ticker: "CRYPTO:BTC", name: "Bitcoin", price_provider: "binance_public")
assert_difference "Entry.count", 1 do
BinanceAccount::Processor.new(@ba).process
end
assert @account.entries.exists?(external_id: "binance_futures_BTCUSDT_1")
end
test "processes P2P BUY trades with double-entry logic and exact native fiat" do
@family.update!(currency: "USD")
@account.update!(currency: "USD")
provider = mock
@item.stubs(:binance_provider).returns(provider)
@ba.stubs(:binance_item).returns(@item)
# Silence other importers
provider.stubs(:get_spot_trades).returns([])
provider.stubs(:get_futures_trades).returns([])
# Mock the exact TZS/USDT payload with actual fiat transfer amounts
provider.stubs(:get_all_p2p_trades).returns([
{
"orderNumber" => "22883918231657005056",
"createTime" => 1777736533166,
"tradeType" => "BUY",
"asset" => "USDT",
"fiat" => "TZS",
"totalPrice" => "31500.00",
"unitPrice" => "2746.29",
"amount" => "11.47", # Gross crypto
"takerAmount" => "11.41", # Net crypto
"takerCommission" => "0.06" # Crypto fee
}
])
Security.create!(ticker: "CRYPTO:USDT", name: "Tether", price_provider: "binance_public")
# It MUST create 2 entries: 1 Deposit (Transaction) and 1 Purchase (Trade)
assert_difference "Entry.count", 2 do
BinanceAccount::Processor.new(@ba).process
end
# Verify the Deposit (Transaction) - Should be native fiat
deposit = @account.entries.find_by(external_id: "binance_p2p_22883918231657005056_funding")
assert_not_nil deposit
assert_equal "Transaction", deposit.entryable_type
assert_equal (-31500.00), deposit.amount.to_f # Negative = Fiat Cash INFLOW
assert_equal "TZS", deposit.currency
# Verify the Buy (Trade) - Should reflect the fiat cost basis
trade = @account.entries.find_by(external_id: "binance_p2p_22883918231657005056")
assert_not_nil trade
assert_equal "Trade", trade.entryable_type
assert_equal 31500.00, trade.amount.to_f # Positive = Fiat Cash OUTFLOW
assert_equal "TZS", trade.currency
assert_equal "Buy", trade.entryable.investment_activity_label
# Verify the specific crypto math and fiat fee conversion
assert_equal 11.41, trade.entryable.qty.to_f
# Fiat Fee = Crypto Fee (0.06) * Unit Price (2746.29) = 164.7774 (rounds to 164.78)
assert_equal 164.78, trade.entryable.fee.to_f
end
test "skips processing if P2P external_id already exists" do
@family.update!(currency: "USD")
@account.update!(currency: "USD")
# Pre-create the trade in the database
@account.entries.create!(
date: Date.current,
name: "Existing P2P",
amount: 10,
currency: "USD",
external_id: "binance_p2p_existing_123",
entryable: Transaction.new
)
provider = mock
@item.stubs(:binance_provider).returns(provider)
@ba.stubs(:binance_item).returns(@item)
provider.stubs(:get_spot_trades).returns([])
provider.stubs(:get_futures_trades).returns([])
# Mock a payload with the SAME orderNumber
provider.stubs(:get_all_p2p_trades).returns([
{ "orderNumber" => "existing_123", "tradeType" => "BUY", "asset" => "USDT", "amount" => "10.0" }
])
Security.create!(ticker: "CRYPTO:USDT", name: "Tether", price_provider: "binance_public")
# Assert that NO new entries are created
assert_no_difference "Entry.count" do
BinanceAccount::Processor.new(@ba).process
end
end
end