mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-08 23:15:24 +02:00
Sketch out Plaid provider tests
This commit is contained in:
parent
09b27709c0
commit
7b26d9a4d4
23 changed files with 1308 additions and 168 deletions
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
75
app/models/plaid_item/accounts_snapshot.rb
Normal file
75
app/models/plaid_item/accounts_snapshot.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
||||
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|
|
||||
snapshot.accounts.each do |raw_account|
|
||||
plaid_account = plaid_item.plaid_accounts.find_or_initialize_by(
|
||||
plaid_id: raw_account_payload.account_id
|
||||
plaid_id: raw_account.account_id
|
||||
)
|
||||
|
||||
PlaidAccount::Importer.new(
|
||||
plaid_account,
|
||||
accounts_data: accounts_data,
|
||||
transactions_data: transactions_data,
|
||||
investments_data: investments_data,
|
||||
liabilities_data: liabilities_data
|
||||
account_snapshot: snapshot.get_account_data(raw_account.account_id)
|
||||
).import
|
||||
end
|
||||
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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
3
db/schema.rb
generated
3
db/schema.rb
generated
|
@ -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
|
||||
|
||||
|
|
84
test/models/provider/plaid_test.rb
Normal file
84
test/models/provider/plaid_test.rb
Normal file
|
@ -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
|
2
test/support/plaid_mock.rb
Normal file
2
test/support/plaid_mock.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
class PlaidMock
|
||||
end
|
|
@ -29,6 +29,8 @@ VCR.configure do |config|
|
|||
config.filter_sensitive_data("<OPENAI_ORGANIZATION_ID>") { ENV["OPENAI_ORGANIZATION_ID"] }
|
||||
config.filter_sensitive_data("<STRIPE_SECRET_KEY>") { ENV["STRIPE_SECRET_KEY"] }
|
||||
config.filter_sensitive_data("<STRIPE_WEBHOOK_SECRET>") { ENV["STRIPE_WEBHOOK_SECRET"] }
|
||||
config.filter_sensitive_data("<PLAID_CLIENT_ID>") { ENV["PLAID_CLIENT_ID"] }
|
||||
config.filter_sensitive_data("<PLAID_SECRET>") { ENV["PLAID_SECRET"] }
|
||||
end
|
||||
|
||||
module ActiveSupport
|
||||
|
|
124
test/vcr_cassettes/plaid/exchange_public_token.yml
Normal file
124
test/vcr_cassettes/plaid/exchange_public_token.yml
Normal file
|
@ -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_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<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_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<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
|
103
test/vcr_cassettes/plaid/get_item.yml
Normal file
103
test/vcr_cassettes/plaid/get_item.yml
Normal file
|
@ -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_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<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
|
572
test/vcr_cassettes/plaid/get_item_investments.yml
Normal file
572
test/vcr_cassettes/plaid/get_item_investments.yml
Normal file
|
@ -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_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<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_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<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
|
135
test/vcr_cassettes/plaid/get_transactions_with_next_cursor.yml
Normal file
135
test/vcr_cassettes/plaid/get_transactions_with_next_cursor.yml
Normal file
|
@ -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_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<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_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<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
|
64
test/vcr_cassettes/plaid/link_token.yml
Normal file
64
test/vcr_cassettes/plaid/link_token.yml
Normal file
|
@ -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_CLIENT_ID>"
|
||||
Plaid-Version:
|
||||
- '2020-09-14'
|
||||
Plaid-Secret:
|
||||
- "<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
|
Loading…
Add table
Add a link
Reference in a new issue