1
0
Fork 0
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:
Zach Gollwitzer 2025-05-19 09:55:16 -04:00
parent 09b27709c0
commit 7b26d9a4d4
23 changed files with 1308 additions and 168 deletions

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 }

View file

@ -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")

View file

@ -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
View file

@ -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

View 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

View file

@ -0,0 +1,2 @@
class PlaidMock
end

View file

@ -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

View 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

View 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

View 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

View 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

View 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