mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +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
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
|
Loading…
Add table
Add a link
Reference in a new issue