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:
parent
dcf5ab233a
commit
b912e4c1fa
8 changed files with 293 additions and 47 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
test_investments_payload = {
|
||||
securities: [], # mocked
|
||||
holdings: [
|
||||
{
|
||||
"security_id" => "123",
|
||||
"quantity" => 100,
|
||||
"institution_price" => 100,
|
||||
"iso_currency_code" => "USD"
|
||||
}
|
||||
],
|
||||
transactions: [] # not relevant for test
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
test "upserts security records via SecurityResolver" do
|
||||
# TODO
|
||||
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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
test "handles security resolution for unknown securities" do
|
||||
# TODO
|
||||
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 "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
|
||||
|
|
|
@ -15,14 +15,17 @@ class PlaidAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
|
|||
removed: []
|
||||
})
|
||||
|
||||
mock_processor = mock("PlaidEntry::TransactionProcessor")
|
||||
PlaidEntry::TransactionProcessor.expects(:new)
|
||||
.with(added_transactions.first, plaid_account: @plaid_account)
|
||||
mock_processor = mock("PlaidEntry::Processor")
|
||||
category_matcher_mock = mock("PlaidAccount::Transactions::CategoryMatcher")
|
||||
|
||||
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::TransactionProcessor.expects(:new)
|
||||
.with(modified_transactions.first, plaid_account: @plaid_account)
|
||||
PlaidEntry::Processor.expects(:new)
|
||||
.with(modified_transactions.first, plaid_account: @plaid_account, category_matcher: category_matcher_mock)
|
||||
.returns(mock_processor)
|
||||
.once
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue