mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-07 06:25:19 +02:00
Plaid sync domain improvements (#2267)
Breaks our Plaid sync process out into more manageable classes. Notably, this moves the sync process to a distinct, 2-step flow: 1. Import stage - we first make API calls and import Plaid data to "mirror" tables 2. Processing stage - read the raw data, apply business rules, build internal domain models and sync balances This provides several benefits: - Plaid syncs can now be "replayed" without fetching API data again - Mirror tables provide better audit and debugging capabilities - Eliminates the "all or nothing" sync behavior that is currently in place, which is brittle
This commit is contained in:
parent
5c82af0e8c
commit
03a146222d
72 changed files with 3763 additions and 706 deletions
35
test/models/plaid_account/importer_test.rb
Normal file
35
test/models/plaid_account/importer_test.rb
Normal file
|
@ -0,0 +1,35 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::ImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@mock_provider = PlaidMock.new
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@plaid_item = @plaid_account.plaid_item
|
||||
|
||||
@accounts_snapshot = PlaidItem::AccountsSnapshot.new(@plaid_item, plaid_provider: @mock_provider)
|
||||
@account_snapshot = @accounts_snapshot.get_account_data(@plaid_account.plaid_id)
|
||||
end
|
||||
|
||||
test "imports account data" do
|
||||
PlaidAccount::Importer.new(@plaid_account, account_snapshot: @account_snapshot).import
|
||||
|
||||
assert_equal @account_snapshot.account_data.account_id, @plaid_account.plaid_id
|
||||
assert_equal @account_snapshot.account_data.name, @plaid_account.name
|
||||
assert_equal @account_snapshot.account_data.mask, @plaid_account.mask
|
||||
assert_equal @account_snapshot.account_data.type, @plaid_account.plaid_type
|
||||
assert_equal @account_snapshot.account_data.subtype, @plaid_account.plaid_subtype
|
||||
|
||||
# This account has transactions data
|
||||
assert_equal PlaidMock::TRANSACTIONS.count, @plaid_account.raw_transactions_payload["added"].count
|
||||
|
||||
# This account does not have investment data
|
||||
assert_equal 0, @plaid_account.raw_investments_payload["holdings"].count
|
||||
assert_equal 0, @plaid_account.raw_investments_payload["securities"].count
|
||||
assert_equal 0, @plaid_account.raw_investments_payload["transactions"].count
|
||||
|
||||
# This account is a credit card, so it should have liability data
|
||||
assert_equal @plaid_account.plaid_id, @plaid_account.raw_liabilities_payload["credit"]["account_id"]
|
||||
assert_nil @plaid_account.raw_liabilities_payload["mortgage"]
|
||||
assert_nil @plaid_account.raw_liabilities_payload["student"]
|
||||
end
|
||||
end
|
|
@ -0,0 +1,83 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Investments::BalanceCalculatorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
|
||||
@plaid_account.update!(
|
||||
plaid_type: "investment",
|
||||
current_balance: 4000,
|
||||
available_balance: 2000 # We ignore this since we have current_balance + holdings
|
||||
)
|
||||
end
|
||||
|
||||
test "calculates total balance from cash and positions" do
|
||||
brokerage_cash_security_id = "plaid_brokerage_cash" # Plaid's brokerage cash security
|
||||
cash_equivalent_security_id = "plaid_cash_equivalent" # Cash equivalent security (i.e. money market fund)
|
||||
aapl_security_id = "plaid_aapl_security" # Regular stock security
|
||||
|
||||
test_investments = {
|
||||
transactions: [], # Irrelevant for balance calcs, leave empty
|
||||
holdings: [
|
||||
# $1,000 in brokerage cash
|
||||
{
|
||||
security_id: brokerage_cash_security_id,
|
||||
cost_basis: 1000,
|
||||
institution_price: 1,
|
||||
institution_value: 1000,
|
||||
quantity: 1000
|
||||
},
|
||||
# $1,000 in money market funds
|
||||
{
|
||||
security_id: cash_equivalent_security_id,
|
||||
cost_basis: 1000,
|
||||
institution_price: 1,
|
||||
institution_value: 1000,
|
||||
quantity: 1000
|
||||
},
|
||||
# $2,000 worth of AAPL stock
|
||||
{
|
||||
security_id: aapl_security_id,
|
||||
cost_basis: 2000,
|
||||
institution_price: 200,
|
||||
institution_value: 2000,
|
||||
quantity: 10
|
||||
}
|
||||
],
|
||||
securities: [
|
||||
{
|
||||
security_id: brokerage_cash_security_id,
|
||||
ticker_symbol: "CUR:USD",
|
||||
is_cash_equivalent: true,
|
||||
type: "cash"
|
||||
},
|
||||
{
|
||||
security_id: cash_equivalent_security_id,
|
||||
ticker_symbol: "VMFXX", # Vanguard Money Market Reserves
|
||||
is_cash_equivalent: true,
|
||||
type: "mutual fund"
|
||||
},
|
||||
{
|
||||
security_id: aapl_security_id,
|
||||
ticker_symbol: "AAPL",
|
||||
is_cash_equivalent: false,
|
||||
type: "equity",
|
||||
market_identifier_code: "XNAS"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@plaid_account.update!(raw_investments_payload: test_investments)
|
||||
|
||||
security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
|
||||
balance_calculator = PlaidAccount::Investments::BalanceCalculator.new(@plaid_account, security_resolver: security_resolver)
|
||||
|
||||
# We set this equal to `current_balance`
|
||||
assert_equal 4000, balance_calculator.balance
|
||||
|
||||
# This is the sum of "non-brokerage-cash-holdings". In the above test case, this means
|
||||
# we're summing up $2,000 of AAPL + $1,000 Vanguard MM for $3,000 in holdings value.
|
||||
# We back this $3,000 from the $4,000 total to get $1,000 in cash balance.
|
||||
assert_equal 1000, balance_calculator.cash_balance
|
||||
end
|
||||
end
|
|
@ -0,0 +1,49 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
|
||||
end
|
||||
|
||||
test "creates holding records from Plaid holdings snapshot" do
|
||||
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
|
||||
|
||||
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
|
115
test/models/plaid_account/investments/security_resolver_test.rb
Normal file
115
test/models/plaid_account/investments/security_resolver_test.rb
Normal file
|
@ -0,0 +1,115 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Investments::SecurityResolverTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@upstream_resolver = mock("Security::Resolver")
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
|
||||
end
|
||||
|
||||
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 "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
|
|
@ -0,0 +1,111 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
|
||||
end
|
||||
|
||||
|
||||
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 "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
|
|
@ -0,0 +1,39 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Liabilities::CreditProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@plaid_account.update!(
|
||||
plaid_type: "credit",
|
||||
plaid_subtype: "credit_card"
|
||||
)
|
||||
|
||||
@plaid_account.account.update!(
|
||||
accountable: CreditCard.new,
|
||||
)
|
||||
end
|
||||
|
||||
test "updates credit card minimum payment and APR from Plaid data" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {
|
||||
credit: {
|
||||
minimum_payment_amount: 100,
|
||||
aprs: [ { apr_percentage: 15.0 } ]
|
||||
}
|
||||
})
|
||||
|
||||
processor = PlaidAccount::Liabilities::CreditProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
assert_equal 100, @plaid_account.account.credit_card.minimum_payment
|
||||
assert_equal 15.0, @plaid_account.account.credit_card.apr
|
||||
end
|
||||
|
||||
test "does nothing when liability data absent" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {})
|
||||
processor = PlaidAccount::Liabilities::CreditProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
assert_nil @plaid_account.account.credit_card.minimum_payment
|
||||
assert_nil @plaid_account.account.credit_card.apr
|
||||
end
|
||||
end
|
|
@ -0,0 +1,44 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Liabilities::MortgageProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@plaid_account.update!(
|
||||
plaid_type: "loan",
|
||||
plaid_subtype: "mortgage"
|
||||
)
|
||||
|
||||
@plaid_account.account.update!(accountable: Loan.new)
|
||||
end
|
||||
|
||||
test "updates loan interest rate and type from Plaid data" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {
|
||||
mortgage: {
|
||||
interest_rate: {
|
||||
type: "fixed",
|
||||
percentage: 4.25
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
processor = PlaidAccount::Liabilities::MortgageProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
|
||||
assert_equal "fixed", loan.rate_type
|
||||
assert_equal 4.25, loan.interest_rate
|
||||
end
|
||||
|
||||
test "does nothing when mortgage data absent" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {})
|
||||
|
||||
processor = PlaidAccount::Liabilities::MortgageProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
|
||||
assert_nil loan.rate_type
|
||||
assert_nil loan.interest_rate
|
||||
end
|
||||
end
|
|
@ -0,0 +1,68 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Liabilities::StudentLoanProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
@plaid_account.update!(
|
||||
plaid_type: "loan",
|
||||
plaid_subtype: "student"
|
||||
)
|
||||
|
||||
# Change the underlying accountable to a Loan so the helper method `loan` is available
|
||||
@plaid_account.account.update!(accountable: Loan.new)
|
||||
end
|
||||
|
||||
test "updates loan details including term months from Plaid data" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {
|
||||
student: {
|
||||
interest_rate_percentage: 5.5,
|
||||
origination_principal_amount: 20000,
|
||||
origination_date: Date.new(2020, 1, 1),
|
||||
expected_payoff_date: Date.new(2022, 1, 1)
|
||||
}
|
||||
})
|
||||
|
||||
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
|
||||
assert_equal "fixed", loan.rate_type
|
||||
assert_equal 5.5, loan.interest_rate
|
||||
assert_equal 20000, loan.initial_balance
|
||||
assert_equal 24, loan.term_months
|
||||
end
|
||||
|
||||
test "handles missing payoff dates gracefully" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {
|
||||
student: {
|
||||
interest_rate_percentage: 4.8,
|
||||
origination_principal_amount: 15000,
|
||||
origination_date: Date.new(2021, 6, 1)
|
||||
# expected_payoff_date omitted
|
||||
}
|
||||
})
|
||||
|
||||
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
|
||||
assert_nil loan.term_months
|
||||
assert_equal 4.8, loan.interest_rate
|
||||
assert_equal 15000, loan.initial_balance
|
||||
end
|
||||
|
||||
test "does nothing when loan data absent" do
|
||||
@plaid_account.update!(raw_liabilities_payload: {})
|
||||
|
||||
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
|
||||
processor.process
|
||||
|
||||
loan = @plaid_account.account.loan
|
||||
|
||||
assert_nil loan.interest_rate
|
||||
assert_nil loan.initial_balance
|
||||
assert_nil loan.term_months
|
||||
end
|
||||
end
|
172
test/models/plaid_account/processor_test.rb
Normal file
172
test/models/plaid_account/processor_test.rb
Normal file
|
@ -0,0 +1,172 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
end
|
||||
|
||||
test "processes new account and assigns attributes" do
|
||||
Account.destroy_all # Clear out internal accounts so we start fresh
|
||||
|
||||
expect_default_subprocessor_calls
|
||||
|
||||
@plaid_account.update!(
|
||||
plaid_id: "test_plaid_id",
|
||||
plaid_type: "depository",
|
||||
plaid_subtype: "checking",
|
||||
current_balance: 1000,
|
||||
available_balance: 1000,
|
||||
currency: "USD",
|
||||
name: "Test Plaid Account",
|
||||
mask: "1234"
|
||||
)
|
||||
|
||||
assert_difference "Account.count" do
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
|
||||
@plaid_account.reload
|
||||
|
||||
account = Account.order(created_at: :desc).first
|
||||
assert_equal "Test Plaid Account", account.name
|
||||
assert_equal @plaid_account.id, account.plaid_account_id
|
||||
assert_equal "checking", account.subtype
|
||||
assert_equal 1000, account.balance
|
||||
assert_equal 1000, account.cash_balance
|
||||
assert_equal "USD", account.currency
|
||||
assert_equal "Depository", account.accountable_type
|
||||
assert_equal "checking", account.subtype
|
||||
end
|
||||
|
||||
test "processing is idempotent with updates and enrichments" do
|
||||
expect_default_subprocessor_calls
|
||||
|
||||
assert_equal "Plaid Depository Account", @plaid_account.account.name
|
||||
assert_equal "checking", @plaid_account.account.subtype
|
||||
|
||||
@plaid_account.account.update!(
|
||||
name: "User updated name",
|
||||
subtype: "savings",
|
||||
balance: 2000 # User cannot override balance. This will be overridden by the processor on next processing
|
||||
)
|
||||
|
||||
@plaid_account.account.lock_attr!(:name)
|
||||
@plaid_account.account.lock_attr!(:subtype)
|
||||
@plaid_account.account.lock_attr!(:balance) # Even if balance somehow becomes locked, Plaid ignores it and overrides it
|
||||
|
||||
assert_no_difference "Account.count" do
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
|
||||
@plaid_account.reload
|
||||
|
||||
assert_equal "User updated name", @plaid_account.account.name
|
||||
assert_equal "savings", @plaid_account.account.subtype
|
||||
assert_equal @plaid_account.current_balance, @plaid_account.account.balance # Overriden by processor
|
||||
end
|
||||
|
||||
test "account processing failure halts further processing" do
|
||||
Account.any_instance.stubs(:save!).raises(StandardError.new("Test error"))
|
||||
|
||||
PlaidAccount::Transactions::Processor.any_instance.expects(:process).never
|
||||
PlaidAccount::Investments::TransactionsProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Investments::HoldingsProcessor.any_instance.expects(:process).never
|
||||
|
||||
expect_no_investment_balance_calculator_calls
|
||||
expect_no_liability_processor_calls
|
||||
|
||||
assert_raises(StandardError) do
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
end
|
||||
|
||||
test "product processing failure reports exception and continues processing" do
|
||||
PlaidAccount::Transactions::Processor.any_instance.stubs(:process).raises(StandardError.new("Test error"))
|
||||
|
||||
# Subsequent product processors still run
|
||||
expect_investment_product_processor_calls
|
||||
|
||||
assert_nothing_raised do
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
end
|
||||
|
||||
test "calculates balance using BalanceCalculator for investment accounts" do
|
||||
@plaid_account.update!(plaid_type: "investment")
|
||||
|
||||
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).returns(1000).once
|
||||
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:cash_balance).returns(1000).once
|
||||
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
|
||||
test "processes credit liability data" do
|
||||
expect_investment_product_processor_calls
|
||||
expect_no_investment_balance_calculator_calls
|
||||
expect_depository_product_processor_calls
|
||||
|
||||
@plaid_account.update!(plaid_type: "credit", plaid_subtype: "credit card")
|
||||
|
||||
PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).once
|
||||
PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).never
|
||||
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
|
||||
test "processes mortgage liability data" do
|
||||
expect_investment_product_processor_calls
|
||||
expect_no_investment_balance_calculator_calls
|
||||
expect_depository_product_processor_calls
|
||||
|
||||
@plaid_account.update!(plaid_type: "loan", plaid_subtype: "mortgage")
|
||||
|
||||
PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).once
|
||||
PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).never
|
||||
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
|
||||
test "processes student loan liability data" do
|
||||
expect_investment_product_processor_calls
|
||||
expect_no_investment_balance_calculator_calls
|
||||
expect_depository_product_processor_calls
|
||||
|
||||
@plaid_account.update!(plaid_type: "loan", plaid_subtype: "student")
|
||||
|
||||
PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).once
|
||||
|
||||
PlaidAccount::Processor.new(@plaid_account).process
|
||||
end
|
||||
|
||||
private
|
||||
def expect_investment_product_processor_calls
|
||||
PlaidAccount::Investments::TransactionsProcessor.any_instance.expects(:process).once
|
||||
PlaidAccount::Investments::HoldingsProcessor.any_instance.expects(:process).once
|
||||
end
|
||||
|
||||
def expect_depository_product_processor_calls
|
||||
PlaidAccount::Transactions::Processor.any_instance.expects(:process).once
|
||||
end
|
||||
|
||||
def expect_no_investment_balance_calculator_calls
|
||||
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).never
|
||||
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:cash_balance).never
|
||||
end
|
||||
|
||||
def expect_no_liability_processor_calls
|
||||
PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).never
|
||||
PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).never
|
||||
end
|
||||
|
||||
def expect_default_subprocessor_calls
|
||||
expect_depository_product_processor_calls
|
||||
expect_investment_product_processor_calls
|
||||
expect_no_investment_balance_calculator_calls
|
||||
expect_no_liability_processor_calls
|
||||
end
|
||||
end
|
136
test/models/plaid_account/transactions/category_matcher_test.rb
Normal file
136
test/models/plaid_account/transactions/category_matcher_test.rb
Normal file
|
@ -0,0 +1,136 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Transactions::CategoryMatcherTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:empty)
|
||||
|
||||
# User income categories
|
||||
@income = @family.categories.create!(name: "Income", classification: "income")
|
||||
@dividend_income = @family.categories.create!(name: "Dividend Income", parent: @income, classification: "income")
|
||||
@interest_income = @family.categories.create!(name: "Interest Income", parent: @income, classification: "income")
|
||||
|
||||
# User expense categories
|
||||
@loan_payments = @family.categories.create!(name: "Loan Payments")
|
||||
@fees = @family.categories.create!(name: "Fees")
|
||||
@entertainment = @family.categories.create!(name: "Entertainment")
|
||||
|
||||
@food_and_drink = @family.categories.create!(name: "Food & Drink")
|
||||
@groceries = @family.categories.create!(name: "Groceries", parent: @food_and_drink)
|
||||
@restaurant = @family.categories.create!(name: "Restaurant", parent: @food_and_drink)
|
||||
|
||||
@shopping = @family.categories.create!(name: "Shopping")
|
||||
@clothing = @family.categories.create!(name: "Clothing", parent: @shopping)
|
||||
|
||||
@home = @family.categories.create!(name: "Home")
|
||||
@medical = @family.categories.create!(name: "Medical")
|
||||
@personal_care = @family.categories.create!(name: "Personal Care")
|
||||
@transportation = @family.categories.create!(name: "Transportation")
|
||||
@trips = @family.categories.create!(name: "Trips")
|
||||
|
||||
@services = @family.categories.create!(name: "Services")
|
||||
@car = @family.categories.create!(name: "Car", parent: @services)
|
||||
|
||||
@giving = @family.categories.create!(name: "Giving")
|
||||
|
||||
@matcher = PlaidAccount::Transactions::CategoryMatcher.new(@family.categories)
|
||||
end
|
||||
|
||||
test "matches expense categories" do
|
||||
assert_equal @loan_payments, @matcher.match("loan_payments_car_payment")
|
||||
assert_equal @loan_payments, @matcher.match("loan_payments_credit_card_payment")
|
||||
assert_equal @loan_payments, @matcher.match("loan_payments_personal_loan_payment")
|
||||
assert_equal @loan_payments, @matcher.match("loan_payments_mortgage_payment")
|
||||
assert_equal @loan_payments, @matcher.match("loan_payments_student_loan_payment")
|
||||
assert_equal @loan_payments, @matcher.match("loan_payments_other_payment")
|
||||
assert_equal @fees, @matcher.match("bank_fees_atm_fees")
|
||||
assert_equal @fees, @matcher.match("bank_fees_foreign_transaction_fees")
|
||||
assert_equal @fees, @matcher.match("bank_fees_insufficient_funds")
|
||||
assert_equal @fees, @matcher.match("bank_fees_interest_charge")
|
||||
assert_equal @fees, @matcher.match("bank_fees_overdraft_fees")
|
||||
assert_equal @fees, @matcher.match("bank_fees_other_bank_fees")
|
||||
assert_equal @entertainment, @matcher.match("entertainment_casinos_and_gambling")
|
||||
assert_equal @entertainment, @matcher.match("entertainment_music_and_audio")
|
||||
assert_equal @entertainment, @matcher.match("entertainment_sporting_events_amusement_parks_and_museums")
|
||||
assert_equal @entertainment, @matcher.match("entertainment_tv_and_movies")
|
||||
assert_equal @entertainment, @matcher.match("entertainment_video_games")
|
||||
assert_equal @entertainment, @matcher.match("entertainment_other_entertainment")
|
||||
assert_equal @food_and_drink, @matcher.match("food_and_drink_beer_wine_and_liquor")
|
||||
assert_equal @food_and_drink, @matcher.match("food_and_drink_coffee")
|
||||
assert_equal @food_and_drink, @matcher.match("food_and_drink_fast_food")
|
||||
assert_equal @groceries, @matcher.match("food_and_drink_groceries")
|
||||
assert_equal @restaurant, @matcher.match("food_and_drink_restaurant")
|
||||
assert_equal @food_and_drink, @matcher.match("food_and_drink_vending_machines")
|
||||
assert_equal @food_and_drink, @matcher.match("food_and_drink_other_food_and_drink")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_bookstores_and_newsstands")
|
||||
assert_equal @clothing, @matcher.match("general_merchandise_clothing_and_accessories")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_convenience_stores")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_department_stores")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_discount_stores")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_electronics")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_gifts_and_novelties")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_office_supplies")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_online_marketplaces")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_pet_supplies")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_sporting_goods")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_superstores")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_tobacco_and_vape")
|
||||
assert_equal @shopping, @matcher.match("general_merchandise_other_general_merchandise")
|
||||
assert_equal @home, @matcher.match("home_improvement_furniture")
|
||||
assert_equal @home, @matcher.match("home_improvement_hardware")
|
||||
assert_equal @home, @matcher.match("home_improvement_repair_and_maintenance")
|
||||
assert_equal @home, @matcher.match("home_improvement_security")
|
||||
assert_equal @home, @matcher.match("home_improvement_other_home_improvement")
|
||||
assert_equal @medical, @matcher.match("medical_dental_care")
|
||||
assert_equal @medical, @matcher.match("medical_eye_care")
|
||||
assert_equal @medical, @matcher.match("medical_nursing_care")
|
||||
assert_equal @medical, @matcher.match("medical_pharmacies_and_supplements")
|
||||
assert_equal @medical, @matcher.match("medical_primary_care")
|
||||
assert_equal @medical, @matcher.match("medical_veterinary_services")
|
||||
assert_equal @medical, @matcher.match("medical_other_medical")
|
||||
assert_equal @personal_care, @matcher.match("personal_care_gyms_and_fitness_centers")
|
||||
assert_equal @personal_care, @matcher.match("personal_care_hair_and_beauty")
|
||||
assert_equal @personal_care, @matcher.match("personal_care_laundry_and_dry_cleaning")
|
||||
assert_equal @personal_care, @matcher.match("personal_care_other_personal_care")
|
||||
assert_equal @services, @matcher.match("general_services_accounting_and_financial_planning")
|
||||
assert_equal @car, @matcher.match("general_services_automotive")
|
||||
assert_equal @services, @matcher.match("general_services_childcare")
|
||||
assert_equal @services, @matcher.match("general_services_consulting_and_legal")
|
||||
assert_equal @services, @matcher.match("general_services_education")
|
||||
assert_equal @services, @matcher.match("general_services_insurance")
|
||||
assert_equal @services, @matcher.match("general_services_postage_and_shipping")
|
||||
assert_equal @services, @matcher.match("general_services_storage")
|
||||
assert_equal @services, @matcher.match("general_services_other_general_services")
|
||||
assert_equal @giving, @matcher.match("government_and_non_profit_donations")
|
||||
assert_nil @matcher.match("government_and_non_profit_government_departments_and_agencies")
|
||||
assert_nil @matcher.match("government_and_non_profit_tax_payment")
|
||||
assert_nil @matcher.match("government_and_non_profit_other_government_and_non_profit")
|
||||
assert_equal @transportation, @matcher.match("transportation_bikes_and_scooters")
|
||||
assert_equal @transportation, @matcher.match("transportation_gas")
|
||||
assert_equal @transportation, @matcher.match("transportation_parking")
|
||||
assert_equal @transportation, @matcher.match("transportation_public_transit")
|
||||
assert_equal @transportation, @matcher.match("transportation_taxis_and_ride_shares")
|
||||
assert_equal @transportation, @matcher.match("transportation_tolls")
|
||||
assert_equal @transportation, @matcher.match("transportation_other_transportation")
|
||||
assert_equal @trips, @matcher.match("travel_flights")
|
||||
assert_equal @trips, @matcher.match("travel_lodging")
|
||||
assert_equal @trips, @matcher.match("travel_rental_cars")
|
||||
assert_equal @trips, @matcher.match("travel_other_travel")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_gas_and_electricity")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_internet_and_cable")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_rent")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_sewage_and_waste_management")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_telephone")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_water")
|
||||
assert_equal @home, @matcher.match("rent_and_utilities_other_utilities")
|
||||
end
|
||||
|
||||
test "matches income categories" do
|
||||
assert_equal @dividend_income, @matcher.match("income_dividends")
|
||||
assert_equal @interest_income, @matcher.match("income_interest_earned")
|
||||
assert_equal @income, @matcher.match("income_tax_refund")
|
||||
assert_equal @income, @matcher.match("income_retirement_pension")
|
||||
assert_equal @income, @matcher.match("income_unemployment")
|
||||
assert_equal @income, @matcher.match("income_wages")
|
||||
assert_equal @income, @matcher.match("income_other_income")
|
||||
end
|
||||
end
|
63
test/models/plaid_account/transactions/processor_test.rb
Normal file
63
test/models/plaid_account/transactions/processor_test.rb
Normal file
|
@ -0,0 +1,63 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@plaid_account = plaid_accounts(:one)
|
||||
end
|
||||
|
||||
test "processes added and modified plaid transactions" do
|
||||
added_transactions = [ { "transaction_id" => "123" } ]
|
||||
modified_transactions = [ { "transaction_id" => "456" } ]
|
||||
|
||||
@plaid_account.update!(raw_transactions_payload: {
|
||||
added: added_transactions,
|
||||
modified: modified_transactions,
|
||||
removed: []
|
||||
})
|
||||
|
||||
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::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
|
||||
|
||||
processor = PlaidAccount::Transactions::Processor.new(@plaid_account)
|
||||
processor.process
|
||||
end
|
||||
|
||||
test "removes transactions no longer in plaid" do
|
||||
destroyable_transaction_id = "destroy_me"
|
||||
@plaid_account.account.entries.create!(
|
||||
plaid_id: destroyable_transaction_id,
|
||||
date: Date.current,
|
||||
amount: 100,
|
||||
name: "Destroy me",
|
||||
currency: "USD",
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
@plaid_account.update!(raw_transactions_payload: {
|
||||
added: [],
|
||||
modified: [],
|
||||
removed: [ { "transaction_id" => destroyable_transaction_id } ]
|
||||
})
|
||||
|
||||
processor = PlaidAccount::Transactions::Processor.new(@plaid_account)
|
||||
|
||||
assert_difference [ "Entry.count", "Transaction.count" ], -1 do
|
||||
processor.process
|
||||
end
|
||||
|
||||
assert_nil Entry.find_by(plaid_id: destroyable_transaction_id)
|
||||
end
|
||||
end
|
35
test/models/plaid_account/type_mappable_test.rb
Normal file
35
test/models/plaid_account/type_mappable_test.rb
Normal file
|
@ -0,0 +1,35 @@
|
|||
require "test_helper"
|
||||
|
||||
class PlaidAccount::TypeMappableTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
class MockProcessor
|
||||
include PlaidAccount::TypeMappable
|
||||
end
|
||||
|
||||
@mock_processor = MockProcessor.new
|
||||
end
|
||||
|
||||
test "maps types to accountables" do
|
||||
assert_instance_of Depository, @mock_processor.map_accountable("depository")
|
||||
assert_instance_of Investment, @mock_processor.map_accountable("investment")
|
||||
assert_instance_of CreditCard, @mock_processor.map_accountable("credit")
|
||||
assert_instance_of Loan, @mock_processor.map_accountable("loan")
|
||||
assert_instance_of OtherAsset, @mock_processor.map_accountable("other")
|
||||
end
|
||||
|
||||
test "maps subtypes" do
|
||||
assert_equal "checking", @mock_processor.map_subtype("depository", "checking")
|
||||
assert_equal "roth_ira", @mock_processor.map_subtype("investment", "roth")
|
||||
end
|
||||
|
||||
test "raises on invalid types" do
|
||||
assert_raises PlaidAccount::TypeMappable::UnknownAccountTypeError do
|
||||
@mock_processor.map_accountable("unknown")
|
||||
end
|
||||
end
|
||||
|
||||
test "handles nil subtypes" do
|
||||
assert_equal "other", @mock_processor.map_subtype("depository", nil)
|
||||
assert_equal "other", @mock_processor.map_subtype("depository", "unknown")
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue