mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
Add SnapTrade brokerage integration with full trade history support (#737)
* Introduce SnapTrade integration with models, migrations, views, and activity processing logic. * Refactor SnapTrade activities processing: improve activity fetching flow, handle pending states, and update UI elements for enhanced user feedback. * Update Brakeman ignore file to include intentional redirect for SnapTrade OAuth portal. * Refactor SnapTrade models, views, and processing logic: add currency extraction helper, improve pending state handling, optimize migration checks, and enhance user feedback in UI. * Remove encryption for SnapTrade `snaptrade_user_id`, as it is an identifier, not a secret. * Introduce `SnaptradeConnectionCleanupJob` to asynchronously handle SnapTrade connection cleanup and improve i18n for SnapTrade item status messages. * Update SnapTrade encryption: make `snaptrade_user_secret` non-deterministic to enhance security. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: luckyPipewrench <luckypipewrench@proton.me> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -89,14 +89,21 @@ class HoldingTest < ActiveSupport::TestCase
|
||||
assert_equal Money.new(200.00, "USD"), @amzn.avg_cost
|
||||
end
|
||||
|
||||
test "avg_cost treats zero cost_basis as unknown" do
|
||||
test "avg_cost treats zero cost_basis as unknown when not locked" do
|
||||
# Some providers return 0 when they don't have cost basis data
|
||||
# This should be treated as "unknown" (return nil), not as $0 cost
|
||||
@amzn.update!(cost_basis: 0)
|
||||
@amzn.update!(cost_basis: 0, cost_basis_locked: false)
|
||||
|
||||
assert_nil @amzn.avg_cost
|
||||
end
|
||||
|
||||
test "avg_cost returns zero cost_basis when locked (e.g., airdrops)" do
|
||||
# User-set $0 cost basis is valid for airdrops and should be honored
|
||||
@amzn.update!(cost_basis: 0, cost_basis_source: "manual", cost_basis_locked: true)
|
||||
|
||||
assert_equal Money.new(0, "USD"), @amzn.avg_cost
|
||||
end
|
||||
|
||||
test "trend returns nil when cost basis is unknown" do
|
||||
# Without cost basis, we can't calculate unrealized gain/loss
|
||||
assert_nil @amzn.trend
|
||||
|
||||
258
test/models/snaptrade_account/activities_processor_test.rb
Normal file
258
test/models/snaptrade_account/activities_processor_test.rb
Normal file
@@ -0,0 +1,258 @@
|
||||
require "test_helper"
|
||||
|
||||
class SnaptradeAccount::ActivitiesProcessorTest < ActiveSupport::TestCase
|
||||
include SecuritiesTestHelper
|
||||
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@snaptrade_item = snaptrade_items(:configured_item)
|
||||
@snaptrade_account = snaptrade_accounts(:fidelity_401k)
|
||||
|
||||
# Create a linked Sure account for the SnapTrade account
|
||||
@account = @family.accounts.create!(
|
||||
name: "Test Investment",
|
||||
balance: 50000,
|
||||
cash_balance: 1000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
|
||||
# Link the SnapTrade account to the Sure account
|
||||
@snaptrade_account.ensure_account_provider!(@account)
|
||||
@snaptrade_account.reload
|
||||
end
|
||||
|
||||
test "processes buy trade activity" do
|
||||
@snaptrade_account.update!(raw_activities_payload: [
|
||||
build_trade_activity(
|
||||
id: "trade_001",
|
||||
type: "BUY",
|
||||
symbol: "AAPL",
|
||||
units: 10,
|
||||
price: 150.00,
|
||||
settlement_date: Date.current.to_s
|
||||
)
|
||||
])
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
processor.process
|
||||
|
||||
# Verify a trade was created (external_id is on entry, not trade)
|
||||
entry = @account.entries.find_by(external_id: "trade_001", source: "snaptrade")
|
||||
assert_not_nil entry, "Entry should be created"
|
||||
assert entry.entryable.is_a?(Trade), "Entry should be a Trade"
|
||||
|
||||
trade = entry.entryable
|
||||
assert_equal 10, trade.qty
|
||||
assert_equal 150.00, trade.price.to_f
|
||||
assert_equal "Buy", trade.investment_activity_label
|
||||
end
|
||||
|
||||
test "processes sell trade activity with negative quantity" do
|
||||
@snaptrade_account.update!(raw_activities_payload: [
|
||||
build_trade_activity(
|
||||
id: "trade_002",
|
||||
type: "SELL",
|
||||
symbol: "AAPL",
|
||||
units: 5,
|
||||
price: 160.00,
|
||||
settlement_date: Date.current.to_s
|
||||
)
|
||||
])
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
processor.process
|
||||
|
||||
entry = @account.entries.find_by(external_id: "trade_002", source: "snaptrade")
|
||||
assert_not_nil entry
|
||||
trade = entry.entryable
|
||||
assert_equal(-5, trade.qty) # Sell should be negative
|
||||
assert_equal "Sell", trade.investment_activity_label
|
||||
end
|
||||
|
||||
test "processes dividend cash activity" do
|
||||
@snaptrade_account.update!(raw_activities_payload: [
|
||||
build_cash_activity(
|
||||
id: "div_001",
|
||||
type: "DIVIDEND",
|
||||
amount: 25.50,
|
||||
settlement_date: Date.current.to_s,
|
||||
symbol: "VTI"
|
||||
)
|
||||
])
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
processor.process
|
||||
|
||||
entry = @account.entries.find_by(external_id: "div_001", source: "snaptrade")
|
||||
assert_not_nil entry, "Entry should be created"
|
||||
assert entry.entryable.is_a?(Transaction), "Entry should be a Transaction"
|
||||
|
||||
transaction = entry.entryable
|
||||
assert_equal "Dividend", transaction.investment_activity_label
|
||||
end
|
||||
|
||||
test "processes contribution with positive amount" do
|
||||
@snaptrade_account.update!(raw_activities_payload: [
|
||||
build_cash_activity(
|
||||
id: "contrib_001",
|
||||
type: "CONTRIBUTION",
|
||||
amount: 500.00,
|
||||
settlement_date: Date.current.to_s
|
||||
)
|
||||
])
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
processor.process
|
||||
|
||||
entry = @account.entries.find_by(external_id: "contrib_001", source: "snaptrade")
|
||||
assert_not_nil entry
|
||||
# Amount is on entry, not transaction
|
||||
assert_equal 500.00, entry.amount.to_f # Positive for contributions
|
||||
assert_equal "Contribution", entry.entryable.investment_activity_label
|
||||
end
|
||||
|
||||
test "processes withdrawal with negative amount" do
|
||||
@snaptrade_account.update!(raw_activities_payload: [
|
||||
build_cash_activity(
|
||||
id: "withdraw_001",
|
||||
type: "WITHDRAWAL",
|
||||
amount: 200.00,
|
||||
settlement_date: Date.current.to_s
|
||||
)
|
||||
])
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
processor.process
|
||||
|
||||
entry = @account.entries.find_by(external_id: "withdraw_001", source: "snaptrade")
|
||||
assert_not_nil entry
|
||||
assert_equal(-200.00, entry.amount.to_f) # Negative for withdrawals
|
||||
assert_equal "Withdrawal", entry.entryable.investment_activity_label
|
||||
end
|
||||
|
||||
test "maps all known activity types correctly" do
|
||||
type_mappings = {
|
||||
"BUY" => "Buy",
|
||||
"SELL" => "Sell",
|
||||
"DIVIDEND" => "Dividend",
|
||||
"DIV" => "Dividend",
|
||||
"CONTRIBUTION" => "Contribution",
|
||||
"WITHDRAWAL" => "Withdrawal",
|
||||
"TRANSFER_IN" => "Transfer",
|
||||
"TRANSFER_OUT" => "Transfer",
|
||||
"INTEREST" => "Interest",
|
||||
"FEE" => "Fee",
|
||||
"TAX" => "Fee",
|
||||
"REI" => "Reinvestment",
|
||||
"REINVEST" => "Reinvestment",
|
||||
"CASH" => "Contribution",
|
||||
"CORP_ACTION" => "Other",
|
||||
"SPLIT_REVERSE" => "Other"
|
||||
}
|
||||
|
||||
type_mappings.each do |snaptrade_type, expected_label|
|
||||
actual = SnaptradeAccount::ActivitiesProcessor::SNAPTRADE_TYPE_TO_LABEL[snaptrade_type]
|
||||
assert_equal expected_label, actual, "Type #{snaptrade_type} should map to #{expected_label}"
|
||||
end
|
||||
end
|
||||
|
||||
test "logs unmapped activity types" do
|
||||
@snaptrade_account.update!(raw_activities_payload: [
|
||||
build_cash_activity(
|
||||
id: "unknown_001",
|
||||
type: "SOME_NEW_TYPE",
|
||||
amount: 100.00,
|
||||
settlement_date: Date.current.to_s
|
||||
)
|
||||
])
|
||||
|
||||
# Capture log output
|
||||
log_output = StringIO.new
|
||||
old_logger = Rails.logger
|
||||
Rails.logger = Logger.new(log_output)
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
processor.process
|
||||
|
||||
Rails.logger = old_logger
|
||||
|
||||
assert_includes log_output.string, "Unmapped activity type 'SOME_NEW_TYPE'"
|
||||
end
|
||||
|
||||
test "skips activities without external_id" do
|
||||
@snaptrade_account.update!(raw_activities_payload: [
|
||||
build_cash_activity(
|
||||
id: nil,
|
||||
type: "DIVIDEND",
|
||||
amount: 50.00,
|
||||
settlement_date: Date.current.to_s
|
||||
)
|
||||
])
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
processor.process
|
||||
|
||||
# No entry should be created with snaptrade source
|
||||
assert_equal 0, @account.entries.where(source: "snaptrade").count
|
||||
end
|
||||
|
||||
test "skips processing when no linked account" do
|
||||
# Remove the account provider link
|
||||
@snaptrade_account.account_provider&.destroy
|
||||
@snaptrade_account.reload
|
||||
|
||||
@snaptrade_account.update!(raw_activities_payload: [
|
||||
build_trade_activity(
|
||||
id: "trade_orphan",
|
||||
type: "BUY",
|
||||
symbol: "AAPL",
|
||||
units: 10,
|
||||
price: 150.00,
|
||||
settlement_date: Date.current.to_s
|
||||
)
|
||||
])
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
processor.process
|
||||
|
||||
# No entries should be created with this external_id
|
||||
assert_equal 0, Entry.where(external_id: "trade_orphan").count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_trade_activity(id:, type:, symbol:, units:, price:, settlement_date:)
|
||||
{
|
||||
"id" => id,
|
||||
"type" => type,
|
||||
"symbol" => {
|
||||
"symbol" => symbol,
|
||||
"description" => "#{symbol} Inc"
|
||||
},
|
||||
"units" => units,
|
||||
"price" => price,
|
||||
"settlement_date" => settlement_date,
|
||||
"currency" => { "code" => "USD" }
|
||||
}
|
||||
end
|
||||
|
||||
def build_cash_activity(id:, type:, amount:, settlement_date:, symbol: nil)
|
||||
activity = {
|
||||
"id" => id,
|
||||
"type" => type,
|
||||
"amount" => amount,
|
||||
"settlement_date" => settlement_date,
|
||||
"currency" => { "code" => "USD" }
|
||||
}
|
||||
|
||||
if symbol
|
||||
activity["symbol"] = {
|
||||
"symbol" => symbol,
|
||||
"description" => "#{symbol} Fund"
|
||||
}
|
||||
end
|
||||
|
||||
activity
|
||||
end
|
||||
end
|
||||
139
test/models/snaptrade_account_test.rb
Normal file
139
test/models/snaptrade_account_test.rb
Normal file
@@ -0,0 +1,139 @@
|
||||
require "test_helper"
|
||||
|
||||
class SnaptradeAccountTest < ActiveSupport::TestCase
|
||||
fixtures :families, :snaptrade_items, :snaptrade_accounts
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@snaptrade_item = snaptrade_items(:configured_item)
|
||||
@snaptrade_account = snaptrade_accounts(:fidelity_401k)
|
||||
end
|
||||
|
||||
test "validates presence of name" do
|
||||
@snaptrade_account.name = nil
|
||||
assert_not @snaptrade_account.valid?
|
||||
assert_includes @snaptrade_account.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates presence of currency" do
|
||||
@snaptrade_account.currency = nil
|
||||
assert_not @snaptrade_account.valid?
|
||||
assert_includes @snaptrade_account.errors[:currency], "can't be blank"
|
||||
end
|
||||
|
||||
test "ensure_account_provider! creates link when account provided" do
|
||||
account = @family.accounts.create!(
|
||||
name: "Test Investment",
|
||||
balance: 10000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
|
||||
assert_nil @snaptrade_account.account_provider
|
||||
|
||||
@snaptrade_account.ensure_account_provider!(account)
|
||||
@snaptrade_account.reload
|
||||
|
||||
assert_not_nil @snaptrade_account.account_provider
|
||||
assert_equal account, @snaptrade_account.current_account
|
||||
end
|
||||
|
||||
test "ensure_account_provider! updates link when account changes" do
|
||||
account1 = @family.accounts.create!(
|
||||
name: "First Account",
|
||||
balance: 10000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
account2 = @family.accounts.create!(
|
||||
name: "Second Account",
|
||||
balance: 20000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
|
||||
@snaptrade_account.ensure_account_provider!(account1)
|
||||
assert_equal account1, @snaptrade_account.reload.current_account
|
||||
|
||||
@snaptrade_account.ensure_account_provider!(account2)
|
||||
assert_equal account2, @snaptrade_account.reload.current_account
|
||||
end
|
||||
|
||||
test "ensure_account_provider! is idempotent" do
|
||||
account = @family.accounts.create!(
|
||||
name: "Test Investment",
|
||||
balance: 10000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
|
||||
@snaptrade_account.ensure_account_provider!(account)
|
||||
provider1 = @snaptrade_account.reload.account_provider
|
||||
|
||||
@snaptrade_account.ensure_account_provider!(account)
|
||||
provider2 = @snaptrade_account.reload.account_provider
|
||||
|
||||
assert_equal provider1.id, provider2.id
|
||||
end
|
||||
|
||||
test "upsert_holdings_snapshot! stores holdings and updates timestamp" do
|
||||
holdings = [
|
||||
{ "symbol" => { "symbol" => "AAPL" }, "units" => 10 },
|
||||
{ "symbol" => { "symbol" => "MSFT" }, "units" => 5 }
|
||||
]
|
||||
|
||||
@snaptrade_account.upsert_holdings_snapshot!(holdings)
|
||||
|
||||
assert_equal holdings, @snaptrade_account.raw_holdings_payload
|
||||
assert_not_nil @snaptrade_account.last_holdings_sync
|
||||
end
|
||||
|
||||
test "upsert_activities_snapshot! stores activities and updates timestamp" do
|
||||
activities = [
|
||||
{ "id" => "act1", "type" => "BUY", "amount" => 1000 },
|
||||
{ "id" => "act2", "type" => "DIVIDEND", "amount" => 50 }
|
||||
]
|
||||
|
||||
@snaptrade_account.upsert_activities_snapshot!(activities)
|
||||
|
||||
assert_equal activities, @snaptrade_account.raw_activities_payload
|
||||
assert_not_nil @snaptrade_account.last_activities_sync
|
||||
end
|
||||
|
||||
test "upsert_from_snaptrade! extracts data from API response" do
|
||||
# Use a Hash that mimics the SnapTrade SDK response structure
|
||||
api_response = {
|
||||
"id" => "new_account_id",
|
||||
"brokerage_authorization" => "auth_xyz",
|
||||
"number" => "9999999",
|
||||
"name" => "Schwab Brokerage",
|
||||
"status" => "active",
|
||||
"balance" => {
|
||||
"total" => { "amount" => 125000, "currency" => "USD" }
|
||||
},
|
||||
"meta" => { "type" => "INDIVIDUAL", "institution_name" => "Charles Schwab" }
|
||||
}
|
||||
|
||||
@snaptrade_account.upsert_from_snaptrade!(api_response)
|
||||
|
||||
assert_equal "new_account_id", @snaptrade_account.snaptrade_account_id
|
||||
assert_equal "auth_xyz", @snaptrade_account.snaptrade_authorization_id
|
||||
assert_equal "9999999", @snaptrade_account.account_number
|
||||
assert_equal "Schwab Brokerage", @snaptrade_account.name
|
||||
assert_equal "Charles Schwab", @snaptrade_account.brokerage_name
|
||||
assert_equal 125000, @snaptrade_account.current_balance.to_i
|
||||
assert_equal "INDIVIDUAL", @snaptrade_account.account_type
|
||||
end
|
||||
|
||||
test "snaptrade_credentials returns credentials from parent item" do
|
||||
credentials = @snaptrade_account.snaptrade_credentials
|
||||
|
||||
assert_equal "user_123", credentials[:user_id]
|
||||
assert_equal "secret_abc", credentials[:user_secret]
|
||||
end
|
||||
|
||||
test "snaptrade_provider returns provider from parent item" do
|
||||
provider = @snaptrade_account.snaptrade_provider
|
||||
|
||||
assert_instance_of Provider::Snaptrade, provider
|
||||
end
|
||||
end
|
||||
78
test/models/snaptrade_item_test.rb
Normal file
78
test/models/snaptrade_item_test.rb
Normal file
@@ -0,0 +1,78 @@
|
||||
require "test_helper"
|
||||
|
||||
class SnaptradeItemTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
end
|
||||
|
||||
test "validates presence of name" do
|
||||
item = SnaptradeItem.new(family: @family, client_id: "test", consumer_key: "test")
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates presence of client_id on create" do
|
||||
item = SnaptradeItem.new(family: @family, name: "Test", consumer_key: "test")
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:client_id], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates presence of consumer_key on create" do
|
||||
item = SnaptradeItem.new(family: @family, name: "Test", client_id: "test")
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:consumer_key], "can't be blank"
|
||||
end
|
||||
|
||||
test "credentials_configured? returns true when credentials are set" do
|
||||
item = SnaptradeItem.new(
|
||||
family: @family,
|
||||
name: "Test",
|
||||
client_id: "test_client_id",
|
||||
consumer_key: "test_consumer_key"
|
||||
)
|
||||
assert item.credentials_configured?
|
||||
end
|
||||
|
||||
test "credentials_configured? returns false when credentials are missing" do
|
||||
item = SnaptradeItem.new(family: @family, name: "Test")
|
||||
assert_not item.credentials_configured?
|
||||
end
|
||||
|
||||
test "user_registered? returns false when user_id and secret are blank" do
|
||||
item = SnaptradeItem.new(
|
||||
family: @family,
|
||||
name: "Test",
|
||||
client_id: "test",
|
||||
consumer_key: "test"
|
||||
)
|
||||
assert_not item.user_registered?
|
||||
end
|
||||
|
||||
test "user_registered? returns true when user_id and secret are present" do
|
||||
item = SnaptradeItem.new(
|
||||
family: @family,
|
||||
name: "Test",
|
||||
client_id: "test",
|
||||
consumer_key: "test",
|
||||
snaptrade_user_id: "user_123",
|
||||
snaptrade_user_secret: "secret_abc"
|
||||
)
|
||||
assert item.user_registered?
|
||||
end
|
||||
|
||||
test "snaptrade_provider returns nil when credentials not configured" do
|
||||
item = SnaptradeItem.new(family: @family, name: "Test")
|
||||
assert_nil item.snaptrade_provider
|
||||
end
|
||||
|
||||
test "snaptrade_provider returns provider instance when configured" do
|
||||
item = SnaptradeItem.new(
|
||||
family: @family,
|
||||
name: "Test",
|
||||
client_id: "test_client_id",
|
||||
consumer_key: "test_consumer_key"
|
||||
)
|
||||
provider = item.snaptrade_provider
|
||||
assert_instance_of Provider::Snaptrade, provider
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user