diff --git a/app/models/plaid_item/accounts_snapshot.rb b/app/models/plaid_item/accounts_snapshot.rb index a6d2c355..fee02a76 100644 --- a/app/models/plaid_item/accounts_snapshot.rb +++ b/app/models/plaid_item/accounts_snapshot.rb @@ -62,18 +62,32 @@ class PlaidItem::AccountsSnapshot ) end + def can_fetch_transactions? + plaid_item.supports_product?("transactions") && accounts.any? + end + def transactions_data - return nil unless plaid_item.supports_product?("transactions") + return nil unless can_fetch_transactions? @transactions_data ||= plaid_provider.get_transactions(plaid_item.access_token) end + def can_fetch_investments? + plaid_item.supports_product?("investments") && + accounts.any? { |a| a.type == "investment" } + end + def investments_data - return nil unless plaid_item.supports_product?("investments") + return nil unless can_fetch_investments? @investments_data ||= plaid_provider.get_item_investments(plaid_item.access_token) end + def can_fetch_liabilities? + plaid_item.supports_product?("liabilities") && + accounts.any? { |a| [ "credit", "loan" ].include?(a.type) } + end + def liabilities_data - return nil unless plaid_item.supports_product?("liabilities") + return nil unless can_fetch_liabilities? @liabilities_data ||= plaid_provider.get_item_liabilities(plaid_item.access_token) end end diff --git a/test/models/plaid_account/importer_test.rb b/test/models/plaid_account/importer_test.rb index 68f61041..b1084cdb 100644 --- a/test/models/plaid_account/importer_test.rb +++ b/test/models/plaid_account/importer_test.rb @@ -2,34 +2,45 @@ 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) + @mock_account_snapshot = mock end test "imports account data" do - PlaidAccount::Importer.new(@plaid_account, account_snapshot: @account_snapshot).import + account_data = OpenStruct.new( + account_id: "acc_1", + name: "Test Account", + mask: "1234", + ) - 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 + transactions_data = OpenStruct.new( + added: [], + modified: [], + removed: [], + ) - # This account has transactions data - assert_equal PlaidMock::TRANSACTIONS.count, @plaid_account.raw_transactions_payload["added"].count + investments_data = OpenStruct.new( + holdings: [], + transactions: [], + securities: [], + ) - # 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 + liabilities_data = OpenStruct.new( + credit: [], + mortgage: [], + student: [], + ) - # 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"] + @mock_account_snapshot.expects(:account_data).returns(account_data).at_least_once + @mock_account_snapshot.expects(:transactions_data).returns(transactions_data).at_least_once + @mock_account_snapshot.expects(:investments_data).returns(investments_data).at_least_once + @mock_account_snapshot.expects(:liabilities_data).returns(liabilities_data).at_least_once + + @plaid_account.expects(:upsert_plaid_snapshot!).with(account_data) + @plaid_account.expects(:upsert_plaid_transactions_snapshot!).with(transactions_data) + @plaid_account.expects(:upsert_plaid_investments_snapshot!).with(investments_data) + @plaid_account.expects(:upsert_plaid_liabilities_snapshot!).with(liabilities_data) + + PlaidAccount::Importer.new(@plaid_account, account_snapshot: @mock_account_snapshot).import end end diff --git a/test/models/plaid_item/accounts_snapshot_test.rb b/test/models/plaid_item/accounts_snapshot_test.rb new file mode 100644 index 00000000..a08b0e1e --- /dev/null +++ b/test/models/plaid_item/accounts_snapshot_test.rb @@ -0,0 +1,105 @@ +require "test_helper" + +class PlaidItem::AccountsSnapshotTest < ActiveSupport::TestCase + setup do + @plaid_item = plaid_items(:one) + @plaid_item.plaid_accounts.destroy_all # Clean slate + + @plaid_provider = mock + @snapshot = PlaidItem::AccountsSnapshot.new(@plaid_item, plaid_provider: @plaid_provider) + end + + test "fetches accounts" do + @plaid_provider.expects(:get_item_accounts).with(@plaid_item.access_token).returns( + OpenStruct.new(accounts: []) + ) + @snapshot.accounts + end + + test "fetches transactions data if item supports transactions and any accounts present" do + @plaid_item.update!(available_products: [ "transactions" ], billed_products: []) + + @snapshot.expects(:accounts).returns([ + OpenStruct.new( + account_id: "123", + type: "depository" + ) + ]).at_least_once + + @plaid_provider.expects(:get_transactions).with(@plaid_item.access_token).once + @plaid_provider.expects(:get_item_investments).never + @plaid_provider.expects(:get_item_liabilities).never + + @snapshot.get_account_data("123") + end + + test "does not fetch transactions if no accounts" do + @plaid_item.update!(available_products: [ "transactions" ], billed_products: []) + + @snapshot.expects(:accounts).returns([]).at_least_once + + @plaid_provider.expects(:get_transactions).never + @plaid_provider.expects(:get_item_investments).never + @plaid_provider.expects(:get_item_liabilities).never + + @snapshot.get_account_data("123") + end + + test "fetches investments data if item supports investments and investment accounts present" do + @plaid_item.update!(available_products: [ "investments" ], billed_products: []) + + @snapshot.expects(:accounts).returns([ + OpenStruct.new( + account_id: "123", + type: "investment" + ) + ]).at_least_once + + @plaid_provider.expects(:get_transactions).never + @plaid_provider.expects(:get_item_investments).with(@plaid_item.access_token).once + @plaid_provider.expects(:get_item_liabilities).never + + @snapshot.get_account_data("123") + end + + test "does not fetch investments if no investment accounts" do + @plaid_item.update!(available_products: [ "investments" ], billed_products: []) + + @snapshot.expects(:accounts).returns([]).at_least_once + + @plaid_provider.expects(:get_transactions).never + @plaid_provider.expects(:get_item_investments).never + @plaid_provider.expects(:get_item_liabilities).never + + @snapshot.get_account_data("123") + end + + test "fetches liabilities data if item supports liabilities and liabilities accounts present" do + @plaid_item.update!(available_products: [ "liabilities" ], billed_products: []) + + @snapshot.expects(:accounts).returns([ + OpenStruct.new( + account_id: "123", + type: "credit" + ) + ]).at_least_once + + @plaid_provider.expects(:get_transactions).never + @plaid_provider.expects(:get_item_investments).never + @plaid_provider.expects(:get_item_liabilities).with(@plaid_item.access_token).once + + @snapshot.get_account_data("123") + end + + test "does not fetch liabilities if no liabilities accounts" do + @plaid_item.update!(available_products: [ "liabilities" ], billed_products: []) + + @snapshot.expects(:accounts).returns([]).at_least_once + + @plaid_provider.expects(:get_transactions).never + @plaid_provider.expects(:get_item_investments).never + @plaid_provider.expects(:get_item_liabilities).never + + @snapshot.get_account_data("123") + end +end diff --git a/test/models/plaid_item/importer_test.rb b/test/models/plaid_item/importer_test.rb index 085517c9..cce355fa 100644 --- a/test/models/plaid_item/importer_test.rb +++ b/test/models/plaid_item/importer_test.rb @@ -3,21 +3,47 @@ require "ostruct" class PlaidItem::ImporterTest < ActiveSupport::TestCase setup do - @mock_provider = PlaidMock.new + @mock_provider = mock("Provider::Plaid") @plaid_item = plaid_items(:one) @importer = PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider) end test "imports item metadata" do - PlaidAccount::Importer.any_instance.expects(:import).times(PlaidMock::ACCOUNTS.count) + item_data = OpenStruct.new( + item_id: "item_1", + available_products: [ "transactions", "investments", "liabilities" ], + billed_products: [], + institution_id: "ins_1", + institution_name: "First Platypus Bank", + ) - PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider).import + @mock_provider.expects(:get_item).with(@plaid_item.access_token).returns( + OpenStruct.new(item: item_data) + ) - assert_equal PlaidMock::ITEM.institution_id, @plaid_item.institution_id - assert_equal PlaidMock::ITEM.available_products, @plaid_item.available_products - assert_equal PlaidMock::ITEM.billed_products, @plaid_item.billed_products + institution_data = OpenStruct.new( + institution_id: "ins_1", + institution_name: "First Platypus Bank", + ) - assert_equal PlaidMock::ITEM.item_id, @plaid_item.raw_payload["item_id"] - assert_equal PlaidMock::INSTITUTION.institution_id, @plaid_item.raw_institution_payload["institution_id"] + @mock_provider.expects(:get_institution).with("ins_1").returns( + OpenStruct.new(institution: institution_data) + ) + + PlaidItem::AccountsSnapshot.any_instance.expects(:accounts).returns([ + OpenStruct.new( + account_id: "acc_1", + type: "depository" + ) + ]).at_least_once + + PlaidItem::AccountsSnapshot.any_instance.expects(:get_account_data).with("acc_1").once + + PlaidAccount::Importer.any_instance.expects(:import).once + + @plaid_item.expects(:upsert_plaid_snapshot!).with(item_data) + @plaid_item.expects(:upsert_plaid_institution_snapshot!).with(institution_data) + + @importer.import end end diff --git a/test/support/plaid_mock.rb b/test/support/plaid_mock.rb deleted file mode 100644 index eddc54b8..00000000 --- a/test/support/plaid_mock.rb +++ /dev/null @@ -1,214 +0,0 @@ -require "ostruct" - -# Lightweight wrapper that allows Ostruct objects to properly serialize to JSON -# for storage on PlaidItem / PlaidAccount JSONB columns -class MockData < OpenStruct - def as_json(options = {}) - @table.as_json(options) - end -end - -# A basic Plaid provider mock that returns static payloads for testing -class PlaidMock - TransactionSyncResponse = Struct.new(:added, :modified, :removed, :cursor, keyword_init: true) - InvestmentsResponse = Struct.new(:holdings, :transactions, :securities, keyword_init: true) - - ITEM = MockData.new( - item_id: "item_mock_1", - institution_id: "ins_mock", - institution_name: "Mock Institution", - available_products: [], - billed_products: %w[transactions investments liabilities] - ) - - INSTITUTION = MockData.new( - institution_id: "ins_mock", - institution_name: "Mock Institution" - ) - - ACCOUNTS = [ - MockData.new( - account_id: "acc_mock_1", - name: "Mock Checking", - mask: "1111", - type: "depository", - subtype: "checking", - balances: MockData.new( - current: 1_000.00, - available: 800.00, - iso_currency_code: "USD" - ) - ), - MockData.new( - account_id: "acc_mock_2", - name: "Mock Brokerage", - mask: "2222", - type: "investment", - subtype: "brokerage", - balances: MockData.new( - current: 15_000.00, - available: 15_000.00, - iso_currency_code: "USD" - ) - ) - ] - - SECURITIES = [ - MockData.new( - security_id: "sec_mock_1", - ticker_symbol: "AAPL", - proxy_security_id: nil, - market_identifier_code: "XNAS", - type: "equity", - is_cash_equivalent: false - ), - # Cash security representation – used to exclude cash-equivalent holdings - MockData.new( - security_id: "sec_mock_cash", - ticker_symbol: "CUR:USD", - proxy_security_id: nil, - market_identifier_code: nil, - type: "cash", - is_cash_equivalent: true - ) - ] - - TRANSACTIONS = [ - MockData.new( - transaction_id: "txn_mock_1", - account_id: "acc_mock_1", - merchant_name: "Mock Coffee", - original_description: "MOCK COFFEE SHOP", - amount: 4.50, - iso_currency_code: "USD", - date: Date.current.to_s, - personal_finance_category: OpenStruct.new(primary: "FOOD_AND_DRINK", detailed: "COFFEE_SHOP"), - website: "https://coffee.example.com", - logo_url: "https://coffee.example.com/logo.png", - merchant_entity_id: "merch_mock_1" - ) - ] - - INVESTMENT_TRANSACTIONS = [ - MockData.new( - investment_transaction_id: "inv_txn_mock_1", - account_id: "acc_mock_2", - security_id: "sec_mock_1", - type: "buy", - name: "BUY AAPL", - quantity: 10, - price: 150.00, - amount: -1_500.00, - iso_currency_code: "USD", - date: Date.current.to_s - ), - MockData.new( - investment_transaction_id: "inv_txn_mock_cash", - account_id: "acc_mock_2", - security_id: "sec_mock_cash", - type: "cash", - name: "Cash Dividend", - quantity: 1, - price: 200.00, - amount: 200.00, - iso_currency_code: "USD", - date: Date.current.to_s - ) - ] - - HOLDINGS = [ - MockData.new( - account_id: "acc_mock_2", - security_id: "sec_mock_1", - quantity: 10, - institution_price: 150.00, - iso_currency_code: "USD" - ), - MockData.new( - account_id: "acc_mock_2", - security_id: "sec_mock_cash", - quantity: 200.0, - institution_price: 1.00, - iso_currency_code: "USD" - ) - ] - - LIABILITIES = { - credit: [ - MockData.new( - account_id: "acc_mock_1", - minimum_payment_amount: 25.00, - aprs: [ MockData.new(apr_percentage: 19.99) ] - ) - ], - mortgage: [ - MockData.new( - account_id: "acc_mock_3", - origination_principal_amount: 250_000, - origination_date: 10.years.ago.to_date.to_s, - interest_rate: MockData.new(type: "fixed", percentage: 3.5) - ) - ], - student: [ - MockData.new( - account_id: "acc_mock_4", - origination_principal_amount: 50_000, - origination_date: 6.years.ago.to_date.to_s, - interest_rate_percentage: 4.0 - ) - ] - } - - def get_link_token(*, **) - MockData.new(link_token: "link-mock-123") - end - - def create_public_token(username: nil) - "public-mock-#{username || 'user'}" - end - - def exchange_public_token(_token) - MockData.new(access_token: "access-mock-123") - end - - def get_item(_access_token) - MockData.new( - item: ITEM - ) - end - - def get_institution(institution_id) - MockData.new( - institution: INSTITUTION - ) - end - - def get_item_accounts(_item_or_token) - MockData.new(accounts: ACCOUNTS) - end - - def get_transactions(access_token, next_cursor: nil) - TransactionSyncResponse.new( - added: TRANSACTIONS, - modified: [], - removed: [], - cursor: "cursor-mock-1" - ) - end - - def get_item_investments(_item_or_token, **) - InvestmentsResponse.new( - holdings: HOLDINGS, - transactions: INVESTMENT_TRANSACTIONS, - securities: SECURITIES - ) - end - - def get_item_liabilities(_item_or_token) - MockData.new( - credit: LIABILITIES[:credit], - mortgage: LIABILITIES[:mortgage], - student: LIABILITIES[:student] - ) - end -end