mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-08 15:05:22 +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,
|
amount: transaction.entry.amount.abs,
|
||||||
classification: transaction.entry.classification,
|
classification: transaction.entry.classification,
|
||||||
description: transaction.entry.name,
|
description: transaction.entry.name,
|
||||||
merchant: transaction.merchant&.name,
|
merchant: transaction.merchant&.name
|
||||||
hint: transaction.plaid_category_detailed
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -27,7 +27,7 @@ class PlaidAccount::Investments::HoldingsProcessor
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
attr_reader :plaid_account
|
attr_reader :plaid_account, :security_resolver
|
||||||
|
|
||||||
def account
|
def account
|
||||||
plaid_account.account
|
plaid_account.account
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
class PlaidAccount::Investments::TransactionsProcessor
|
class PlaidAccount::Investments::TransactionsProcessor
|
||||||
|
SecurityNotFoundError = Class.new(StandardError)
|
||||||
|
|
||||||
def initialize(plaid_account, security_resolver:)
|
def initialize(plaid_account, security_resolver:)
|
||||||
@plaid_account = plaid_account
|
@plaid_account = plaid_account
|
||||||
@security_resolver = security_resolver
|
@security_resolver = security_resolver
|
||||||
|
@ -6,12 +8,10 @@ class PlaidAccount::Investments::TransactionsProcessor
|
||||||
|
|
||||||
def process
|
def process
|
||||||
transactions.each do |transaction|
|
transactions.each do |transaction|
|
||||||
resolved_security_result = security_resolver.resolve(plaid_security_id: transaction["security_id"])
|
if cash_transaction?(transaction)
|
||||||
|
|
||||||
if resolved_security_result.security.present?
|
|
||||||
find_or_create_trade_entry(transaction)
|
|
||||||
else
|
|
||||||
find_or_create_cash_entry(transaction)
|
find_or_create_cash_entry(transaction)
|
||||||
|
else
|
||||||
|
find_or_create_trade_entry(transaction)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -23,17 +23,25 @@ class PlaidAccount::Investments::TransactionsProcessor
|
||||||
plaid_account.account
|
plaid_account.account
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def cash_transaction?(transaction)
|
||||||
|
transaction["type"] == "cash" || transaction["type"] == "fee"
|
||||||
|
end
|
||||||
|
|
||||||
def find_or_create_trade_entry(transaction)
|
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|
|
entry = account.entries.find_or_initialize_by(plaid_id: transaction["investment_transaction_id"]) do |e|
|
||||||
e.entryable = Trade.new
|
e.entryable = Trade.new
|
||||||
end
|
end
|
||||||
|
|
||||||
entry.enrich_attribute(
|
|
||||||
:name,
|
|
||||||
transaction["name"],
|
|
||||||
source: "plaid"
|
|
||||||
)
|
|
||||||
|
|
||||||
entry.assign_attributes(
|
entry.assign_attributes(
|
||||||
amount: transaction["quantity"] * transaction["price"],
|
amount: transaction["quantity"] * transaction["price"],
|
||||||
currency: transaction["iso_currency_code"],
|
currency: transaction["iso_currency_code"],
|
||||||
|
@ -41,12 +49,18 @@ class PlaidAccount::Investments::TransactionsProcessor
|
||||||
)
|
)
|
||||||
|
|
||||||
entry.trade.assign_attributes(
|
entry.trade.assign_attributes(
|
||||||
security: security,
|
security: resolved_security_result.security,
|
||||||
qty: transaction["quantity"],
|
qty: transaction["quantity"],
|
||||||
price: transaction["price"],
|
price: transaction["price"],
|
||||||
currency: transaction["iso_currency_code"]
|
currency: transaction["iso_currency_code"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
entry.enrich_attribute(
|
||||||
|
:name,
|
||||||
|
transaction["name"],
|
||||||
|
source: "plaid"
|
||||||
|
)
|
||||||
|
|
||||||
entry.save!
|
entry.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -55,18 +69,18 @@ class PlaidAccount::Investments::TransactionsProcessor
|
||||||
e.entryable = Transaction.new
|
e.entryable = Transaction.new
|
||||||
end
|
end
|
||||||
|
|
||||||
entry.enrich_attribute(
|
|
||||||
:name,
|
|
||||||
transaction["name"],
|
|
||||||
source: "plaid"
|
|
||||||
)
|
|
||||||
|
|
||||||
entry.assign_attributes(
|
entry.assign_attributes(
|
||||||
amount: transaction["amount"],
|
amount: transaction["amount"],
|
||||||
currency: transaction["iso_currency_code"],
|
currency: transaction["iso_currency_code"],
|
||||||
date: transaction["date"]
|
date: transaction["date"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
entry.enrich_attribute(
|
||||||
|
:name,
|
||||||
|
transaction["name"],
|
||||||
|
source: "plaid"
|
||||||
|
)
|
||||||
|
|
||||||
entry.save!
|
entry.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ class PlaidAccount::Transactions::Processor
|
||||||
def process
|
def process
|
||||||
# Each entry is processed inside a transaction, but to avoid locking up the DB when
|
# 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.
|
# 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(
|
PlaidEntry::Processor.new(
|
||||||
transaction,
|
transaction,
|
||||||
plaid_account: plaid_account,
|
plaid_account: plaid_account,
|
||||||
|
@ -15,7 +15,7 @@ class PlaidAccount::Transactions::Processor
|
||||||
end
|
end
|
||||||
|
|
||||||
PlaidAccount.transaction do
|
PlaidAccount.transaction do
|
||||||
removed_transactions.find_each do |transaction|
|
removed_transactions.each do |transaction|
|
||||||
remove_plaid_transaction(transaction)
|
remove_plaid_transaction(transaction)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,14 +2,48 @@ require "test_helper"
|
||||||
|
|
||||||
class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
|
class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
|
||||||
setup do
|
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
|
end
|
||||||
|
|
||||||
test "creates holding records from Plaid holdings snapshot" do
|
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
|
end
|
||||||
|
|
||||||
test "upserts security records via SecurityResolver" do
|
holding = Holding.order(created_at: :desc).first
|
||||||
# TODO
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,14 +2,114 @@ require "test_helper"
|
||||||
|
|
||||||
class PlaidAccount::Investments::SecurityResolverTest < ActiveSupport::TestCase
|
class PlaidAccount::Investments::SecurityResolverTest < ActiveSupport::TestCase
|
||||||
setup do
|
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
|
end
|
||||||
|
|
||||||
test "finds existing security by identifiers" do
|
test "handles missing plaid security" do
|
||||||
# TODO
|
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
|
end
|
||||||
|
|
||||||
test "creates new security when none found" do
|
test "identifies brokerage cash plaid securities" do
|
||||||
# TODO
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,14 +2,110 @@ require "test_helper"
|
||||||
|
|
||||||
class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::TestCase
|
class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::TestCase
|
||||||
setup do
|
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
|
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
|
end
|
||||||
|
|
||||||
test "handles security resolution for unknown securities" do
|
entry = Entry.order(created_at: :desc).first
|
||||||
# TODO
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,14 +15,17 @@ class PlaidAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
|
||||||
removed: []
|
removed: []
|
||||||
})
|
})
|
||||||
|
|
||||||
mock_processor = mock("PlaidEntry::TransactionProcessor")
|
mock_processor = mock("PlaidEntry::Processor")
|
||||||
PlaidEntry::TransactionProcessor.expects(:new)
|
category_matcher_mock = mock("PlaidAccount::Transactions::CategoryMatcher")
|
||||||
.with(added_transactions.first, plaid_account: @plaid_account)
|
|
||||||
|
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)
|
.returns(mock_processor)
|
||||||
.once
|
.once
|
||||||
|
|
||||||
PlaidEntry::TransactionProcessor.expects(:new)
|
PlaidEntry::Processor.expects(:new)
|
||||||
.with(modified_transactions.first, plaid_account: @plaid_account)
|
.with(modified_transactions.first, plaid_account: @plaid_account, category_matcher: category_matcher_mock)
|
||||||
.returns(mock_processor)
|
.returns(mock_processor)
|
||||||
.once
|
.once
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue