fix(snaptrade): import non-primary-currency cash as cash holdings (#1979)

* fix(snaptrade): import non-primary-currency cash as cash holdings

Fixes #1809.

SnaptradeAccount#upsert_balances! picked a single cash entry (account
currency -> USD -> first) and stored only that in cash_balance; every
other currency's cash was discarded. A moomoo Canada account with CAD
$500 + USD $1000 imported only the CAD.

Persist the full balances snapshot (new raw_balances_payload column) and
surface each non-primary-currency cash entry as a synthetic per-currency
cash holding (Security.cash_for(account, currency:)), mirroring the
existing cash-security pattern. The primary currency stays in
cash_balance. HoldingsProcessor now also runs for cash-only balances, and
the Processor invokes it when there are holdings OR non-primary cash.
Cash holdings use a stable external_id so repeated syncs update rather
than duplicate.

* fix(snaptrade): encrypt raw_balances_payload and drop cash amount from log

Addresses PR #1979 review: Codex P1 (encrypt the newly persisted balances snapshot at rest, matching the other raw provider payloads) and CodeRabbit nitpick (do not log monetary amounts at info level).

* refactor(snaptrade): extract primary_cash_entry and harden balances test

PR #1979 review: extract the shared account-currency->USD->first cash selection into a private helper (CodeRabbit DRY nitpick); reorder the upsert_balances! test so the primary currency is not first, proving dig(:currency,:code) resolves it on string-keyed payloads rather than the entries.first fallback (jjmata).
This commit is contained in:
dripsmvcp
2026-05-31 06:29:37 +09:00
committed by GitHub
parent ea316b4277
commit f7df709e6d
7 changed files with 183 additions and 32 deletions

View File

@@ -320,4 +320,78 @@ class SnaptradeAccountProcessorTest < ActiveSupport::TestCase
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
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