1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-08 06:55:21 +02:00

Add investment product test cases

This commit is contained in:
Zach Gollwitzer 2025-05-23 17:31:38 -04:00
parent dcf5ab233a
commit b912e4c1fa
8 changed files with 293 additions and 47 deletions

View file

@ -70,8 +70,7 @@ class Family::AutoCategorizer
amount: transaction.entry.amount.abs,
classification: transaction.entry.classification,
description: transaction.entry.name,
merchant: transaction.merchant&.name,
hint: transaction.plaid_category_detailed
merchant: transaction.merchant&.name
}
end
end

View file

@ -27,7 +27,7 @@ class PlaidAccount::Investments::HoldingsProcessor
end
private
attr_reader :plaid_account
attr_reader :plaid_account, :security_resolver
def account
plaid_account.account

View file

@ -1,4 +1,6 @@
class PlaidAccount::Investments::TransactionsProcessor
SecurityNotFoundError = Class.new(StandardError)
def initialize(plaid_account, security_resolver:)
@plaid_account = plaid_account
@security_resolver = security_resolver
@ -6,12 +8,10 @@ class PlaidAccount::Investments::TransactionsProcessor
def process
transactions.each do |transaction|
resolved_security_result = security_resolver.resolve(plaid_security_id: transaction["security_id"])
if resolved_security_result.security.present?
find_or_create_trade_entry(transaction)
else
if cash_transaction?(transaction)
find_or_create_cash_entry(transaction)
else
find_or_create_trade_entry(transaction)
end
end
end
@ -23,17 +23,25 @@ class PlaidAccount::Investments::TransactionsProcessor
plaid_account.account
end
def cash_transaction?(transaction)
transaction["type"] == "cash" || transaction["type"] == "fee"
end
def find_or_create_trade_entry(transaction)
resolved_security_result = security_resolver.resolve(plaid_security_id: transaction["security_id"])
unless resolved_security_result.security.present?
Sentry.capture_exception(SecurityNotFoundError.new("Could not find security for plaid trade")) do |scope|
scope.set_tags(plaid_account_id: plaid_account.id)
end
return # We can't process a non-cash transaction without a security
end
entry = account.entries.find_or_initialize_by(plaid_id: transaction["investment_transaction_id"]) do |e|
e.entryable = Trade.new
end
entry.enrich_attribute(
:name,
transaction["name"],
source: "plaid"
)
entry.assign_attributes(
amount: transaction["quantity"] * transaction["price"],
currency: transaction["iso_currency_code"],
@ -41,12 +49,18 @@ class PlaidAccount::Investments::TransactionsProcessor
)
entry.trade.assign_attributes(
security: security,
security: resolved_security_result.security,
qty: transaction["quantity"],
price: transaction["price"],
currency: transaction["iso_currency_code"]
)
entry.enrich_attribute(
:name,
transaction["name"],
source: "plaid"
)
entry.save!
end
@ -55,18 +69,18 @@ class PlaidAccount::Investments::TransactionsProcessor
e.entryable = Transaction.new
end
entry.enrich_attribute(
:name,
transaction["name"],
source: "plaid"
)
entry.assign_attributes(
amount: transaction["amount"],
currency: transaction["iso_currency_code"],
date: transaction["date"]
)
entry.enrich_attribute(
:name,
transaction["name"],
source: "plaid"
)
entry.save!
end

View file

@ -6,7 +6,7 @@ class PlaidAccount::Transactions::Processor
def process
# Each entry is processed inside a transaction, but to avoid locking up the DB when
# there are hundreds or thousands of transactions, we process them individually.
modified_transactions.find_each do |transaction|
modified_transactions.each do |transaction|
PlaidEntry::Processor.new(
transaction,
plaid_account: plaid_account,
@ -15,7 +15,7 @@ class PlaidAccount::Transactions::Processor
end
PlaidAccount.transaction do
removed_transactions.find_each do |transaction|
removed_transactions.each do |transaction|
remove_plaid_transaction(transaction)
end
end

View file

@ -2,14 +2,48 @@ require "test_helper"
class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
setup do
# TODO: set up holdings data and security resolver stub
@plaid_account = plaid_accounts(:one)
@security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
end
test "creates holding records from Plaid holdings snapshot" do
# TODO
end
test_investments_payload = {
securities: [], # mocked
holdings: [
{
"security_id" => "123",
"quantity" => 100,
"institution_price" => 100,
"iso_currency_code" => "USD"
}
],
transactions: [] # not relevant for test
}
test "upserts security records via SecurityResolver" do
# TODO
@plaid_account.update!(raw_investments_payload: test_investments_payload)
@security_resolver.expects(:resolve)
.with(plaid_security_id: "123")
.returns(
OpenStruct.new(
security: securities(:aapl),
cash_equivalent?: false,
brokerage_cash?: false
)
)
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
assert_difference "Holding.count" do
processor.process
end
holding = Holding.order(created_at: :desc).first
assert_equal 100, holding.qty
assert_equal 100, holding.price
assert_equal "USD", holding.currency
assert_equal securities(:aapl), holding.security
assert_equal Date.current, holding.date
end
end

View file

@ -2,14 +2,114 @@ require "test_helper"
class PlaidAccount::Investments::SecurityResolverTest < ActiveSupport::TestCase
setup do
# TODO: stub security provider lookup
@upstream_resolver = mock("Security::Resolver")
@plaid_account = plaid_accounts(:one)
@resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
end
test "finds existing security by identifiers" do
# TODO
test "handles missing plaid security" do
missing_id = "missing_security_id"
# Ensure there are *no* securities that reference the missing ID
@plaid_account.update!(raw_investments_payload: {
securities: [
{
"security_id" => "some_other_id",
"ticker_symbol" => "FOO",
"type" => "equity",
"market_identifier_code" => "XNAS"
}
]
})
Security::Resolver.expects(:new).never
Sentry.stubs(:capture_exception)
response = @resolver.resolve(plaid_security_id: missing_id)
assert_nil response.security
refute response.cash_equivalent?
refute response.brokerage_cash?
end
test "creates new security when none found" do
# TODO
test "identifies brokerage cash plaid securities" do
brokerage_cash_id = "brokerage_cash_security_id"
@plaid_account.update!(raw_investments_payload: {
securities: [
{
"security_id" => brokerage_cash_id,
"ticker_symbol" => "CUR:USD", # Plaid brokerage cash ticker
"type" => "cash",
"is_cash_equivalent" => true
}
]
})
Security::Resolver.expects(:new).never
response = @resolver.resolve(plaid_security_id: brokerage_cash_id)
assert_nil response.security
assert response.cash_equivalent?
assert response.brokerage_cash?
end
test "identifies cash equivalent plaid securities" do
mmf_security_id = "money_market_security_id"
@plaid_account.update!(raw_investments_payload: {
securities: [
{
"security_id" => mmf_security_id,
"ticker_symbol" => "VMFXX", # Vanguard Federal Money Market Fund
"type" => "mutual fund",
"is_cash_equivalent" => true,
"market_identifier_code" => "XNAS"
}
]
})
resolved_security = Security.create!(ticker: "VMFXX", exchange_operating_mic: "XNAS")
Security::Resolver.expects(:new)
.with("VMFXX", exchange_operating_mic: "XNAS")
.returns(@upstream_resolver)
@upstream_resolver.expects(:resolve).returns(resolved_security)
response = @resolver.resolve(plaid_security_id: mmf_security_id)
assert_equal resolved_security, response.security
assert response.cash_equivalent?
refute response.brokerage_cash?
end
test "resolves normal plaid securities" do
security_id = "regular_security_id"
@plaid_account.update!(raw_investments_payload: {
securities: [
{
"security_id" => security_id,
"ticker_symbol" => "IVV",
"type" => "etf",
"is_cash_equivalent" => false,
"market_identifier_code" => "XNAS"
}
]
})
resolved_security = Security.create!(ticker: "IVV", exchange_operating_mic: "XNAS")
Security::Resolver.expects(:new)
.with("IVV", exchange_operating_mic: "XNAS")
.returns(@upstream_resolver)
@upstream_resolver.expects(:resolve).returns(resolved_security)
response = @resolver.resolve(plaid_security_id: security_id)
assert_equal resolved_security, response.security
refute response.cash_equivalent? # Normal securities are not cash equivalent
refute response.brokerage_cash?
end
end

View file

@ -2,14 +2,110 @@ require "test_helper"
class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::TestCase
setup do
# TODO: set up investment plaid account and security resolver stub
@plaid_account = plaid_accounts(:one)
@security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
end
test "creates trade entries from Plaid investment transactions" do
# TODO
test "creates regular trade entries" do
test_investments_payload = {
transactions: [
{
"transaction_id" => "123",
"security_id" => "123",
"type" => "buy",
"quantity" => 1, # Positive, so "buy 1 share"
"price" => 100,
"iso_currency_code" => "USD",
"date" => Date.current,
"name" => "Buy 1 share of AAPL"
}
]
}
@plaid_account.update!(raw_investments_payload: test_investments_payload)
@security_resolver.stubs(:resolve).returns(OpenStruct.new(
security: securities(:aapl)
))
processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)
assert_difference [ "Entry.count", "Trade.count" ], 1 do
processor.process
end
entry = Entry.order(created_at: :desc).first
assert_equal 100, entry.amount
assert_equal "USD", entry.currency
assert_equal Date.current, entry.date
assert_equal "Buy 1 share of AAPL", entry.name
end
test "handles security resolution for unknown securities" do
# TODO
test "creates cash transactions" do
test_investments_payload = {
transactions: [
{
"transaction_id" => "123",
"type" => "cash",
"subtype" => "withdrawal",
"amount" => 100, # Positive, so moving money OUT of the account
"iso_currency_code" => "USD",
"date" => Date.current,
"name" => "Withdrawal"
}
]
}
@plaid_account.update!(raw_investments_payload: test_investments_payload)
@security_resolver.expects(:resolve).never # Cash transactions don't have a security
processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
processor.process
end
entry = Entry.order(created_at: :desc).first
assert_equal 100, entry.amount
assert_equal "USD", entry.currency
assert_equal Date.current, entry.date
assert_equal "Withdrawal", entry.name
end
test "creates fee transactions" do
test_investments_payload = {
transactions: [
{
"transaction_id" => "123",
"type" => "fee",
"subtype" => "miscellaneous fee",
"amount" => 10.25,
"iso_currency_code" => "USD",
"date" => Date.current,
"name" => "Miscellaneous fee"
}
]
}
@plaid_account.update!(raw_investments_payload: test_investments_payload)
@security_resolver.expects(:resolve).never # Cash transactions don't have a security
processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
processor.process
end
entry = Entry.order(created_at: :desc).first
assert_equal 10.25, entry.amount
assert_equal "USD", entry.currency
assert_equal Date.current, entry.date
assert_equal "Miscellaneous fee", entry.name
end
end

View file

@ -15,16 +15,19 @@ class PlaidAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
removed: []
})
mock_processor = mock("PlaidEntry::TransactionProcessor")
PlaidEntry::TransactionProcessor.expects(:new)
.with(added_transactions.first, plaid_account: @plaid_account)
.returns(mock_processor)
.once
mock_processor = mock("PlaidEntry::Processor")
category_matcher_mock = mock("PlaidAccount::Transactions::CategoryMatcher")
PlaidEntry::TransactionProcessor.expects(:new)
.with(modified_transactions.first, plaid_account: @plaid_account)
.returns(mock_processor)
.once
PlaidAccount::Transactions::CategoryMatcher.stubs(:new).returns(category_matcher_mock)
PlaidEntry::Processor.expects(:new)
.with(added_transactions.first, plaid_account: @plaid_account, category_matcher: category_matcher_mock)
.returns(mock_processor)
.once
PlaidEntry::Processor.expects(:new)
.with(modified_transactions.first, plaid_account: @plaid_account, category_matcher: category_matcher_mock)
.returns(mock_processor)
.once
mock_processor.expects(:process).twice