mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 22:34:47 +00:00
* 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>
259 lines
7.5 KiB
Ruby
259 lines
7.5 KiB
Ruby
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
|