From 7b26d9a4d464c3d146de9a4535ae89822e77aa05 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 19 May 2025 09:55:16 -0400 Subject: [PATCH] Sketch out Plaid provider tests --- app/controllers/webhooks_controller.rb | 4 +- app/models/plaid_account.rb | 39 ++ app/models/plaid_account/importer.rb | 41 +- .../plaid_account/investments_importer.rb | 13 - .../plaid_account/liabilities_importer.rb | 13 - .../plaid_account/transactions_importer.rb | 13 - app/models/plaid_item.rb | 35 +- app/models/plaid_item/accounts_snapshot.rb | 75 +++ app/models/plaid_item/importer.rb | 86 +-- app/models/plaid_item/syncer.rb | 20 +- app/models/provider.rb | 3 - app/models/provider/plaid.rb | 24 +- app/models/provider/plaid_sandbox.rb | 15 + ...33020_add_raw_payload_to_plaid_entities.rb | 6 +- db/schema.rb | 3 + test/models/provider/plaid_test.rb | 84 +++ test/support/plaid_mock.rb | 2 + test/test_helper.rb | 2 + .../plaid/exchange_public_token.yml | 124 ++++ test/vcr_cassettes/plaid/get_item.yml | 103 ++++ .../plaid/get_item_investments.yml | 572 ++++++++++++++++++ .../get_transactions_with_next_cursor.yml | 135 +++++ test/vcr_cassettes/plaid/link_token.yml | 64 ++ 23 files changed, 1308 insertions(+), 168 deletions(-) delete mode 100644 app/models/plaid_account/investments_importer.rb delete mode 100644 app/models/plaid_account/liabilities_importer.rb delete mode 100644 app/models/plaid_account/transactions_importer.rb create mode 100644 app/models/plaid_item/accounts_snapshot.rb create mode 100644 test/models/provider/plaid_test.rb create mode 100644 test/support/plaid_mock.rb create mode 100644 test/vcr_cassettes/plaid/exchange_public_token.yml create mode 100644 test/vcr_cassettes/plaid/get_item.yml create mode 100644 test/vcr_cassettes/plaid/get_item_investments.yml create mode 100644 test/vcr_cassettes/plaid/get_transactions_with_next_cursor.yml create mode 100644 test/vcr_cassettes/plaid/link_token.yml diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index ff1ae08c..1ef75db6 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -6,7 +6,7 @@ class WebhooksController < ApplicationController webhook_body = request.body.read plaid_verification_header = request.headers["Plaid-Verification"] - client = Provider::Plaid.new(Rails.application.config.plaid, region: :us) + client = Provider::Registry.plaid_provider_for_region(:us) client.validate_webhook!(plaid_verification_header, webhook_body) client.process_webhook(webhook_body) @@ -21,7 +21,7 @@ class WebhooksController < ApplicationController webhook_body = request.body.read plaid_verification_header = request.headers["Plaid-Verification"] - client = Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu) + client = Provider::Registry.plaid_provider_for_region(:eu) client.validate_webhook!(plaid_verification_header, webhook_body) client.process_webhook(webhook_body) diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 4730985d..013ab875 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -52,6 +52,45 @@ class PlaidAccount < ApplicationRecord ) end + def upsert_plaid_snapshot!(account_snapshot) + assign_attributes( + current_balance: account_snapshot.balances.current, + available_balance: account_snapshot.balances.available, + currency: account_snapshot.balances.iso_currency_code, + plaid_type: account_snapshot.type, + plaid_subtype: account_snapshot.subtype, + name: account_snapshot.name, + mask: account_snapshot.mask, + raw_payload: account_snapshot + ) + + save! + end + + def upsert_plaid_transactions_snapshot!(transactions_snapshot) + assign_attributes( + raw_transactions_payload: transactions_snapshot + ) + + save! + end + + def upsert_plaid_investments_snapshot!(investments_snapshot) + assign_attributes( + raw_investments_payload: investments_snapshot + ) + + save! + end + + def upsert_plaid_liabilities_snapshot!(liabilities_snapshot) + assign_attributes( + raw_liabilities_payload: liabilities_snapshot + ) + + save! + end + def sync_investments!(transactions:, holdings:, securities:) PlaidInvestmentSync.new(self).sync!(transactions:, holdings:, securities:) end diff --git a/app/models/plaid_account/importer.rb b/app/models/plaid_account/importer.rb index c4aa5d99..1306278f 100644 --- a/app/models/plaid_account/importer.rb +++ b/app/models/plaid_account/importer.rb @@ -1,47 +1,34 @@ -# PlaidItem::Importer passes a raw payload retrieved from accounts_get API call, along with the PlaidAccount entity -# This class is responsible for making sense of that raw data, persisting it, and triggering transaction/investment/liability data imports for this account class PlaidAccount::Importer - def initialize(plaid_account, account_data:, transactions_data:, investments_data:, liabilities_data:) + def initialize(plaid_account, account_snapshot:) @plaid_account = plaid_account - @account_data = account_data - @transactions_data = transactions_data - @investments_data = investments_data - @liabilities_data = liabilities_data + @account_snapshot = account_snapshot end def import - update_account_info - - import_transactions if transactions_data.present? - import_investments if investments_data.present? - import_liabilities if liabilities_data.present? + PlaidAccount.transaction do + import_account_info + import_transactions if account_snapshot.transactions_data.present? + import_investments if account_snapshot.investments_data.present? + import_liabilities if account_snapshot.liabilities_data.present? + end end private - attr_reader :plaid_account, :account_data, :transactions_data, :investments_data, :liabilities_data + attr_reader :plaid_account, :account_snapshot - def update_account_info - plaid_account.raw_payload = account_data - plaid_account.current_balance = account_data.balances.current - plaid_account.available_balance = account_data.balances.available - plaid_account.currency = account_data.balances.iso_currency_code - plaid_account.plaid_type = account_data.type - plaid_account.plaid_subtype = account_data.subtype - plaid_account.name = account_data.name - plaid_account.mask = account_data.mask - - plaid_account.save! + def import_account_info + plaid_account.upsert_plaid_snapshot!(account_snapshot.account_data) end def import_transactions - PlaidAccount::TransactionsImporter.new(plaid_account, transactions_data: transactions_data).import + plaid_account.upsert_plaid_transactions_snapshot!(account_snapshot.transactions_data) end def import_investments - PlaidAccount::InvestmentsImporter.new(plaid_account, investments_data: investments_data).import + plaid_account.upsert_plaid_investments_snapshot!(account_snapshot.investments_data) end def import_liabilities - PlaidAccount::LiabilitiesImporter.new(plaid_account, liabilities_data: liabilities_data).import + plaid_account.upsert_plaid_liabilities_snapshot!(account_snapshot.liabilities_data) end end diff --git a/app/models/plaid_account/investments_importer.rb b/app/models/plaid_account/investments_importer.rb deleted file mode 100644 index 65ed4b80..00000000 --- a/app/models/plaid_account/investments_importer.rb +++ /dev/null @@ -1,13 +0,0 @@ -class PlaidAccount::InvestmentsImporter - def initialize(plaid_account, plaid_provider:) - @plaid_account = plaid_account - @plaid_provider = plaid_provider - end - - def import - # TODO - end - - private - attr_reader :plaid_account, :plaid_provider -end diff --git a/app/models/plaid_account/liabilities_importer.rb b/app/models/plaid_account/liabilities_importer.rb deleted file mode 100644 index fd277df5..00000000 --- a/app/models/plaid_account/liabilities_importer.rb +++ /dev/null @@ -1,13 +0,0 @@ -class PlaidAccount::LiabilitiesImporter - def initialize(plaid_account, plaid_provider:) - @plaid_account = plaid_account - @plaid_provider = plaid_provider - end - - def import - # TODO - end - - private - attr_reader :plaid_account, :plaid_provider -end diff --git a/app/models/plaid_account/transactions_importer.rb b/app/models/plaid_account/transactions_importer.rb deleted file mode 100644 index 3f03e3ac..00000000 --- a/app/models/plaid_account/transactions_importer.rb +++ /dev/null @@ -1,13 +0,0 @@ -class PlaidAccount::TransactionsImporter - def initialize(plaid_account, plaid_provider:) - @plaid_account = plaid_account - @plaid_provider = plaid_provider - end - - def import - # TODO - end - - private - attr_reader :plaid_account, :plaid_provider -end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index abd81dc7..16091ab2 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -60,6 +60,27 @@ class PlaidItem < ApplicationRecord .exists? end + def upsert_plaid_snapshot!(item_snapshot) + assign_attributes( + available_products: item_snapshot.available_products, + billed_products: item_snapshot.billed_products, + raw_payload: item_snapshot, + ) + + save! + end + + def upsert_plaid_institution_snapshot!(institution_snapshot) + assign_attributes( + institution_id: institution_snapshot.institution_id, + institution_url: institution_snapshot.url, + institution_color: institution_snapshot.primary_color, + raw_institution_payload: institution_snapshot + ) + + save! + end + def auto_match_categories! if family.categories.none? family.categories.bootstrap! @@ -90,11 +111,8 @@ class PlaidItem < ApplicationRecord end end - # Plaid returns mutually exclusive arrays here. If the item has made a request for a product, - # it is put in the billed_products array. If it is supported, but not yet used, it goes in the - # available_products array. - def supported_products - available_products + billed_products + def supports_product?(product) + supported_products.include?(product) end private @@ -105,5 +123,12 @@ class PlaidItem < ApplicationRecord Sentry.capture_exception(e) end + # Plaid returns mutually exclusive arrays here. If the item has made a request for a product, + # it is put in the billed_products array. If it is supported, but not yet used, it goes in the + # available_products array. + def supported_products + available_products + billed_products + end + class PlaidConnectionLostError < StandardError; end end diff --git a/app/models/plaid_item/accounts_snapshot.rb b/app/models/plaid_item/accounts_snapshot.rb new file mode 100644 index 00000000..f059ed5c --- /dev/null +++ b/app/models/plaid_item/accounts_snapshot.rb @@ -0,0 +1,75 @@ +# All Plaid data is fetched at the item-level. This class is a simple wrapper that +# providers a convenience method, get_account_data which scopes the item-level payload +# to each Plaid Account +class PlaidItem::AccountsSnapshot + def initialize(plaid_item, plaid_provider:) + @plaid_item = plaid_item + @plaid_provider = plaid_provider + end + + def accounts + @accounts ||= plaid_provider.get_item_accounts(plaid_item).accounts + end + + def get_account_data(account_id) + AccountData.new( + account_data: accounts.find { |a| a.account_id == account_id }, + transactions_data: account_scoped_transactions_data(account_id), + investments_data: account_scoped_investments_data(account_id), + liabilities_data: account_scoped_liabilities_data(account_id) + ) + end + + private + attr_reader :plaid_item, :plaid_provider + + TransactionsData = Data.define(:added, :modified, :removed) + LiabilitiesData = Data.define(:credit, :mortgage, :student) + InvestmentsData = Data.define(:transactions, :holdings, :securities) + AccountData = Data.define(:account_data, :transactions_data, :investments_data, :liabilities_data) + + def account_scoped_transactions_data(account_id) + return nil unless transactions_data + + TransactionsData.new( + added: transactions_data.added.select { |t| t.account_id == account_id }, + modified: transactions_data.modified.select { |t| t.account_id == account_id }, + removed: transactions_data.removed.select { |t| t.account_id == account_id } + ) + end + + def account_scoped_investments_data(account_id) + return nil unless investments_data + + InvestmentsData.new( + transactions: investments_data.transactions.select { |t| t.account_id == account_id }, + holdings: investments_data.holdings.select { |h| h.account_id == account_id }, + securities: investments_data.securities + ) + end + + def account_scoped_liabilities_data(account_id) + return nil unless liabilities_data + + LiabilitiesData.new( + credit: liabilities_data.credit&.find { |c| c.account_id == account_id }, + mortgage: liabilities_data.mortgage&.find { |m| m.account_id == account_id }, + student: liabilities_data.student&.find { |s| s.account_id == account_id } + ) + end + + def transactions_data + return nil unless plaid_item.supports_product?("transactions") + @transactions_data ||= plaid_provider.get_item_transactions(plaid_item) + end + + def investments_data + return nil unless plaid_item.supports_product?("investments") + @investments_data ||= plaid_provider.get_item_investments(plaid_item) + end + + def liabilities_data + return nil unless plaid_item.supports_product?("liabilities") + @liabilities_data ||= plaid_provider.get_item_liabilities(plaid_item) + end +end diff --git a/app/models/plaid_item/importer.rb b/app/models/plaid_item/importer.rb index 779e3e19..9a4b295e 100644 --- a/app/models/plaid_item/importer.rb +++ b/app/models/plaid_item/importer.rb @@ -5,9 +5,8 @@ class PlaidItem::Importer end def import - import_item_metadata - import_institution_metadata - import_accounts + fetch_and_import_item_data + fetch_and_import_accounts_data rescue Plaid::ApiError => e handle_plaid_error(e) end @@ -29,77 +28,26 @@ class PlaidItem::Importer end end - def import_item_metadata - item_response = plaid_provider.get_item(plaid_item.access_token) - item_data = item_response.item + def fetch_and_import_item_data + item_data = plaid_provider.get_item(plaid_item.access_token).item + institution_data = plaid_provider.get_institution(item_data.institution_id).institution - # plaid_item.raw_payload = item_response - plaid_item.available_products = item_data.available_products - plaid_item.billed_products = item_data.billed_products - plaid_item.institution_id = item_data.institution_id - - plaid_item.save! + plaid_item.upsert_plaid_snapshot!(item_data) + plaid_item.upsert_plaid_institution_snapshot!(institution_data) end - def import_institution_metadata - institution_response = plaid_provider.get_institution(plaid_item.institution_id) - institution_data = institution_response.institution + def fetch_and_import_accounts_data + snapshot = PlaidItem::AccountsSnapshot.new(plaid_item, plaid_provider: plaid_provider) - # plaid_item.raw_institution_payload = institution_response - plaid_item.institution_id = institution_data.institution_id - plaid_item.institution_url = institution_data.url - plaid_item.institution_color = institution_data.primary_color + snapshot.accounts.each do |raw_account| + plaid_account = plaid_item.plaid_accounts.find_or_initialize_by( + plaid_id: raw_account.account_id + ) - plaid_item.save! - end - - def import_accounts - accounts_data = plaid_provider.get_item_accounts(plaid_item).accounts - - PlaidItem.transaction do - accounts_data.each do |raw_account_payload| - plaid_account = plaid_item.plaid_accounts.find_or_initialize_by( - plaid_id: raw_account_payload.account_id - ) - - PlaidAccount::Importer.new( - plaid_account, - accounts_data: accounts_data, - transactions_data: transactions_data, - investments_data: investments_data, - liabilities_data: liabilities_data - ).import - end + PlaidAccount::Importer.new( + plaid_account, + account_snapshot: snapshot.get_account_data(raw_account.account_id) + ).import end end - - def transactions_supported? - plaid_item.supported_products.include?("transactions") - end - - def investments_supported? - plaid_item.supported_products.include?("investments") - end - - def liabilities_supported? - plaid_item.supported_products.include?("liabilities") - end - - def transactions_data - return nil unless transactions_supported? - - plaid_provider.get_item_transactions(plaid_item).transactions - end - - def investments_data - return nil unless investments_supported? - - plaid_provider.get_item_investments(plaid_item).investments - end - - def liabilities_data - return nil unless liabilities_supported? - - plaid_provider.get_item_liabilities(plaid_item).liabilities - end end diff --git a/app/models/plaid_item/syncer.rb b/app/models/plaid_item/syncer.rb index e7357b09..f66a42cf 100644 --- a/app/models/plaid_item/syncer.rb +++ b/app/models/plaid_item/syncer.rb @@ -7,19 +7,19 @@ class PlaidItem::Syncer def perform_sync(sync) # Loads item metadata, accounts, transactions, and other data to our DB - import_item_data + fetch_and_import_item_data # Processes the raw Plaid data and updates internal domain objects - # process_item_data + process_item_data # All data is synced, so we can now run an account sync to calculate historical balances and more - # plaid_item.reload.accounts.each do |account| - # account.sync_later( - # parent_sync: sync, - # window_start_date: sync.window_start_date, - # window_end_date: sync.window_end_date - # ) - # end + plaid_item.reload.accounts.each do |account| + account.sync_later( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + end end def perform_post_sync @@ -31,7 +31,7 @@ class PlaidItem::Syncer plaid_item.plaid_provider end - def import_item_data + def fetch_and_import_item_data PlaidItem::Importer.new(plaid_item, plaid_provider: plaid_provider).import end diff --git a/app/models/provider.rb b/app/models/provider.rb index e9702349..4fe68f6d 100644 --- a/app/models/provider.rb +++ b/app/models/provider.rb @@ -18,9 +18,6 @@ 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 ac5fe0f4..a1ab698e 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -111,8 +111,8 @@ class Provider::Plaid client.accounts_get(request) end - def get_item_transactions(item) - cursor = item.next_cursor + def get_transactions(access_token:, next_cursor: nil) + cursor = next_cursor added = [] modified = [] removed = [] @@ -120,7 +120,7 @@ class Provider::Plaid while has_more request = Plaid::TransactionsSyncRequest.new( - access_token: item.access_token, + access_token: access_token, cursor: cursor, options: { include_original_description: true @@ -139,18 +139,18 @@ class Provider::Plaid TransactionSyncResponse.new(added:, modified:, removed:, cursor:) end - def get_item_investments(item, 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(item) - transactions, transaction_securities = get_item_investment_transactions(item, start_date:, end_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:) merged_securities = ((holding_securities || []) + (transaction_securities || [])).uniq { |s| s.security_id } InvestmentsResponse.new(holdings:, transactions:, securities: merged_securities) end - def get_item_liabilities(item) - request = Plaid::LiabilitiesGetRequest.new({ access_token: item.access_token }) + def get_item_liabilities(access_token:) + request = Plaid::LiabilitiesGetRequest.new({ access_token: access_token }) response = client.liabilities_get(request) response.liabilities end @@ -170,21 +170,21 @@ class Provider::Plaid TransactionSyncResponse = Struct.new :added, :modified, :removed, :cursor, keyword_init: true InvestmentsResponse = Struct.new :holdings, :transactions, :securities, keyword_init: true - def get_item_holdings(item) - request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: item.access_token }) + def get_item_holdings(access_token:) + request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: access_token }) response = client.investments_holdings_get(request) [ response.holdings, response.securities ] end - def get_item_investment_transactions(item, start_date:, end_date:) + def get_item_investment_transactions(access_token:, start_date:, end_date:) transactions = [] securities = [] offset = 0 loop do request = Plaid::InvestmentsTransactionsGetRequest.new( - access_token: item.access_token, + access_token: access_token, start_date: start_date.to_s, end_date: end_date.to_s, options: { offset: offset } diff --git a/app/models/provider/plaid_sandbox.rb b/app/models/provider/plaid_sandbox.rb index 132b4422..c4f94066 100644 --- a/app/models/provider/plaid_sandbox.rb +++ b/app/models/provider/plaid_sandbox.rb @@ -3,6 +3,21 @@ class Provider::PlaidSandbox < Provider::Plaid def initialize @client = create_client + @region = :us + end + + def create_public_token(institution_id: nil, products: nil, user: nil) + client.sandbox_public_token_create( + Plaid::SandboxPublicTokenCreateRequest.new( + institution_id: institution_id || "ins_56", # Chase + initial_products: products || [ "transactions", "investments", "liabilities" ], + options: { + # This is a custom user we created in Plaid Dashboard + # See https://dashboard.plaid.com/developers/sandbox + override_username: user || "custom_test" + } + ) + ).public_token end def fire_webhook(item, type: "TRANSACTIONS", code: "SYNC_UPDATES_AVAILABLE") diff --git a/db/migrate/20250518133020_add_raw_payload_to_plaid_entities.rb b/db/migrate/20250518133020_add_raw_payload_to_plaid_entities.rb index 90d9bfe3..6c2be250 100644 --- a/db/migrate/20250518133020_add_raw_payload_to_plaid_entities.rb +++ b/db/migrate/20250518133020_add_raw_payload_to_plaid_entities.rb @@ -1,7 +1,11 @@ class AddRawPayloadToPlaidEntities < ActiveRecord::Migration[7.2] def change - add_column :plaid_accounts, :raw_payload, :jsonb, default: {} add_column :plaid_items, :raw_payload, :jsonb, default: {} add_column :plaid_items, :raw_institution_payload, :jsonb, default: {} + + add_column :plaid_accounts, :raw_payload, :jsonb, default: {} + add_column :plaid_accounts, :raw_transactions_payload, :jsonb, default: {} + add_column :plaid_accounts, :raw_investments_payload, :jsonb, default: {} + add_column :plaid_accounts, :raw_liabilities_payload, :jsonb, default: {} end end diff --git a/db/schema.rb b/db/schema.rb index 5cd7e03e..9a7aa93f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -430,6 +430,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_18_133020) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.jsonb "raw_payload", default: {} + t.jsonb "raw_transactions_payload", default: {} + t.jsonb "raw_investments_payload", default: {} + t.jsonb "raw_liabilities_payload", default: {} t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" end diff --git a/test/models/provider/plaid_test.rb b/test/models/provider/plaid_test.rb new file mode 100644 index 00000000..ce2c8765 --- /dev/null +++ b/test/models/provider/plaid_test.rb @@ -0,0 +1,84 @@ +require "test_helper" + +class Provider::PlaidTest < ActiveSupport::TestCase + setup do + # Do not change, this is whitelisted in the Plaid Dashboard for local dev + @redirect_url = "http://localhost:3000/accounts" + + # A specialization of Plaid client with sandbox-only extensions + @plaid = Provider::PlaidSandbox.new + end + + test "gets link token" do + VCR.use_cassette("plaid/link_token") do + link_token = @plaid.get_link_token( + user_id: "test-user-id", + webhooks_url: "https://example.com/webhooks", + redirect_url: @redirect_url + ) + + assert_match /link-sandbox-.*/, link_token.link_token + end + end + + test "exchanges public token" do + VCR.use_cassette("plaid/exchange_public_token") do + public_token = @plaid.create_public_token + exchange_response = @plaid.exchange_public_token(public_token) + + assert_match /access-sandbox-.*/, exchange_response.access_token + end + end + + test "gets item" do + VCR.use_cassette("plaid/get_item") do + access_token = get_access_token + item = @plaid.get_item(access_token).item + + assert_equal "ins_56", item.institution_id + assert_equal "Chase", item.institution_name + end + end + + test "gets item transactions with optional cursor for partial syncs" do + VCR.use_cassette("plaid/get_transactions_with_next_cursor", record: :all) do + access_token = get_access_token + + # First call, we get everything + transactions_response = @plaid.get_transactions(access_token: access_token) + + puts transactions_response.to_json + + assert transactions_response.added.size > 0 + + # Second call, we get only the latest transactions + transactions_with_cursor = @plaid.get_transactions( + access_token: access_token, + next_cursor: transactions_response.cursor + ) + + assert_equal 0, transactions_with_cursor.added.size + assert_equal 0, transactions_with_cursor.modified.size + assert_equal 0, transactions_with_cursor.removed.size + end + end + + test "gets item investments" do + VCR.use_cassette("plaid/get_item_investments", record: :all) do + access_token = get_access_token + investments_response = @plaid.get_item_investments(access_token: access_token) + + puts investments_response.to_json + end + end + + private + def get_access_token + VCR.use_cassette("plaid/exchange_public_token", record: :all) do + public_token = @plaid.create_public_token + exchange_response = @plaid.exchange_public_token(public_token) + + exchange_response.access_token + end + end +end diff --git a/test/support/plaid_mock.rb b/test/support/plaid_mock.rb new file mode 100644 index 00000000..94503dfc --- /dev/null +++ b/test/support/plaid_mock.rb @@ -0,0 +1,2 @@ +class PlaidMock +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 7eac9dde..65f5db35 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -29,6 +29,8 @@ VCR.configure do |config| config.filter_sensitive_data("") { ENV["OPENAI_ORGANIZATION_ID"] } config.filter_sensitive_data("") { ENV["STRIPE_SECRET_KEY"] } config.filter_sensitive_data("") { ENV["STRIPE_WEBHOOK_SECRET"] } + config.filter_sensitive_data("") { ENV["PLAID_CLIENT_ID"] } + config.filter_sensitive_data("") { ENV["PLAID_SECRET"] } end module ActiveSupport diff --git a/test/vcr_cassettes/plaid/exchange_public_token.yml b/test/vcr_cassettes/plaid/exchange_public_token.yml new file mode 100644 index 00000000..09911d97 --- /dev/null +++ b/test/vcr_cassettes/plaid/exchange_public_token.yml @@ -0,0 +1,124 @@ +--- +http_interactions: +- request: + method: post + uri: https://sandbox.plaid.com/sandbox/public_token/create + body: + encoding: UTF-8 + string: '{"institution_id":"ins_56","initial_products":["transactions","investments","liabilities"],"options":{"override_username":"custom_test"}}' + headers: + Content-Type: + - application/json + User-Agent: + - Plaid Ruby v38.0.0 + Accept: + - application/json + Plaid-Client-Id: + - "" + Plaid-Version: + - '2020-09-14' + Plaid-Secret: + - "" + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 19 May 2025 13:54:31 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '110' + Connection: + - keep-alive + Plaid-Version: + - '2020-09-14' + Vary: + - Accept-Encoding + X-Envoy-Upstream-Service-Time: + - '2776' + X-Envoy-Decorator-Operation: + - default.svc-apiv2:8080/* + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Xss-Protection: + - 1; mode=block + body: + encoding: ASCII-8BIT + string: |- + { + "public_token": "public-sandbox-10b2d144-5203-4b0a-93f6-9ce25af4b052", + "request_id": "6zPegA0EukTP72O" + } + recorded_at: Mon, 19 May 2025 13:54:31 GMT +- request: + method: post + uri: https://sandbox.plaid.com/item/public_token/exchange + body: + encoding: UTF-8 + string: '{"public_token":"public-sandbox-10b2d144-5203-4b0a-93f6-9ce25af4b052"}' + headers: + Content-Type: + - application/json + User-Agent: + - Plaid Ruby v38.0.0 + Accept: + - application/json + Plaid-Client-Id: + - "" + Plaid-Version: + - '2020-09-14' + Plaid-Secret: + - "" + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 19 May 2025 13:54:31 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '164' + Connection: + - keep-alive + Plaid-Version: + - '2020-09-14' + Vary: + - Accept-Encoding + X-Envoy-Upstream-Service-Time: + - '151' + X-Envoy-Decorator-Operation: + - default.svc-apiv2:8080/* + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Xss-Protection: + - 1; mode=block + body: + encoding: ASCII-8BIT + string: |- + { + "access_token": "access-sandbox-7dd1d7be-e7de-43bd-bdce-5e2d3d757cb2", + "item_id": "PaKznRKQV9TnKx98J5mEhVE33r3MVZC7Xkv73", + "request_id": "SVKjsbMsaqdHLBJ" + } + recorded_at: Mon, 19 May 2025 13:54:31 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/plaid/get_item.yml b/test/vcr_cassettes/plaid/get_item.yml new file mode 100644 index 00000000..7e4695df --- /dev/null +++ b/test/vcr_cassettes/plaid/get_item.yml @@ -0,0 +1,103 @@ +--- +http_interactions: +- request: + method: post + uri: https://sandbox.plaid.com/item/get + body: + encoding: UTF-8 + string: '{"access_token":"access-sandbox-60ea96d0-fe6c-41aa-a3ab-c88c33bd2f7e"}' + headers: + Content-Type: + - application/json + User-Agent: + - Plaid Ruby v38.0.0 + Accept: + - application/json + Plaid-Client-Id: + - "" + Plaid-Version: + - '2020-09-14' + Plaid-Secret: + - "" + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 19 May 2025 13:53:41 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '972' + Connection: + - keep-alive + Plaid-Version: + - '2020-09-14' + Vary: + - Accept-Encoding + X-Envoy-Upstream-Service-Time: + - '142' + X-Envoy-Decorator-Operation: + - default.svc-apiv2:8080/* + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Xss-Protection: + - 1; mode=block + body: + encoding: ASCII-8BIT + string: |- + { + "item": { + "available_products": [ + "assets", + "auth", + "balance", + "identity", + "identity_match", + "income_verification", + "recurring_transactions", + "signal" + ], + "billed_products": [ + "investments", + "liabilities", + "transactions" + ], + "consent_expiration_time": null, + "created_at": "2025-05-19T13:53:38Z", + "error": null, + "institution_id": "ins_56", + "institution_name": "Chase", + "item_id": "RmaB9v7w3jTVJ6JLllAnSWVzwxvQxJCRw4RRe", + "products": [ + "investments", + "liabilities", + "transactions" + ], + "update_type": "background", + "webhook": "" + }, + "request_id": "OkYR6qCP5vc6iEk", + "status": { + "investments": { + "last_failed_update": null, + "last_successful_update": "2025-05-19T13:53:39.616Z" + }, + "last_webhook": null, + "transactions": { + "last_failed_update": null, + "last_successful_update": null + } + } + } + recorded_at: Mon, 19 May 2025 13:53:41 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/plaid/get_item_investments.yml b/test/vcr_cassettes/plaid/get_item_investments.yml new file mode 100644 index 00000000..01b82a9b --- /dev/null +++ b/test/vcr_cassettes/plaid/get_item_investments.yml @@ -0,0 +1,572 @@ +--- +http_interactions: +- request: + method: post + uri: https://sandbox.plaid.com/investments/holdings/get + body: + encoding: UTF-8 + string: '{"access_token":"access-sandbox-01f5e5d8-b1f0-47b4-933a-6664c0064173"}' + headers: + Content-Type: + - application/json + User-Agent: + - Plaid Ruby v38.0.0 + Accept: + - application/json + Plaid-Client-Id: + - "" + Plaid-Version: + - '2020-09-14' + Plaid-Secret: + - "" + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 19 May 2025 13:53:33 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '6483' + Connection: + - keep-alive + Plaid-Version: + - '2020-09-14' + Vary: + - Accept-Encoding + X-Envoy-Upstream-Service-Time: + - '340' + X-Envoy-Decorator-Operation: + - default.svc-apiv2:8080/* + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Xss-Protection: + - 1; mode=block + body: + encoding: ASCII-8BIT + string: |- + { + "accounts": [ + { + "account_id": "9AokBkPa5BfWEVAe7XPqswvgkq5V6kh4J5BG7", + "balances": { + "available": 10000, + "current": 10000, + "iso_currency_code": "USD", + "limit": null, + "unofficial_currency_code": null + }, + "holder_category": "personal", + "mask": "1511", + "name": "Test Depository Account", + "official_name": "Plaid checking", + "persistent_account_id": "efe6f4d74b96154ebd8e40efafd36d8d43682c3f5cecdba620385593", + "subtype": "checking", + "type": "depository" + }, + { + "account_id": "ye6dBd4XDBTgZPB7EJwpUQqAKob31KS4vlM3L", + "balances": { + "available": 15000, + "current": 15000, + "iso_currency_code": "USD", + "limit": null, + "unofficial_currency_code": null + }, + "holder_category": "personal", + "mask": "2471", + "name": "Test Student Loan Account", + "official_name": "Plaid student", + "persistent_account_id": "75962201bbe945642e0f2cc9cb45bf6ad3fb70c1109ddac39affd9cc", + "subtype": "student", + "type": "loan" + }, + { + "account_id": "mk6JEJmGaEFB5d6Zoevbu5xnW8MmeWig3Wkm3", + "balances": { + "available": 11900, + "current": 1000, + "iso_currency_code": "USD", + "limit": 12900, + "unofficial_currency_code": null + }, + "holder_category": "personal", + "mask": "7144", + "name": "Test Credit Card Account", + "official_name": "Plaid credit card", + "persistent_account_id": "43c7fc22e70c7ba9bdac1ec02ee445107d2f166703760a4dc149fdbf", + "subtype": "credit card", + "type": "credit" + }, + { + "account_id": "vP6BKBxw3KuwDNKy16v7TDvrWdnzmWiq7EJz9", + "balances": { + "available": 8000, + "current": 10000, + "iso_currency_code": "USD", + "limit": null, + "unofficial_currency_code": null + }, + "holder_category": "personal", + "mask": "8749", + "name": "Test Brokerage Account", + "official_name": "Plaid brokerage", + "persistent_account_id": "28933ba7cec01ee3a8823c13aa091fada6fc38a08872dd1e12b76023", + "subtype": "brokerage", + "type": "investment" + } + ], + "holdings": [ + { + "account_id": "vP6BKBxw3KuwDNKy16v7TDvrWdnzmWiq7EJz9", + "cost_basis": 2000, + "institution_price": 100, + "institution_price_as_of": "2025-05-08", + "institution_price_datetime": null, + "institution_value": 2000, + "iso_currency_code": "USD", + "quantity": 20, + "security_id": "xnL3QM3Ax4fP9lVmNLblTDaMkVMqN3fmxrXRd", + "unofficial_currency_code": null, + "vested_quantity": null, + "vested_value": null + }, + { + "account_id": "vP6BKBxw3KuwDNKy16v7TDvrWdnzmWiq7EJz9", + "cost_basis": 3000, + "institution_price": 1, + "institution_price_as_of": "2025-05-08", + "institution_price_datetime": null, + "institution_value": 3000, + "iso_currency_code": "USD", + "quantity": 3000, + "security_id": "EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX", + "unofficial_currency_code": null, + "vested_quantity": null, + "vested_value": null + }, + { + "account_id": "vP6BKBxw3KuwDNKy16v7TDvrWdnzmWiq7EJz9", + "cost_basis": 5000, + "institution_price": 1, + "institution_price_as_of": "2025-05-08", + "institution_price_datetime": null, + "institution_value": 5000, + "iso_currency_code": "USD", + "quantity": 5000, + "security_id": "7Dv19k16PZtEaexk6EZyFxP95o9ynrF4REalG", + "unofficial_currency_code": null, + "vested_quantity": null, + "vested_value": null + } + ], + "item": { + "available_products": [ + "assets", + "auth", + "balance", + "identity", + "identity_match", + "income_verification", + "recurring_transactions", + "signal" + ], + "billed_products": [ + "investments", + "liabilities", + "transactions" + ], + "consent_expiration_time": null, + "error": null, + "institution_id": "ins_56", + "institution_name": "Chase", + "item_id": "oB6bAblGWACoJlk9Ex8bIJg33domK6uR1yRRv", + "products": [ + "investments", + "liabilities", + "transactions" + ], + "update_type": "background", + "webhook": "" + }, + "request_id": "ARlPzgbuVxL6rjp", + "securities": [ + { + "close_price": 1, + "close_price_as_of": "2025-04-28", + "cusip": null, + "fixed_income": null, + "industry": "Investment Trusts or Mutual Funds", + "institution_id": null, + "institution_security_id": null, + "is_cash_equivalent": true, + "isin": null, + "iso_currency_code": "USD", + "market_identifier_code": null, + "name": "Vanguard Money Market Reserves - Federal Money Market Fd USD MNT", + "option_contract": null, + "proxy_security_id": null, + "sector": "Miscellaneous", + "security_id": "7Dv19k16PZtEaexk6EZyFxP95o9ynrF4REalG", + "sedol": "2571678", + "ticker_symbol": "VMFXX", + "type": "mutual fund", + "unofficial_currency_code": null, + "update_datetime": null + }, + { + "close_price": 1, + "close_price_as_of": "2025-05-18", + "cusip": null, + "fixed_income": null, + "industry": null, + "institution_id": null, + "institution_security_id": null, + "is_cash_equivalent": true, + "isin": null, + "iso_currency_code": "USD", + "market_identifier_code": null, + "name": "U S Dollar", + "option_contract": null, + "proxy_security_id": null, + "sector": null, + "security_id": "EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX", + "sedol": null, + "ticker_symbol": "CUR:USD", + "type": "cash", + "unofficial_currency_code": null, + "update_datetime": null + }, + { + "close_price": 211.26, + "close_price_as_of": "2025-05-16", + "cusip": null, + "fixed_income": null, + "industry": "Telecommunications Equipment", + "institution_id": null, + "institution_security_id": null, + "is_cash_equivalent": false, + "isin": null, + "iso_currency_code": "USD", + "market_identifier_code": "XNAS", + "name": "Apple Inc", + "option_contract": null, + "proxy_security_id": null, + "sector": "Electronic Technology", + "security_id": "xnL3QM3Ax4fP9lVmNLblTDaMkVMqN3fmxrXRd", + "sedol": "2046251", + "ticker_symbol": "AAPL", + "type": "equity", + "unofficial_currency_code": null, + "update_datetime": null + } + ] + } + recorded_at: Mon, 19 May 2025 13:53:33 GMT +- request: + method: post + uri: https://sandbox.plaid.com/investments/transactions/get + body: + encoding: UTF-8 + string: '{"access_token":"access-sandbox-01f5e5d8-b1f0-47b4-933a-6664c0064173","start_date":"2023-05-20","end_date":"2025-05-19","options":{"offset":0}}' + headers: + Content-Type: + - application/json + User-Agent: + - Plaid Ruby v38.0.0 + Accept: + - application/json + Plaid-Client-Id: + - "" + Plaid-Version: + - '2020-09-14' + Plaid-Secret: + - "" + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 19 May 2025 13:53:33 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '7248' + Connection: + - keep-alive + Plaid-Version: + - '2020-09-14' + Vary: + - Accept-Encoding + X-Envoy-Upstream-Service-Time: + - '378' + X-Envoy-Decorator-Operation: + - default.svc-apiv2:8080/* + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Xss-Protection: + - 1; mode=block + body: + encoding: ASCII-8BIT + string: |- + { + "accounts": [ + { + "account_id": "9AokBkPa5BfWEVAe7XPqswvgkq5V6kh4J5BG7", + "balances": { + "available": 10000, + "current": 10000, + "iso_currency_code": "USD", + "limit": null, + "unofficial_currency_code": null + }, + "holder_category": "personal", + "mask": "1511", + "name": "Test Depository Account", + "official_name": "Plaid checking", + "persistent_account_id": "efe6f4d74b96154ebd8e40efafd36d8d43682c3f5cecdba620385593", + "subtype": "checking", + "type": "depository" + }, + { + "account_id": "ye6dBd4XDBTgZPB7EJwpUQqAKob31KS4vlM3L", + "balances": { + "available": 15000, + "current": 15000, + "iso_currency_code": "USD", + "limit": null, + "unofficial_currency_code": null + }, + "holder_category": "personal", + "mask": "2471", + "name": "Test Student Loan Account", + "official_name": "Plaid student", + "persistent_account_id": "75962201bbe945642e0f2cc9cb45bf6ad3fb70c1109ddac39affd9cc", + "subtype": "student", + "type": "loan" + }, + { + "account_id": "mk6JEJmGaEFB5d6Zoevbu5xnW8MmeWig3Wkm3", + "balances": { + "available": 11900, + "current": 1000, + "iso_currency_code": "USD", + "limit": 12900, + "unofficial_currency_code": null + }, + "holder_category": "personal", + "mask": "7144", + "name": "Test Credit Card Account", + "official_name": "Plaid credit card", + "persistent_account_id": "43c7fc22e70c7ba9bdac1ec02ee445107d2f166703760a4dc149fdbf", + "subtype": "credit card", + "type": "credit" + }, + { + "account_id": "vP6BKBxw3KuwDNKy16v7TDvrWdnzmWiq7EJz9", + "balances": { + "available": 8000, + "current": 10000, + "iso_currency_code": "USD", + "limit": null, + "unofficial_currency_code": null + }, + "holder_category": "personal", + "mask": "8749", + "name": "Test Brokerage Account", + "official_name": "Plaid brokerage", + "persistent_account_id": "28933ba7cec01ee3a8823c13aa091fada6fc38a08872dd1e12b76023", + "subtype": "brokerage", + "type": "investment" + } + ], + "investment_transactions": [ + { + "account_id": "vP6BKBxw3KuwDNKy16v7TDvrWdnzmWiq7EJz9", + "amount": -5000, + "cancel_transaction_id": null, + "date": "2025-05-03", + "fees": 0, + "investment_transaction_id": "X53oko4eQkUkKN78A5GmhN9lvl6wdeF1awWEz", + "iso_currency_code": "USD", + "name": "retirement contribution", + "price": 1, + "quantity": -5000, + "security_id": "EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX", + "subtype": "contribution", + "type": "cash", + "unofficial_currency_code": null + }, + { + "account_id": "vP6BKBxw3KuwDNKy16v7TDvrWdnzmWiq7EJz9", + "amount": 5000, + "cancel_transaction_id": null, + "date": "2025-05-03", + "fees": 0, + "investment_transaction_id": "D5EoQogL4QUnaZbVWg4PhB9pLpvl3bc4MKPQA", + "iso_currency_code": "USD", + "name": "buy money market shares with contribution cash", + "price": 1, + "quantity": 5000, + "security_id": "7Dv19k16PZtEaexk6EZyFxP95o9ynrF4REalG", + "subtype": "contribution", + "type": "buy", + "unofficial_currency_code": null + }, + { + "account_id": "vP6BKBxw3KuwDNKy16v7TDvrWdnzmWiq7EJz9", + "amount": 2000, + "cancel_transaction_id": null, + "date": "2025-05-02", + "fees": 0, + "investment_transaction_id": "V5zpKpdjwKUvNGPgAzqnIbgAKAXM3Ecqr38d3", + "iso_currency_code": "USD", + "name": "buy AAPL stock", + "price": 100, + "quantity": 20, + "security_id": "xnL3QM3Ax4fP9lVmNLblTDaMkVMqN3fmxrXRd", + "subtype": "buy", + "type": "buy", + "unofficial_currency_code": null + }, + { + "account_id": "vP6BKBxw3KuwDNKy16v7TDvrWdnzmWiq7EJz9", + "amount": -5000, + "cancel_transaction_id": null, + "date": "2025-05-01", + "fees": 0, + "investment_transaction_id": "wj63r3dGxrCB6LyZo8vQu68WpWKnE9cE8B7Vn", + "iso_currency_code": "USD", + "name": "Deposit cash into brokerage account", + "price": 1, + "quantity": -5000, + "security_id": "EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX", + "subtype": "deposit", + "type": "cash", + "unofficial_currency_code": null + } + ], + "item": { + "available_products": [ + "assets", + "auth", + "balance", + "identity", + "identity_match", + "income_verification", + "recurring_transactions", + "signal" + ], + "billed_products": [ + "investments", + "liabilities", + "transactions" + ], + "consent_expiration_time": null, + "error": null, + "institution_id": "ins_56", + "institution_name": "Chase", + "item_id": "oB6bAblGWACoJlk9Ex8bIJg33domK6uR1yRRv", + "products": [ + "investments", + "liabilities", + "transactions" + ], + "update_type": "background", + "webhook": "" + }, + "request_id": "3PLgnDkJ1iTVixr", + "securities": [ + { + "close_price": 1, + "close_price_as_of": "2025-04-28", + "cusip": null, + "fixed_income": null, + "industry": "Investment Trusts or Mutual Funds", + "institution_id": null, + "institution_security_id": null, + "is_cash_equivalent": true, + "isin": null, + "iso_currency_code": "USD", + "market_identifier_code": null, + "name": "Vanguard Money Market Reserves - Federal Money Market Fd USD MNT", + "option_contract": null, + "proxy_security_id": null, + "sector": "Miscellaneous", + "security_id": "7Dv19k16PZtEaexk6EZyFxP95o9ynrF4REalG", + "sedol": "2571678", + "ticker_symbol": "VMFXX", + "type": "mutual fund", + "unofficial_currency_code": null, + "update_datetime": null + }, + { + "close_price": 1, + "close_price_as_of": "2025-05-18", + "cusip": null, + "fixed_income": null, + "industry": null, + "institution_id": null, + "institution_security_id": null, + "is_cash_equivalent": true, + "isin": null, + "iso_currency_code": "USD", + "market_identifier_code": null, + "name": "U S Dollar", + "option_contract": null, + "proxy_security_id": null, + "sector": null, + "security_id": "EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX", + "sedol": null, + "ticker_symbol": "CUR:USD", + "type": "cash", + "unofficial_currency_code": null, + "update_datetime": null + }, + { + "close_price": 211.26, + "close_price_as_of": "2025-05-16", + "cusip": null, + "fixed_income": null, + "industry": "Telecommunications Equipment", + "institution_id": null, + "institution_security_id": null, + "is_cash_equivalent": false, + "isin": null, + "iso_currency_code": "USD", + "market_identifier_code": "XNAS", + "name": "Apple Inc", + "option_contract": null, + "proxy_security_id": null, + "sector": "Electronic Technology", + "security_id": "xnL3QM3Ax4fP9lVmNLblTDaMkVMqN3fmxrXRd", + "sedol": "2046251", + "ticker_symbol": "AAPL", + "type": "equity", + "unofficial_currency_code": null, + "update_datetime": null + } + ], + "total_investment_transactions": 4 + } + recorded_at: Mon, 19 May 2025 13:53:33 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/plaid/get_transactions_with_next_cursor.yml b/test/vcr_cassettes/plaid/get_transactions_with_next_cursor.yml new file mode 100644 index 00000000..5d085baf --- /dev/null +++ b/test/vcr_cassettes/plaid/get_transactions_with_next_cursor.yml @@ -0,0 +1,135 @@ +--- +http_interactions: +- request: + method: post + uri: https://sandbox.plaid.com/transactions/sync + body: + encoding: UTF-8 + string: '{"access_token":"access-sandbox-ffe2eff6-26ab-421e-9e65-92969a070378","count":100,"options":{"include_original_description":true}}' + headers: + Content-Type: + - application/json + User-Agent: + - Plaid Ruby v38.0.0 + Accept: + - application/json + Plaid-Client-Id: + - "" + Plaid-Version: + - '2020-09-14' + Plaid-Secret: + - "" + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 19 May 2025 13:54:01 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '192' + Connection: + - keep-alive + Plaid-Version: + - '2020-09-14' + Vary: + - Accept-Encoding + X-Envoy-Upstream-Service-Time: + - '328' + X-Envoy-Decorator-Operation: + - default.svc-apiv2:8080/* + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Xss-Protection: + - 1; mode=block + body: + encoding: ASCII-8BIT + string: |- + { + "accounts": [], + "added": [], + "has_more": false, + "modified": [], + "next_cursor": "", + "removed": [], + "request_id": "w91qd0Xa69KduMa", + "transactions_update_status": "NOT_READY" + } + recorded_at: Mon, 19 May 2025 13:54:01 GMT +- request: + method: post + uri: https://sandbox.plaid.com/transactions/sync + body: + encoding: UTF-8 + string: '{"access_token":"access-sandbox-7dd1d7be-e7de-43bd-bdce-5e2d3d757cb2","count":100,"options":{"include_original_description":true}}' + headers: + Content-Type: + - application/json + User-Agent: + - Plaid Ruby v38.0.0 + Accept: + - application/json + Plaid-Client-Id: + - "" + Plaid-Version: + - '2020-09-14' + Plaid-Secret: + - "" + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 19 May 2025 13:54:31 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '192' + Connection: + - keep-alive + Plaid-Version: + - '2020-09-14' + Vary: + - Accept-Encoding + X-Envoy-Upstream-Service-Time: + - '322' + X-Envoy-Decorator-Operation: + - default.svc-apiv2:8080/* + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Xss-Protection: + - 1; mode=block + body: + encoding: ASCII-8BIT + string: |- + { + "accounts": [], + "added": [], + "has_more": false, + "modified": [], + "next_cursor": "", + "removed": [], + "request_id": "KT5sHHvcrYWdNvs", + "transactions_update_status": "NOT_READY" + } + recorded_at: Mon, 19 May 2025 13:54:31 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/plaid/link_token.yml b/test/vcr_cassettes/plaid/link_token.yml new file mode 100644 index 00000000..0c93e198 --- /dev/null +++ b/test/vcr_cassettes/plaid/link_token.yml @@ -0,0 +1,64 @@ +--- +http_interactions: +- request: + method: post + uri: https://sandbox.plaid.com/link/token/create + body: + encoding: UTF-8 + string: '{"client_name":"Maybe Finance","language":"en","country_codes":["US","CA"],"user":{"client_user_id":"test-user-id"},"products":["transactions"],"additional_consented_products":["investments","liabilities"],"webhook":"https://example.com/webhooks","redirect_uri":"http://localhost:3000/accounts","transactions":{"days_requested":730}}' + headers: + Content-Type: + - application/json + User-Agent: + - Plaid Ruby v38.0.0 + Accept: + - application/json + Plaid-Client-Id: + - "" + Plaid-Version: + - '2020-09-14' + Plaid-Secret: + - "" + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Mon, 19 May 2025 12:22:57 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '146' + Connection: + - keep-alive + Plaid-Version: + - '2020-09-14' + Vary: + - Accept-Encoding + X-Envoy-Upstream-Service-Time: + - '51' + X-Envoy-Decorator-Operation: + - default.svc-apiv2:8080/* + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Xss-Protection: + - 1; mode=block + body: + encoding: ASCII-8BIT + string: |- + { + "expiration": "2025-05-19T16:22:58Z", + "link_token": "link-sandbox-f8d1c8ab-86c9-4f6e-9136-8e8fce0b078a", + "request_id": "JOT5vEl1srPSLI4" + } + recorded_at: Mon, 19 May 2025 12:22:57 GMT +recorded_with: VCR 6.3.1