Skip to content

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

Open
dripsmvcp wants to merge 4 commits into
we-promise:mainfrom
dripsmvcp:fix/1809-snaptrade-multicurrency-cash
Open

fix(snaptrade): import non-primary-currency cash as cash holdings#1979
dripsmvcp wants to merge 4 commits into
we-promise:mainfrom
dripsmvcp:fix/1809-snaptrade-multicurrency-cash

Conversation

@dripsmvcp
Copy link
Copy Markdown
Contributor

@dripsmvcp dripsmvcp commented May 25, 2026

Summary

Fixes #1809. SnapTrade brokerage accounts holding cash in multiple currencies only imported the primary currency's cash — every other currency was silently discarded (e.g. a moomoo Canada account with CAD $500 + USD $1000 imported only the CAD). Non-primary cash is now surfaced as per-currency synthetic cash holdings.

Root cause

SnaptradeAccount#upsert_balances! selected a single cash entry (account currency → USD → first) into cash_balance and dropped the rest.

Fix

  • New raw_balances_payload column persists the full balances snapshot (was thrown away).
  • upsert_balances! keeps the primary currency in cash_balance and stores all entries; non_primary_cash_entries excludes the actual primary (including the USD fallback) so nothing double-counts.
  • Security.cash_for(account, currency:) gains an optional currency → distinct per-currency synthetic cash security ("Cash (USD)"); existing no-arg callers unchanged.
  • HoldingsProcessor emits a synthetic cash holding per non-primary currency with a stable external_id (idempotent across re-syncs), and runs even when there are no security holdings.
  • Processor invokes the holdings processor when there are holdings or non-primary cash.

Test plan

  • New model tests: persist-all-entries, primary-stays-in-cash_balance, non-primary→holding, no-duplication across syncs, processor-path with no stock holdings, plus cash_for per-currency variants.
  • 45 affected tests pass (snaptrade_account_processor_test, snaptrade_account_test, security_test, activities_processor_test); rubocop + brakeman clean.

Before / after

A CAD account with CAD $500 + USD $1000 cash:

  • Before: Holdings shows only CA$500.00; USD discarded.
  • After: Holdings shows CA$500.00 (cash balance) and Cash (USD) $1,000.00.
image

Summary by CodeRabbit

  • New Features

    • Multi-currency cash support: non-primary currency cash is tracked and shown as distinct synthetic cash holdings labeled by currency.
  • Improvements

    • Balance sync now persists full encrypted multi-currency snapshots and retains primary-currency cash as the main balance.
    • Processor surfaces secondary-currency cash even when no securities exist and avoids duplicate synthetic holdings across syncs.
    • Logging around balance processing improved.
  • Tests

    • Added tests covering multi-currency cash handling and sync behavior.
  • Chores

    • Added storage for raw balances snapshots (DB migration).

Review Change Stack

Fixes we-promise#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.
@superagent-security superagent-security Bot added the contributor:verified Contributor passed trust analysis. label May 25, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9489a72a-09d8-4d73-bab2-4e0a30d7cb71

📥 Commits

Reviewing files that changed from the base of the PR and between 4db3561 and 3eb97e4.

📒 Files selected for processing (2)
  • app/models/snaptrade_account.rb
  • test/models/snaptrade_account_processor_test.rb

📝 Walkthrough

Walkthrough

Persists full SnapTrade balance snapshots, selects a primary cash entry for cash_balance, exposes non-primary cash entries, resolves per-currency cash securities, and imports secondary-currency cash as synthetic holdings; adds migration, tests, and logging.

Changes

Multi-currency cash holdings on SnapTrade

Layer / File(s) Summary
Database schema and migration
db/migrate/20260525121841_add_raw_balances_payload_to_snaptrade_accounts.rb, db/schema.rb
Adds raw_balances_payload JSONB column to snaptrade_accounts with default empty array and adjusts schema/check constraints.
Per-currency cash security creation
app/models/security.rb
Security.cash_for now accepts currency: and creates/resolves per-currency cash securities with currency-specific ticker and name when currency differs from account primary.
Balance snapshot and non-primary cash extraction
app/models/snaptrade_account.rb
Adds encrypts :raw_balances_payload; upsert_balances! persists normalized balances to raw_balances_payload, selects primary cash (account currency → USD → first entry) for cash_balance; new non_primary_cash_entries returns remaining { currency, amount } pairs and a shared primary_cash_entry helper.
Synthetic cash holdings processing
app/models/snaptrade_account/holdings_processor.rb, app/models/snaptrade_account/processor.rb
HoldingsProcessor#process always calls process_cash_holdings; process_cash_holdings iterates non_primary_cash_entries, resolves per-currency cash via Security.cash_for, and imports synthetic holdings with price: 1 and delete_future_holdings: false. Processor.process triggers processing when non-primary entries exist even without raw_holdings_payload.
Multi-currency cash test coverage
test/models/snaptrade_account_processor_test.rb
Tests cover persistence of raw_balances_payload, primary cash_balance preservation, synthetic non-primary cash imports (including when no securities exist), and idempotency across repeated processor runs.

Sequence Diagram

sequenceDiagram
  participant Processor
  participant HoldingsProcessor
  participant Account
  participant Security
  participant ImportAdapter

  Processor->>HoldingsProcessor: run when raw_holdings_payload.present? OR non_primary_cash_entries.any?
  HoldingsProcessor->>Account: non_primary_cash_entries
  Account-->>HoldingsProcessor: [{currency, amount}...]
  loop for each non-primary entry
    HoldingsProcessor->>Security: Security.cash_for(account, currency: entry.currency)
    Security-->>HoldingsProcessor: cash_security
    HoldingsProcessor->>ImportAdapter: import_holding(quantity=amount, amount=amount, price=1, external_id)
    ImportAdapter-->>HoldingsProcessor: import result
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • we-promise/sure#815: Updates encrypted payload handling for SnapTrade-related fields (similar raw_balances_payload encryption additions).

Suggested reviewers

  • jjmata

🐰 I tunneled through ledgers, small and grand,
I saved each coin from sea and land.
Primary stays put, extras find a bed,
Synthetic carrots for each currency spread.
Hop—no cash lost, every holding fed.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(snaptrade): import non-primary-currency cash as cash holdings' directly and precisely describes the main change: importing non-primary-currency cash positions as synthetic cash holdings.
Linked Issues check ✅ Passed All coding requirements from issue #1809 are met: raw_balances_payload persists full snapshot, upsert_balances! keeps primary currency in cash_balance while storing all entries, non_primary_cash_entries filters correctly, Security.cash_for accepts optional currency parameter, HoldingsProcessor creates synthetic holdings for non-primary currencies with stable external_ids, and Processor invokes holdings processing when non-primary cash exists.
Out of Scope Changes check ✅ Passed All changes are directly scoped to issue #1809: adding raw_balances_payload column, modifying upsert_balances! and HoldingsProcessor to handle multi-currency cash, updating Security.cash_for with currency parameter, and adjusting Processor logic. Migration and schema updates are necessary supporting changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@superagent-security superagent-security Bot added the pr:verified PR passed security analysis. label May 25, 2026
@dripsmvcp
Copy link
Copy Markdown
Contributor Author

@dosu

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f0eba9cc34

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

# Only update cash_balance, preserve current_balance (total account value)
update!(cash_balance: cash_value)
# Only update cash_balance, preserve current_balance (total account value)
update!(cash_balance: cash_value, raw_balances_payload: data)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Encrypt newly persisted balances payload

upsert_balances! now stores the full SnapTrade balances snapshot in raw_balances_payload, but this attribute is not added to the model’s encrypted fields (only raw_payload, raw_transactions_payload, raw_holdings_payload, and raw_activities_payload are encrypted). In environments where ActiveRecord encryption is enabled, this writes account cash-by-currency data in plaintext at rest, which is a regression in handling provider raw payload sensitivity and should be fixed by encrypting this column too.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/models/snaptrade_account/holdings_processor.rb (1)

53-53: 💤 Low value

Avoid logging monetary amounts at info level.

Per project learnings, financial event code should not log sensitive monetary amounts at info level. Consider lowering to debug or removing the amount from the log message.

Suggested change
-        Rails.logger.info "SnaptradeAccount::HoldingsProcessor - Importing #{entry[:currency]} cash holding: #{amount}"
+        Rails.logger.info "SnaptradeAccount::HoldingsProcessor - Importing #{entry[:currency]} cash holding"

Based on learnings: "In financial event code (e.g., settlement/maturity flows in app/models/**/*.rb), do not log sensitive monetary amounts (such as gross, tax, net) at info level."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/snaptrade_account/holdings_processor.rb` at line 53, The log in
SnaptradeAccount::HoldingsProcessor that currently prints monetary amounts (the
Rails.logger.info call "SnaptradeAccount::HoldingsProcessor - Importing
#{entry[:currency]} cash holding: #{amount}") should not output sensitive
amounts at info level; change this call to either Rails.logger.debug and keep
the amount only at debug level, or remove the #{amount} from the message and
keep an info-level log that only includes non-sensitive context (e.g., currency
and action). Locate the log statement in the HoldingsProcessor and update it so
amounts are never emitted at info level.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/models/snaptrade_account/holdings_processor.rb`:
- Line 53: The log in SnaptradeAccount::HoldingsProcessor that currently prints
monetary amounts (the Rails.logger.info call
"SnaptradeAccount::HoldingsProcessor - Importing #{entry[:currency]} cash
holding: #{amount}") should not output sensitive amounts at info level; change
this call to either Rails.logger.debug and keep the amount only at debug level,
or remove the #{amount} from the message and keep an info-level log that only
includes non-sensitive context (e.g., currency and action). Locate the log
statement in the HoldingsProcessor and update it so amounts are never emitted at
info level.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7375c7c5-b6fc-4e59-81d6-8cd3a2fba454

📥 Commits

Reviewing files that changed from the base of the PR and between 8f5454a and f0eba9c.

📒 Files selected for processing (7)
  • app/models/security.rb
  • app/models/snaptrade_account.rb
  • app/models/snaptrade_account/holdings_processor.rb
  • app/models/snaptrade_account/processor.rb
  • db/migrate/20260525121841_add_raw_balances_payload_to_snaptrade_accounts.rb
  • db/schema.rb
  • test/models/snaptrade_account_processor_test.rb

@dosubot
Copy link
Copy Markdown

dosubot Bot commented May 25, 2026

Nice PR! The approach of using synthetic cash holdings for non-primary currencies is architecturally consistent with existing patterns in the codebase — raw_balances_payload follows the same convention as raw_holdings_payload and raw_activities_payload [1], and leveraging Security.cash_for [2] keeps things unified.

A few things worth double-checking:

  1. Double-counting with USD fallback — The non_primary_cash_entries exclusion logic needs to correctly identify which currency actually ended up in cash_balance, not just the account's nominal currency. If an account is EUR but only has USD cash, USD becomes primary via fallback [3]. The exclusion must handle that case.

  2. Processor sequencing — The PR says the processor now runs when there are holdings or non-primary cash. How does HoldingsProcessor know about non-primary cash if upsert_balances! hasn't run yet in the sync cycle? Worth confirming the ordering in Processor [4].

  3. Exchange rate aggregation — Non-primary synthetic holdings are stored in their original currency. Does the existing holdings aggregation correctly convert them to the account currency for total value display?

  4. Activities integration — Do cash activities (deposits/withdrawals) properly interact with per-currency synthetic holdings, or do they still target the primary cash_balance?

  5. Invalid currency codes — If a non-primary balance has an unrecognized currency code, does it fail gracefully or break the entire sync?

Overall the implementation looks solid and well-tested (45 passing tests is great coverage). The critical thing to be confident about is point #1 — the USD fallback exclusion logic is the main place double-counting could sneak in.

To reply, just mention @dosu.


Share context across your team and agents. Try Dosu.

Leave Feedback Learn about sure with Dosu Add Dosu to your team

…m log

Addresses PR we-promise#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).
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/models/snaptrade_account.rb (1)

155-172: 💤 Low value

Consider extracting primary cash entry selection to avoid logic duplication.

The primary cash selection logic (account currency → USD → first) at lines 160-162 is duplicated from upsert_balances! (lines 139-141). Extracting to a private helper would ensure both stay in sync.

♻️ Proposed refactor
+  private
+
+    def primary_cash_entry(entries)
+      entries.find { |b| b.dig(:currency, :code) == currency } ||
+        entries.find { |b| b.dig(:currency, :code) == "USD" } ||
+        entries.first
+    end

Then use primary_cash_entry(data) in both upsert_balances! and non_primary_cash_entries.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/snaptrade_account.rb` around lines 155 - 172, The
primary-cash-selection logic is duplicated between non_primary_cash_entries and
upsert_balances!; extract that logic into a private helper (e.g.,
primary_cash_entry or primary_cash_code) that accepts the balances payload (or
parsed entries) and returns the primary entry or its currency code using the
account currency → "USD" → first fallback; then replace the inline selection in
non_primary_cash_entries and upsert_balances! to call this helper so both use
the same source of truth.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/models/snaptrade_account.rb`:
- Around line 155-172: The primary-cash-selection logic is duplicated between
non_primary_cash_entries and upsert_balances!; extract that logic into a private
helper (e.g., primary_cash_entry or primary_cash_code) that accepts the balances
payload (or parsed entries) and returns the primary entry or its currency code
using the account currency → "USD" → first fallback; then replace the inline
selection in non_primary_cash_entries and upsert_balances! to call this helper
so both use the same source of truth.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 74305bd6-5af4-4c0f-8c58-e5eaf5670494

📥 Commits

Reviewing files that changed from the base of the PR and between f0eba9c and 4db3561.

📒 Files selected for processing (2)
  • app/models/snaptrade_account.rb
  • app/models/snaptrade_account/holdings_processor.rb

Copy link
Copy Markdown
Collaborator

jjmata commented May 26, 2026

The design is sound — persisting the full balance snapshot and deriving synthetic holdings from non-primary entries is the right approach. One item worth verifying before merge:

Potential key-type mismatch in upsert_balances! tests: The new test feeds string-keyed hashes to upsert_balances!:

@snaptrade_account.upsert_balances!([
  { "currency" => { "code" => "USD" }, "cash" => "1500.00" },
  { "currency" => { "code" => "EUR" }, "cash" => "800.00" }
])

But the existing implementation uses symbol-key access: b.dig(:currency, :code) and cash_entry[:cash]. On a plain Ruby hash with string keys, these return nil. If the test passes, it may be because the SnapTrade SDK returns HashWithIndifferentAccess or symbol-keyed hashes in production (so the production code works), while the test data is string-keyed (so it falls through to data.first and coincidentally gets the right value). Worth confirming the actual key type the SnapTrade SDK returns and aligning the test data accordingly.

Other observations:

  • non_primary_cash_entries correctly converts stored JSONB (string-keyed) to with_indifferent_access, so that path is safe regardless of key type.
  • The stable external_id: "snaptrade_cash_#{currency.downcase}" ensures idempotency across re-syncs — the deduplication test covers this.
  • Security.cash_for(account, currency: nil) defaults to the existing behaviour (no-arg callers unchanged) — backward compatible.
  • Running HoldingsProcessor even when raw_holdings_payload is blank (but non-primary cash exists) is the right fix for the case where a brokerage account has only cash and no securities.

Generated by Claude Code

PR we-promise#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).
@dripsmvcp
Copy link
Copy Markdown
Contributor Author

Thanks @jjmata. Confirmed the key-type path: upsert_balances! maps every entry through .with_indifferent_access before selection, so dig(:currency, :code) works regardless of whether the SnapTrade SDK returns string- or symbol-keyed hashes. The valid point was that the test couldn't prove it — USD was the first entry, so it'd pass even via the entries.first fallback. I reordered the test data (EUR first, USD second) so the cash_balance == 1500 (USD) assertion now only holds if the account-currency selection truly resolves USD via dig. Also extracted the shared selection into a primary_cash_entry helper (CodeRabbit's DRY note) so upsert_balances! and non_primary_cash_entries stay in sync. Pushed in 4133a875.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Multi-currency cash balances aren't fully imported on SnapTrade

2 participants