From 86830265192232c02608d8b8256c7c48662096f8 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 19 May 2025 13:03:03 -0400 Subject: [PATCH] First pass at Plaid sync v2 --- app/models/plaid_item/accounts_snapshot.rb | 8 +- app/models/provider.rb | 3 + app/models/provider/plaid.rb | 6 +- test/fixtures/plaid_accounts.yml | 2 +- test/fixtures/plaid_items.yml | 2 +- test/models/plaid_account/importer_test.rb | 28 ++- test/models/plaid_item/importer_test.rb | 52 +----- test/models/provider/plaid_test.rb | 6 +- test/support/plaid_mock.rb | 204 +++++++++++++++++++++ 9 files changed, 236 insertions(+), 75 deletions(-) diff --git a/app/models/plaid_item/accounts_snapshot.rb b/app/models/plaid_item/accounts_snapshot.rb index f059ed5c..aee60a1c 100644 --- a/app/models/plaid_item/accounts_snapshot.rb +++ b/app/models/plaid_item/accounts_snapshot.rb @@ -8,7 +8,7 @@ class PlaidItem::AccountsSnapshot end def accounts - @accounts ||= plaid_provider.get_item_accounts(plaid_item).accounts + @accounts ||= plaid_provider.get_item_accounts(plaid_item.access_token).accounts end def get_account_data(account_id) @@ -60,16 +60,16 @@ class PlaidItem::AccountsSnapshot def transactions_data return nil unless plaid_item.supports_product?("transactions") - @transactions_data ||= plaid_provider.get_item_transactions(plaid_item) + @transactions_data ||= plaid_provider.get_transactions(plaid_item.access_token) end def investments_data return nil unless plaid_item.supports_product?("investments") - @investments_data ||= plaid_provider.get_item_investments(plaid_item) + @investments_data ||= plaid_provider.get_item_investments(plaid_item.access_token) end def liabilities_data return nil unless plaid_item.supports_product?("liabilities") - @liabilities_data ||= plaid_provider.get_item_liabilities(plaid_item) + @liabilities_data ||= plaid_provider.get_item_liabilities(plaid_item.access_token) end end diff --git a/app/models/provider.rb b/app/models/provider.rb index 4fe68f6d..e9702349 100644 --- a/app/models/provider.rb +++ b/app/models/provider.rb @@ -18,6 +18,9 @@ class Provider end private + PaginatedData = Data.define(:paginated, :first_page, :total_pages) + UsageData = Data.define(:used, :limit, :utilization, :plan) + def with_provider_response(error_transformer: nil, &block) data = yield diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 44cc9f34..17b286cb 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -111,7 +111,7 @@ class Provider::Plaid client.accounts_get(request) end - def get_transactions(access_token:, next_cursor: nil) + def get_transactions(access_token, next_cursor: nil) cursor = next_cursor added = [] modified = [] @@ -139,7 +139,7 @@ class Provider::Plaid TransactionSyncResponse.new(added:, modified:, removed:, cursor:) end - def get_item_investments(access_token:, start_date: nil, end_date: Date.current) + def get_item_investments(access_token, start_date: nil, end_date: Date.current) start_date = start_date || MAX_HISTORY_DAYS.days.ago.to_date holdings, holding_securities = get_item_holdings(access_token: access_token) transactions, transaction_securities = get_item_investment_transactions(access_token: access_token, start_date:, end_date:) @@ -149,7 +149,7 @@ class Provider::Plaid InvestmentsResponse.new(holdings:, transactions:, securities: merged_securities) end - def get_item_liabilities(access_token:) + def get_item_liabilities(access_token) request = Plaid::LiabilitiesGetRequest.new({ access_token: access_token }) response = client.liabilities_get(request) response.liabilities diff --git a/test/fixtures/plaid_accounts.yml b/test/fixtures/plaid_accounts.yml index 2a911104..63d25646 100644 --- a/test/fixtures/plaid_accounts.yml +++ b/test/fixtures/plaid_accounts.yml @@ -1,3 +1,3 @@ one: plaid_item: one - plaid_id: "1234567890" + plaid_id: "acc_mock_1" diff --git a/test/fixtures/plaid_items.yml b/test/fixtures/plaid_items.yml index 4a3b3df8..03e7cdfb 100644 --- a/test/fixtures/plaid_items.yml +++ b/test/fixtures/plaid_items.yml @@ -1,6 +1,6 @@ one: family: dylan_family - plaid_id: "1234567890" + plaid_id: "item_mock_1" access_token: encrypted_token_1 name: "Test Bank" billed_products: ["transactions", "investments", "liabilities"] diff --git a/test/models/plaid_account/importer_test.rb b/test/models/plaid_account/importer_test.rb index 19e60eb3..3575dc2d 100644 --- a/test/models/plaid_account/importer_test.rb +++ b/test/models/plaid_account/importer_test.rb @@ -2,27 +2,21 @@ require "test_helper" class PlaidAccount::ImporterTest < ActiveSupport::TestCase setup do - @mock_provider = mock("Provider::Plaid") + @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 - raw_payload = OpenStruct.new( - account_id: "123", - name: "Test Account", - mask: "1234", - type: "checking", - subtype: "checking", - ) + PlaidAccount::Importer.new(@plaid_account, account_snapshot: @account_snapshot).import - PlaidAccount::Importer.new(@plaid_account, raw_payload, plaid_provider: @mock_provider).import - - @plaid_account.reload - - assert_equal "123", @plaid_account.plaid_id - assert_equal "Test Account", @plaid_account.name - assert_equal "1234", @plaid_account.mask - assert_equal "checking", @plaid_account.plaid_type - assert_equal "checking", @plaid_account.plaid_subtype + 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 end end diff --git a/test/models/plaid_item/importer_test.rb b/test/models/plaid_item/importer_test.rb index 9a9d4ae1..fc795b19 100644 --- a/test/models/plaid_item/importer_test.rb +++ b/test/models/plaid_item/importer_test.rb @@ -3,59 +3,19 @@ require "ostruct" class PlaidItem::ImporterTest < ActiveSupport::TestCase setup do - @mock_provider = mock("Provider::Plaid") + @mock_provider = PlaidMock.new @plaid_item = plaid_items(:one) + @importer = PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider) end test "imports item metadata" do - mock_institution_id = "123" - - raw_item_payload = OpenStruct.new( - item: OpenStruct.new( - available_products: [], - billed_products: %w[transactions investments liabilities], - institution_id: mock_institution_id - ) - ) - - raw_institution_payload = OpenStruct.new( - institution: OpenStruct.new( - institution_id: mock_institution_id, - url: "https://example.com", - primary_color: "#000000" - ) - ) - - raw_accounts_payload = OpenStruct.new( - accounts: [ - OpenStruct.new( - account_id: "123", - name: "Test Account", - mask: "1234", - type: "checking", - subtype: "checking", - ) - ] - ) - - @mock_provider.expects(:get_item).returns(raw_item_payload) - @mock_provider.expects(:get_institution).with(mock_institution_id).returns(raw_institution_payload) - @mock_provider.expects(:get_item_accounts).with(@plaid_item).returns(raw_accounts_payload) - @mock_provider.expects(:get_item_transactions).with(@plaid_item).returns(OpenStruct.new(transactions: [])) - @mock_provider.expects(:get_item_investments).with(@plaid_item).returns(OpenStruct.new(investments: [])) - @mock_provider.expects(:get_item_liabilities).with(@plaid_item).returns(OpenStruct.new(liabilities: [])) - - PlaidAccount::Importer.any_instance.expects(:import).times(raw_accounts_payload.accounts.count) + PlaidAccount::Importer.any_instance.expects(:import).times(PlaidMock::ACCOUNTS.count) PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider).import - @plaid_item.reload - - assert_equal mock_institution_id, @plaid_item.institution_id - assert_equal "https://example.com", @plaid_item.institution_url - assert_equal "#000000", @plaid_item.institution_color - assert_equal %w[transactions investments liabilities], @plaid_item.available_products - assert_equal %w[transactions investments liabilities], @plaid_item.billed_products + 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 assert_not_nil @plaid_item.raw_payload assert_not_nil @plaid_item.raw_institution_payload end diff --git a/test/models/provider/plaid_test.rb b/test/models/provider/plaid_test.rb index 765e31d9..1e97e8ad 100644 --- a/test/models/provider/plaid_test.rb +++ b/test/models/provider/plaid_test.rb @@ -61,13 +61,13 @@ class Provider::PlaidTest < ActiveSupport::TestCase VCR.use_cassette("plaid/get_transactions_with_next_cursor") do access_token = get_access_token - transactions_response = @plaid.get_transactions(access_token: access_token) + transactions_response = @plaid.get_transactions(access_token) assert transactions_response.added.size > 0 # Second call, we get only the latest transactions transactions_with_cursor = @plaid.get_transactions( - access_token: access_token, + access_token, next_cursor: transactions_response.cursor ) @@ -80,7 +80,7 @@ class Provider::PlaidTest < ActiveSupport::TestCase test "gets item investments" do VCR.use_cassette("plaid/get_item_investments") do access_token = get_access_token - investments_response = @plaid.get_item_investments(access_token: access_token) + investments_response = @plaid.get_item_investments(access_token) assert_equal 3, investments_response.holdings.size assert_equal 4, investments_response.transactions.size diff --git a/test/support/plaid_mock.rb b/test/support/plaid_mock.rb index 94503dfc..f0ab7073 100644 --- a/test/support/plaid_mock.rb +++ b/test/support/plaid_mock.rb @@ -1,2 +1,206 @@ +require "ostruct" + +# 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 = OpenStruct.new( + item_id: "item_mock_1", + institution_id: "ins_mock", + institution_name: "Mock Institution", + available_products: [], + billed_products: %w[transactions investments liabilities] + ) + + INSTITUTION = OpenStruct.new( + institution_id: "ins_mock", + institution_name: "Mock Institution" + ) + + ACCOUNTS = [ + OpenStruct.new( + account_id: "acc_mock_1", + name: "Mock Checking", + mask: "1111", + type: "depository", + subtype: "checking", + balances: OpenStruct.new( + current: 1_000.00, + available: 800.00, + iso_currency_code: "USD" + ) + ), + OpenStruct.new( + account_id: "acc_mock_2", + name: "Mock Brokerage", + mask: "2222", + type: "investment", + subtype: "brokerage", + balances: OpenStruct.new( + current: 15_000.00, + available: 15_000.00, + iso_currency_code: "USD" + ) + ) + ] + + SECURITIES = [ + OpenStruct.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 + OpenStruct.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 = [ + OpenStruct.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 = [ + OpenStruct.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 + ), + OpenStruct.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 = [ + OpenStruct.new( + account_id: "acc_mock_2", + security_id: "sec_mock_1", + quantity: 10, + institution_price: 150.00, + iso_currency_code: "USD" + ), + OpenStruct.new( + account_id: "acc_mock_2", + security_id: "sec_mock_cash", + quantity: 200.0, + institution_price: 1.00, + iso_currency_code: "USD" + ) + ] + + LIABILITIES = { + credit: [ + OpenStruct.new( + account_id: "acc_mock_1", + minimum_payment_amount: 25.00, + aprs: [ OpenStruct.new(apr_percentage: 19.99) ] + ) + ], + mortgage: [ + OpenStruct.new( + account_id: "acc_mock_3", + origination_principal_amount: 250_000, + origination_date: 10.years.ago.to_date.to_s, + interest_rate: OpenStruct.new(type: "fixed", percentage: 3.5) + ) + ], + student: [ + OpenStruct.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(*, **) + OpenStruct.new(link_token: "link-mock-123") + end + + def create_public_token(username: nil) + "public-mock-#{username || 'user'}" + end + + def exchange_public_token(_token) + OpenStruct.new(access_token: "access-mock-123") + end + + def get_item(_access_token) + OpenStruct.new( + item: ITEM + ) + end + + def get_institution(institution_id) + OpenStruct.new( + institution: INSTITUTION + ) + end + + def get_item_accounts(_item_or_token) + OpenStruct.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) + OpenStruct.new( + credit: LIABILITIES[:credit], + mortgage: LIABILITIES[:mortgage], + student: LIABILITIES[:student] + ) + end end