Files
sure/test/models/snaptrade_account_processor_test.rb
T
Scott HughesandClaude Opus 5 5bba880e66 Fix SnapTrade holdings by using the /positions/all endpoint (#3043)
* Fix SnapTrade holdings by using the /positions/all endpoint

SnapTrade returns HTTP 410 Gone on /positions, /holdings and /options for
apps registered after their 2026 cutoff, so newly connected accounts import
their balance but never any holdings. The importer rescues the error and the
sync still reports success, which makes it look like the brokerage simply
holds nothing.

Switch get_positions to the documented replacement, /positions/all, and teach
the shared payload helpers to read its flat `instrument` object alongside the
legacy nested `symbol.symbol` shape.

Fixes #3029

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Filter unsupported instrument kinds before they reach the payload

Derivatives skipped by HoldingsProcessor still landed in
raw_holdings_payload, where calculate_holdings_value sums units * price
over every entry. Any non-zero sum then displaces SnapTrade's own figure
in calculate_total_balance, so an options-only account could report a
balance derived from per-contract units against a per-share price.

Move the denylist to Provider::Snaptrade#get_positions so unsupported
kinds never enter the payload at all, keeping holdings, balance, currency
detection and cash-equivalent handling consistent with one filter.

Also raise when the positions response carries no results array, so a
partial or schema-changed response leaves the previous snapshot in place
instead of overwriting it with nothing. An empty results array remains a
legitimately empty account.

Note that tax_lots[].cost_basis is documented as a whole-lot total,
unlike the per-share field this reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 06:00:43 +02:00

622 lines
20 KiB
Ruby

require "test_helper"
class SnaptradeAccountProcessorTest < ActiveSupport::TestCase
fixtures :families, :snaptrade_items, :snaptrade_accounts, :accounts, :securities
setup do
@family = families(:dylan_family)
@snaptrade_item = snaptrade_items(:configured_item)
@snaptrade_account = snaptrade_accounts(:fidelity_401k)
# Create and link a Sure investment account
@account = @family.accounts.create!(
name: "Test Investment",
balance: 50000,
cash_balance: 1500,
currency: "USD",
accountable: Investment.new
)
@snaptrade_account.ensure_account_provider!(@account)
@snaptrade_account.reload
end
# === HoldingsProcessor Tests ===
test "holdings processor creates holdings from raw payload" do
security = securities(:aapl)
@snaptrade_account.update!(
raw_holdings_payload: [
{
"symbol" => {
"symbol" => { "symbol" => security.ticker, "description" => security.name }
},
"units" => "100.5",
"price" => "150.25",
"currency" => "USD"
}
]
)
processor = SnaptradeAccount::HoldingsProcessor.new(@snaptrade_account)
processor.process
holding = @account.holdings.find_by(security: security)
assert_not_nil holding
assert_equal BigDecimal("100.5"), holding.qty
assert_equal BigDecimal("150.25"), holding.price
end
test "holdings processor stores cost basis when available" do
security = securities(:aapl)
@snaptrade_account.update!(
raw_holdings_payload: [
{
"symbol" => {
"symbol" => { "symbol" => security.ticker, "description" => security.name }
},
"units" => "50",
"price" => "175.00",
"average_purchase_price" => "125.50",
"currency" => "USD"
}
]
)
processor = SnaptradeAccount::HoldingsProcessor.new(@snaptrade_account)
processor.process
holding = @account.holdings.find_by(security: security)
assert_not_nil holding
assert_equal BigDecimal("125.50"), holding.cost_basis
assert_equal "provider", holding.cost_basis_source
end
test "holdings processor does not overwrite manual cost basis" do
security = securities(:aapl)
# Create holding with manual cost basis
holding = @account.holdings.create!(
security: security,
date: Date.current,
currency: "USD",
qty: 50,
price: 175.00,
amount: 8750.00,
cost_basis: 100.00,
cost_basis_source: "manual"
)
@snaptrade_account.update!(
raw_holdings_payload: [
{
"symbol" => {
"symbol" => { "symbol" => security.ticker }
},
"units" => "50",
"price" => "175.00",
"average_purchase_price" => "125.50",
"currency" => "USD"
}
]
)
processor = SnaptradeAccount::HoldingsProcessor.new(@snaptrade_account)
processor.process
holding.reload
assert_equal BigDecimal("100.00"), holding.cost_basis
assert_equal "manual", holding.cost_basis_source
end
test "holdings processor skips entries without ticker" do
@snaptrade_account.update!(
raw_holdings_payload: [
{
"symbol" => { "symbol" => {} }, # Missing ticker
"units" => "100",
"price" => "50.00"
}
]
)
processor = SnaptradeAccount::HoldingsProcessor.new(@snaptrade_account)
assert_nothing_raised do
processor.process
end
assert_equal 0, @account.holdings.count
end
test "processor trusts API total for multi-currency holdings" do
security = securities(:aapl)
Account.any_instance.stubs(:set_current_balance)
@snaptrade_account.update!(
currency: "CHF",
current_balance: BigDecimal("15000.00"),
cash_balance: BigDecimal("1000.00"),
raw_holdings_payload: [
{
"symbol" => {
"symbol" => { "symbol" => security.ticker, "description" => security.name }
},
"units" => "10",
"price" => "150.00",
"currency" => "USD",
"average_purchase_price" => "125.50"
}
],
raw_activities_payload: []
)
SnaptradeAccount::Processor.new(@snaptrade_account).process
@account.reload
assert_equal BigDecimal("15000.00"), @account.balance
assert_equal BigDecimal("1000.00"), @account.cash_balance
assert_equal "CHF", @account.currency
end
# === /positions/all payload shape ===
test "holdings processor creates holdings from a positions/all payload" do
security = securities(:aapl)
@snaptrade_account.update!(
raw_holdings_payload: [
{
"instrument" => {
"kind" => "stock",
"symbol" => security.ticker,
"raw_symbol" => security.ticker,
"description" => security.name,
"currency" => "USD",
"exchange" => "XNAS"
},
"units" => "10",
"price" => "77.885",
"cost_basis" => "30",
"currency" => "USD"
}
]
)
SnaptradeAccount::HoldingsProcessor.new(@snaptrade_account).process
holding = @account.holdings.find_by(security: security)
assert_not_nil holding
assert_equal BigDecimal("10"), holding.qty
assert_equal BigDecimal("77.885"), holding.price
end
test "holdings processor reads cost_basis as a per-share value" do
security = securities(:aapl)
@snaptrade_account.update!(
raw_holdings_payload: [
{
"instrument" => { "kind" => "stock", "symbol" => security.ticker, "currency" => "USD" },
"units" => "10",
"price" => "77.885",
"cost_basis" => "30",
"currency" => "USD"
}
]
)
SnaptradeAccount::HoldingsProcessor.new(@snaptrade_account).process
holding = @account.holdings.find_by(security: security)
assert_not_nil holding
assert_equal BigDecimal("30"), holding.cost_basis
assert_equal "provider", holding.cost_basis_source
end
test "cash-equivalent positions are recognised in a positions/all payload" do
security = securities(:aapl)
@snaptrade_account.update!(
currency: "USD",
cash_balance: BigDecimal("1000.00"),
raw_holdings_payload: [
{
"instrument" => { "kind" => "mutualfund", "symbol" => security.ticker, "currency" => "USD" },
"units" => "100",
"price" => "1.00",
"currency" => "USD",
"cash_equivalent" => true
}
]
)
assert_equal BigDecimal("100"), @snaptrade_account.cash_equivalent_position_value("USD")
end
# === ActivitiesProcessor Tests ===
test "activities processor maps BUY type to Buy label" do
security = securities(:aapl)
@snaptrade_account.update!(
raw_activities_payload: [
{
"id" => "activity_buy_1",
"type" => "BUY",
"symbol" => { "symbol" => security.ticker, "description" => security.name },
"units" => "10",
"price" => "150.00",
"amount" => "1500.00",
"settlement_date" => Date.current.to_s,
"currency" => "USD"
}
]
)
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
result = processor.process
assert_equal 1, result[:trades]
trade_entry = @account.entries.find_by(external_id: "activity_buy_1")
assert_not_nil trade_entry
assert_equal "Buy", trade_entry.entryable.investment_activity_label
end
test "activities processor maps SELL type with negative quantity" do
security = securities(:aapl)
@snaptrade_account.update!(
raw_activities_payload: [
{
"id" => "activity_sell_1",
"type" => "SELL",
"symbol" => { "symbol" => security.ticker },
"units" => "5",
"price" => "175.00",
"amount" => "875.00",
"settlement_date" => Date.current.to_s,
"currency" => "USD"
}
]
)
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
result = processor.process
assert_equal 1, result[:trades]
trade_entry = @account.entries.find_by(external_id: "activity_sell_1")
assert trade_entry.entryable.qty.negative?
assert_equal "Sell", trade_entry.entryable.investment_activity_label
end
test "activities processor handles DIVIDEND as cash transaction" do
@snaptrade_account.update!(
raw_activities_payload: [
{
"id" => "activity_div_1",
"type" => "DIVIDEND",
"symbol" => { "symbol" => "AAPL" },
"amount" => "25.50",
"settlement_date" => Date.current.to_s,
"currency" => "USD",
"description" => "AAPL Dividend Payment"
}
]
)
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
result = processor.process
assert_equal 1, result[:transactions]
tx_entry = @account.entries.find_by(external_id: "activity_div_1")
assert_not_nil tx_entry
assert_equal "Transaction", tx_entry.entryable_type
assert_equal "Dividend", tx_entry.entryable.investment_activity_label
end
test "activities processor normalizes withdrawal as positive outflow amount" do
@snaptrade_account.update!(
raw_activities_payload: [
{
"id" => "activity_withdraw_1",
"type" => "WITHDRAWAL",
"amount" => "1000.00", # Provider sends positive
"settlement_date" => Date.current.to_s,
"currency" => "USD"
}
]
)
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
result = processor.process
assert_equal 1, result[:transactions]
tx_entry = @account.entries.find_by(external_id: "activity_withdraw_1")
assert_equal 1000.00, tx_entry.amount.to_f
end
test "activities processor skips activities without external_id" do
@snaptrade_account.update!(
raw_activities_payload: [
{
"type" => "DIVIDEND",
"amount" => "50.00"
# Missing "id" field
}
]
)
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
result = processor.process
assert_equal 0, result[:transactions]
assert_equal 0, result[:trades]
end
test "activities processor handles unmapped types as Other" do
@snaptrade_account.update!(
raw_activities_payload: [
{
"id" => "activity_unknown_1",
"type" => "UNKNOWN_TYPE_XYZ",
"amount" => "100.00",
"settlement_date" => Date.current.to_s,
"currency" => "USD"
}
]
)
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
result = processor.process
assert_equal 1, result[:transactions]
tx_entry = @account.entries.find_by(external_id: "activity_unknown_1")
assert_equal "Other", tx_entry.entryable.investment_activity_label
end
test "activities processor is idempotent with same external_id" do
@snaptrade_account.update!(
raw_activities_payload: [
{
"id" => "activity_idempotent_1",
"type" => "DIVIDEND",
"amount" => "75.00",
"settlement_date" => Date.current.to_s,
"currency" => "USD"
}
]
)
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
processor.process
processor.process # Process again
entries = @account.entries.where(external_id: "activity_idempotent_1")
assert_equal 1, entries.count
end
# === Multi-currency cash (issue #1809) ===
test "upsert_balances! persists all entries and keeps the primary currency in cash_balance" do
@snaptrade_account.update!(currency: "USD")
# Primary (USD) is intentionally NOT first so this asserts the
# account-currency selection actually resolves it via dig(:currency, :code)
# on the string-keyed payload — not the `entries.first` fallback.
@snaptrade_account.upsert_balances!([
{ "currency" => { "code" => "EUR" }, "cash" => "800.00" },
{ "currency" => { "code" => "USD" }, "cash" => "1500.00" }
])
@snaptrade_account.reload
assert_equal BigDecimal("1500.00"), @snaptrade_account.cash_balance, "primary (USD) cash stays in cash_balance"
assert_equal 2, @snaptrade_account.raw_balances_payload.size, "all balance entries are persisted, not just the primary"
non_primary = @snaptrade_account.non_primary_cash_entries
assert_equal 1, non_primary.size
assert_equal "EUR", non_primary.first[:currency]
end
test "holdings processor surfaces non-primary-currency cash as a synthetic holding" do
@snaptrade_account.update!(
currency: "USD",
cash_balance: BigDecimal("1500.00"),
raw_balances_payload: [
{ "currency" => { "code" => "USD" }, "cash" => "1500.00" },
{ "currency" => { "code" => "EUR" }, "cash" => "800.00" }
]
)
SnaptradeAccount::HoldingsProcessor.new(@snaptrade_account).process
eur_cash = @account.holdings.joins(:security).where(securities: { kind: "cash" }, currency: "EUR").order(date: :desc).first
assert_not_nil eur_cash, "EUR cash must be imported as a synthetic cash holding"
assert_equal BigDecimal("800"), eur_cash.qty
assert eur_cash.security.cash?, "the holding's security is a synthetic cash security"
usd_cash = @account.holdings.joins(:security).where(securities: { kind: "cash" }, currency: "USD").exists?
assert_not usd_cash, "primary (USD) cash stays in cash_balance, not duplicated as a holding"
end
test "processor surfaces non-primary cash even when there are no security holdings" do
@snaptrade_account.update!(
currency: "USD",
raw_holdings_payload: [],
raw_balances_payload: [
{ "currency" => { "code" => "USD" }, "cash" => "1500.00" },
{ "currency" => { "code" => "EUR" }, "cash" => "800.00" }
]
)
SnaptradeAccount::Processor.new(@snaptrade_account).process
eur_cash = @account.holdings.joins(:security).where(securities: { kind: "cash" }, currency: "EUR").exists?
assert eur_cash, "processor must run the holdings processor so secondary-currency cash is surfaced even with no stock holdings"
end
# === Cash-equivalent positions (money market / sweep funds) ===
#
# SnapTrade includes money market funds in the balances endpoint's `cash`
# figure AND returns them as positions with `cash_equivalent: true`.
# Counting both inflates the account total (e.g. Fidelity SPAXX).
test "processor does not double count cash-equivalent positions included in cash balance" do
security = securities(:aapl)
Account.any_instance.stubs(:set_current_balance)
@snaptrade_account.update!(
currency: "USD",
cash_balance: BigDecimal("5000.00"),
raw_holdings_payload: [
{
"symbol" => {
"symbol" => { "symbol" => "SPAXX", "description" => "Fidelity Government Money Market Fund" }
},
"units" => "4000",
"price" => "1.00",
"currency" => "USD",
"cash_equivalent" => true
},
{
"symbol" => {
"symbol" => { "symbol" => security.ticker, "description" => security.name }
},
"units" => "10",
"price" => "150.00",
"currency" => "USD"
}
],
raw_activities_payload: []
)
SnaptradeAccount::Processor.new(@snaptrade_account).process
@account.reload
assert_equal BigDecimal("1000.00"), @account.cash_balance, "cash-equivalent position value must be subtracted from cash"
assert_equal BigDecimal("6500.00"), @account.balance, "total must count the money market fund only once (1500 stock + 4000 MMF + 1000 cash)"
spaxx = @account.holdings.joins(:security).where(securities: { ticker: "SPAXX" }).order(date: :desc).first
assert_not_nil spaxx, "the cash-equivalent position is still imported as a holding"
assert_equal BigDecimal("4000"), spaxx.amount
debug_entries = DebugLogEntry.where(category: "provider_sync", provider_key: "snaptrade")
assert_equal 1, debug_entries.count, "the exclusion is recorded once in /settings/debug"
assert_equal "4000.0", debug_entries.first.metadata["cash_equivalent_value"]
end
test "cash balance may go negative when cash-equivalent value exceeds reported cash and is recorded for support" do
Account.any_instance.stubs(:set_current_balance)
# A stale holdings snapshot (or real margin) can make the cash-equivalent
# value exceed reported cash. No floor is applied — negative cash is
# legitimate for margin — but before/after values are recorded so support
# can tell the two apart in /settings/debug.
@snaptrade_account.update!(
currency: "USD",
cash_balance: BigDecimal("100.00"),
raw_holdings_payload: [
{
"symbol" => {
"symbol" => { "symbol" => "SPAXX", "description" => "Fidelity Government Money Market Fund" }
},
"units" => "4000",
"price" => "1.00",
"currency" => "USD",
"cash_equivalent" => true
}
],
raw_activities_payload: []
)
SnaptradeAccount::Processor.new(@snaptrade_account).process
@account.reload
assert_equal BigDecimal("-3900.00"), @account.cash_balance, "negative cash is preserved, not floored"
entry = DebugLogEntry.where(category: "provider_sync", provider_key: "snaptrade").order(created_at: :desc).first
assert_equal "100.0", entry.metadata["cash_balance_before"]
assert_equal "-3900.0", entry.metadata["cash_balance_after"]
end
test "cash-equivalent positions are subtracted in the stored cash currency when it falls back to USD" do
Account.any_instance.stubs(:set_current_balance)
# CAD account with no CAD cash entry: upsert_balances!' USD fallback means
# cash_balance is denominated in USD, so the USD money market fund must be
# subtracted from it even though it doesn't match the account currency.
@snaptrade_account.update!(
currency: "CAD",
current_balance: BigDecimal("10000.00"),
cash_balance: BigDecimal("5000.00"),
raw_balances_payload: [
{ "currency" => { "code" => "USD" }, "cash" => "5000.00" }
],
raw_holdings_payload: [
{
"symbol" => {
"symbol" => { "symbol" => "SPAXX", "description" => "Fidelity Government Money Market Fund" }
},
"units" => "4000",
"price" => "1.00",
"currency" => "USD",
"cash_equivalent" => true
}
],
raw_activities_payload: []
)
SnaptradeAccount::Processor.new(@snaptrade_account).process
@account.reload
assert_equal BigDecimal("1000.00"), @account.cash_balance, "USD cash-equivalent position must be subtracted from the USD-denominated cash balance"
assert_equal BigDecimal("10000.00"), @account.balance, "multi-currency holdings still use the API total"
end
test "cash-equivalent positions in a non-primary currency reduce that currency's synthetic cash holding" do
@snaptrade_account.update!(
currency: "USD",
cash_balance: BigDecimal("1500.00"),
raw_balances_payload: [
{ "currency" => { "code" => "USD" }, "cash" => "1500.00" },
{ "currency" => { "code" => "EUR" }, "cash" => "800.00" }
],
raw_holdings_payload: [
{
"symbol" => {
"symbol" => { "symbol" => "EURMM", "description" => "Euro Money Market Fund" }
},
"units" => "300",
"price" => "1.00",
"currency" => "EUR",
"cash_equivalent" => true
}
]
)
SnaptradeAccount::HoldingsProcessor.new(@snaptrade_account).process
eur_cash = @account.holdings.joins(:security).where(securities: { kind: "cash" }, currency: "EUR").order(date: :desc).first
assert_not_nil eur_cash
assert_equal BigDecimal("500"), eur_cash.qty, "EUR synthetic cash must exclude the EUR cash-equivalent position (800 - 300)"
eur_mmf = @account.holdings.joins(:security).where(securities: { ticker: "EURMM" }).order(date: :desc).first
assert_not_nil eur_mmf, "the EUR cash-equivalent position is still imported as a holding"
end
test "non-primary cash holding is not duplicated across repeated syncs" do
@snaptrade_account.update!(
currency: "USD",
raw_balances_payload: [
{ "currency" => { "code" => "USD" }, "cash" => "1500.00" },
{ "currency" => { "code" => "EUR" }, "cash" => "800.00" }
]
)
2.times { SnaptradeAccount::HoldingsProcessor.new(@snaptrade_account).process }
eur_cash = @account.holdings.joins(:security).where(securities: { kind: "cash" }, currency: "EUR")
assert_equal 1, eur_cash.select(:external_id).distinct.count
end
end