1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 20:59:39 +02:00

Plaid sync domain improvements (#2267)
Some checks are pending
Publish Docker image / ci (push) Waiting to run
Publish Docker image / Build docker image (push) Blocked by required conditions

Breaks our Plaid sync process out into more manageable classes. Notably, this moves the sync process to a distinct, 2-step flow:

1. Import stage - we first make API calls and import Plaid data to "mirror" tables
2. Processing stage - read the raw data, apply business rules, build internal domain models and sync balances

This provides several benefits:

- Plaid syncs can now be "replayed" without fetching API data again
- Mirror tables provide better audit and debugging capabilities
- Eliminates the "all or nothing" sync behavior that is currently in place, which is brittle
This commit is contained in:
Zach Gollwitzer 2025-05-23 18:58:22 -04:00 committed by GitHub
parent 5c82af0e8c
commit 03a146222d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 3763 additions and 706 deletions

View file

@ -8,7 +8,7 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest
test "create" do
@plaid_provider = mock
Provider::Registry.expects(:get_provider).with(:plaid_us).returns(@plaid_provider)
Provider::Registry.expects(:plaid_provider_for_region).with("us").returns(@plaid_provider)
public_token = "public-sandbox-1234"

View file

@ -24,9 +24,10 @@ depository:
connected:
family: dylan_family
name: Connected Account
name: Plaid Depository Account
balance: 5000
currency: USD
subtype: checking
accountable_type: Depository
accountable: two
plaid_account: one

View file

@ -1,3 +1,9 @@
one:
current_balance: 1000
available_balance: 1000
currency: USD
name: Plaid Depository Account
plaid_item: one
plaid_id: "1234567890"
plaid_id: "acc_mock_1"
plaid_type: depository
plaid_subtype: checking

View file

@ -1,5 +1,7 @@
one:
family: dylan_family
plaid_id: "1234567890"
plaid_id: "item_mock_1"
access_token: encrypted_token_1
name: "Test Bank"
name: "Test Bank"
billed_products: ["transactions", "investments", "liabilities"]
available_products: []

View file

@ -0,0 +1,35 @@
require "test_helper"
class PlaidAccount::ImporterTest < ActiveSupport::TestCase
setup do
@mock_provider = PlaidMock.new
@plaid_account = plaid_accounts(:one)
@plaid_item = @plaid_account.plaid_item
@accounts_snapshot = PlaidItem::AccountsSnapshot.new(@plaid_item, plaid_provider: @mock_provider)
@account_snapshot = @accounts_snapshot.get_account_data(@plaid_account.plaid_id)
end
test "imports account data" do
PlaidAccount::Importer.new(@plaid_account, account_snapshot: @account_snapshot).import
assert_equal @account_snapshot.account_data.account_id, @plaid_account.plaid_id
assert_equal @account_snapshot.account_data.name, @plaid_account.name
assert_equal @account_snapshot.account_data.mask, @plaid_account.mask
assert_equal @account_snapshot.account_data.type, @plaid_account.plaid_type
assert_equal @account_snapshot.account_data.subtype, @plaid_account.plaid_subtype
# This account has transactions data
assert_equal PlaidMock::TRANSACTIONS.count, @plaid_account.raw_transactions_payload["added"].count
# This account does not have investment data
assert_equal 0, @plaid_account.raw_investments_payload["holdings"].count
assert_equal 0, @plaid_account.raw_investments_payload["securities"].count
assert_equal 0, @plaid_account.raw_investments_payload["transactions"].count
# This account is a credit card, so it should have liability data
assert_equal @plaid_account.plaid_id, @plaid_account.raw_liabilities_payload["credit"]["account_id"]
assert_nil @plaid_account.raw_liabilities_payload["mortgage"]
assert_nil @plaid_account.raw_liabilities_payload["student"]
end
end

View file

@ -0,0 +1,83 @@
require "test_helper"
class PlaidAccount::Investments::BalanceCalculatorTest < ActiveSupport::TestCase
setup do
@plaid_account = plaid_accounts(:one)
@plaid_account.update!(
plaid_type: "investment",
current_balance: 4000,
available_balance: 2000 # We ignore this since we have current_balance + holdings
)
end
test "calculates total balance from cash and positions" do
brokerage_cash_security_id = "plaid_brokerage_cash" # Plaid's brokerage cash security
cash_equivalent_security_id = "plaid_cash_equivalent" # Cash equivalent security (i.e. money market fund)
aapl_security_id = "plaid_aapl_security" # Regular stock security
test_investments = {
transactions: [], # Irrelevant for balance calcs, leave empty
holdings: [
# $1,000 in brokerage cash
{
security_id: brokerage_cash_security_id,
cost_basis: 1000,
institution_price: 1,
institution_value: 1000,
quantity: 1000
},
# $1,000 in money market funds
{
security_id: cash_equivalent_security_id,
cost_basis: 1000,
institution_price: 1,
institution_value: 1000,
quantity: 1000
},
# $2,000 worth of AAPL stock
{
security_id: aapl_security_id,
cost_basis: 2000,
institution_price: 200,
institution_value: 2000,
quantity: 10
}
],
securities: [
{
security_id: brokerage_cash_security_id,
ticker_symbol: "CUR:USD",
is_cash_equivalent: true,
type: "cash"
},
{
security_id: cash_equivalent_security_id,
ticker_symbol: "VMFXX", # Vanguard Money Market Reserves
is_cash_equivalent: true,
type: "mutual fund"
},
{
security_id: aapl_security_id,
ticker_symbol: "AAPL",
is_cash_equivalent: false,
type: "equity",
market_identifier_code: "XNAS"
}
]
}
@plaid_account.update!(raw_investments_payload: test_investments)
security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
balance_calculator = PlaidAccount::Investments::BalanceCalculator.new(@plaid_account, security_resolver: security_resolver)
# We set this equal to `current_balance`
assert_equal 4000, balance_calculator.balance
# This is the sum of "non-brokerage-cash-holdings". In the above test case, this means
# we're summing up $2,000 of AAPL + $1,000 Vanguard MM for $3,000 in holdings value.
# We back this $3,000 from the $4,000 total to get $1,000 in cash balance.
assert_equal 1000, balance_calculator.cash_balance
end
end

View file

@ -0,0 +1,49 @@
require "test_helper"
class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
setup do
@plaid_account = plaid_accounts(:one)
@security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
end
test "creates holding records from Plaid holdings snapshot" do
test_investments_payload = {
securities: [], # mocked
holdings: [
{
"security_id" => "123",
"quantity" => 100,
"institution_price" => 100,
"iso_currency_code" => "USD"
}
],
transactions: [] # not relevant for test
}
@plaid_account.update!(raw_investments_payload: test_investments_payload)
@security_resolver.expects(:resolve)
.with(plaid_security_id: "123")
.returns(
OpenStruct.new(
security: securities(:aapl),
cash_equivalent?: false,
brokerage_cash?: false
)
)
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
assert_difference "Holding.count" do
processor.process
end
holding = Holding.order(created_at: :desc).first
assert_equal 100, holding.qty
assert_equal 100, holding.price
assert_equal "USD", holding.currency
assert_equal securities(:aapl), holding.security
assert_equal Date.current, holding.date
end
end

View file

@ -0,0 +1,115 @@
require "test_helper"
class PlaidAccount::Investments::SecurityResolverTest < ActiveSupport::TestCase
setup do
@upstream_resolver = mock("Security::Resolver")
@plaid_account = plaid_accounts(:one)
@resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
end
test "handles missing plaid security" do
missing_id = "missing_security_id"
# Ensure there are *no* securities that reference the missing ID
@plaid_account.update!(raw_investments_payload: {
securities: [
{
"security_id" => "some_other_id",
"ticker_symbol" => "FOO",
"type" => "equity",
"market_identifier_code" => "XNAS"
}
]
})
Security::Resolver.expects(:new).never
Sentry.stubs(:capture_exception)
response = @resolver.resolve(plaid_security_id: missing_id)
assert_nil response.security
refute response.cash_equivalent?
refute response.brokerage_cash?
end
test "identifies brokerage cash plaid securities" do
brokerage_cash_id = "brokerage_cash_security_id"
@plaid_account.update!(raw_investments_payload: {
securities: [
{
"security_id" => brokerage_cash_id,
"ticker_symbol" => "CUR:USD", # Plaid brokerage cash ticker
"type" => "cash",
"is_cash_equivalent" => true
}
]
})
Security::Resolver.expects(:new).never
response = @resolver.resolve(plaid_security_id: brokerage_cash_id)
assert_nil response.security
assert response.cash_equivalent?
assert response.brokerage_cash?
end
test "identifies cash equivalent plaid securities" do
mmf_security_id = "money_market_security_id"
@plaid_account.update!(raw_investments_payload: {
securities: [
{
"security_id" => mmf_security_id,
"ticker_symbol" => "VMFXX", # Vanguard Federal Money Market Fund
"type" => "mutual fund",
"is_cash_equivalent" => true,
"market_identifier_code" => "XNAS"
}
]
})
resolved_security = Security.create!(ticker: "VMFXX", exchange_operating_mic: "XNAS")
Security::Resolver.expects(:new)
.with("VMFXX", exchange_operating_mic: "XNAS")
.returns(@upstream_resolver)
@upstream_resolver.expects(:resolve).returns(resolved_security)
response = @resolver.resolve(plaid_security_id: mmf_security_id)
assert_equal resolved_security, response.security
assert response.cash_equivalent?
refute response.brokerage_cash?
end
test "resolves normal plaid securities" do
security_id = "regular_security_id"
@plaid_account.update!(raw_investments_payload: {
securities: [
{
"security_id" => security_id,
"ticker_symbol" => "IVV",
"type" => "etf",
"is_cash_equivalent" => false,
"market_identifier_code" => "XNAS"
}
]
})
resolved_security = Security.create!(ticker: "IVV", exchange_operating_mic: "XNAS")
Security::Resolver.expects(:new)
.with("IVV", exchange_operating_mic: "XNAS")
.returns(@upstream_resolver)
@upstream_resolver.expects(:resolve).returns(resolved_security)
response = @resolver.resolve(plaid_security_id: security_id)
assert_equal resolved_security, response.security
refute response.cash_equivalent? # Normal securities are not cash equivalent
refute response.brokerage_cash?
end
end

View file

@ -0,0 +1,111 @@
require "test_helper"
class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::TestCase
setup do
@plaid_account = plaid_accounts(:one)
@security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)
end
test "creates regular trade entries" do
test_investments_payload = {
transactions: [
{
"transaction_id" => "123",
"security_id" => "123",
"type" => "buy",
"quantity" => 1, # Positive, so "buy 1 share"
"price" => 100,
"iso_currency_code" => "USD",
"date" => Date.current,
"name" => "Buy 1 share of AAPL"
}
]
}
@plaid_account.update!(raw_investments_payload: test_investments_payload)
@security_resolver.stubs(:resolve).returns(OpenStruct.new(
security: securities(:aapl)
))
processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)
assert_difference [ "Entry.count", "Trade.count" ], 1 do
processor.process
end
entry = Entry.order(created_at: :desc).first
assert_equal 100, entry.amount
assert_equal "USD", entry.currency
assert_equal Date.current, entry.date
assert_equal "Buy 1 share of AAPL", entry.name
end
test "creates cash transactions" do
test_investments_payload = {
transactions: [
{
"transaction_id" => "123",
"type" => "cash",
"subtype" => "withdrawal",
"amount" => 100, # Positive, so moving money OUT of the account
"iso_currency_code" => "USD",
"date" => Date.current,
"name" => "Withdrawal"
}
]
}
@plaid_account.update!(raw_investments_payload: test_investments_payload)
@security_resolver.expects(:resolve).never # Cash transactions don't have a security
processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
processor.process
end
entry = Entry.order(created_at: :desc).first
assert_equal 100, entry.amount
assert_equal "USD", entry.currency
assert_equal Date.current, entry.date
assert_equal "Withdrawal", entry.name
end
test "creates fee transactions" do
test_investments_payload = {
transactions: [
{
"transaction_id" => "123",
"type" => "fee",
"subtype" => "miscellaneous fee",
"amount" => 10.25,
"iso_currency_code" => "USD",
"date" => Date.current,
"name" => "Miscellaneous fee"
}
]
}
@plaid_account.update!(raw_investments_payload: test_investments_payload)
@security_resolver.expects(:resolve).never # Cash transactions don't have a security
processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
processor.process
end
entry = Entry.order(created_at: :desc).first
assert_equal 10.25, entry.amount
assert_equal "USD", entry.currency
assert_equal Date.current, entry.date
assert_equal "Miscellaneous fee", entry.name
end
end

View file

@ -0,0 +1,39 @@
require "test_helper"
class PlaidAccount::Liabilities::CreditProcessorTest < ActiveSupport::TestCase
setup do
@plaid_account = plaid_accounts(:one)
@plaid_account.update!(
plaid_type: "credit",
plaid_subtype: "credit_card"
)
@plaid_account.account.update!(
accountable: CreditCard.new,
)
end
test "updates credit card minimum payment and APR from Plaid data" do
@plaid_account.update!(raw_liabilities_payload: {
credit: {
minimum_payment_amount: 100,
aprs: [ { apr_percentage: 15.0 } ]
}
})
processor = PlaidAccount::Liabilities::CreditProcessor.new(@plaid_account)
processor.process
assert_equal 100, @plaid_account.account.credit_card.minimum_payment
assert_equal 15.0, @plaid_account.account.credit_card.apr
end
test "does nothing when liability data absent" do
@plaid_account.update!(raw_liabilities_payload: {})
processor = PlaidAccount::Liabilities::CreditProcessor.new(@plaid_account)
processor.process
assert_nil @plaid_account.account.credit_card.minimum_payment
assert_nil @plaid_account.account.credit_card.apr
end
end

View file

@ -0,0 +1,44 @@
require "test_helper"
class PlaidAccount::Liabilities::MortgageProcessorTest < ActiveSupport::TestCase
setup do
@plaid_account = plaid_accounts(:one)
@plaid_account.update!(
plaid_type: "loan",
plaid_subtype: "mortgage"
)
@plaid_account.account.update!(accountable: Loan.new)
end
test "updates loan interest rate and type from Plaid data" do
@plaid_account.update!(raw_liabilities_payload: {
mortgage: {
interest_rate: {
type: "fixed",
percentage: 4.25
}
}
})
processor = PlaidAccount::Liabilities::MortgageProcessor.new(@plaid_account)
processor.process
loan = @plaid_account.account.loan
assert_equal "fixed", loan.rate_type
assert_equal 4.25, loan.interest_rate
end
test "does nothing when mortgage data absent" do
@plaid_account.update!(raw_liabilities_payload: {})
processor = PlaidAccount::Liabilities::MortgageProcessor.new(@plaid_account)
processor.process
loan = @plaid_account.account.loan
assert_nil loan.rate_type
assert_nil loan.interest_rate
end
end

View file

@ -0,0 +1,68 @@
require "test_helper"
class PlaidAccount::Liabilities::StudentLoanProcessorTest < ActiveSupport::TestCase
setup do
@plaid_account = plaid_accounts(:one)
@plaid_account.update!(
plaid_type: "loan",
plaid_subtype: "student"
)
# Change the underlying accountable to a Loan so the helper method `loan` is available
@plaid_account.account.update!(accountable: Loan.new)
end
test "updates loan details including term months from Plaid data" do
@plaid_account.update!(raw_liabilities_payload: {
student: {
interest_rate_percentage: 5.5,
origination_principal_amount: 20000,
origination_date: Date.new(2020, 1, 1),
expected_payoff_date: Date.new(2022, 1, 1)
}
})
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
processor.process
loan = @plaid_account.account.loan
assert_equal "fixed", loan.rate_type
assert_equal 5.5, loan.interest_rate
assert_equal 20000, loan.initial_balance
assert_equal 24, loan.term_months
end
test "handles missing payoff dates gracefully" do
@plaid_account.update!(raw_liabilities_payload: {
student: {
interest_rate_percentage: 4.8,
origination_principal_amount: 15000,
origination_date: Date.new(2021, 6, 1)
# expected_payoff_date omitted
}
})
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
processor.process
loan = @plaid_account.account.loan
assert_nil loan.term_months
assert_equal 4.8, loan.interest_rate
assert_equal 15000, loan.initial_balance
end
test "does nothing when loan data absent" do
@plaid_account.update!(raw_liabilities_payload: {})
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
processor.process
loan = @plaid_account.account.loan
assert_nil loan.interest_rate
assert_nil loan.initial_balance
assert_nil loan.term_months
end
end

View file

@ -0,0 +1,172 @@
require "test_helper"
class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
setup do
@plaid_account = plaid_accounts(:one)
end
test "processes new account and assigns attributes" do
Account.destroy_all # Clear out internal accounts so we start fresh
expect_default_subprocessor_calls
@plaid_account.update!(
plaid_id: "test_plaid_id",
plaid_type: "depository",
plaid_subtype: "checking",
current_balance: 1000,
available_balance: 1000,
currency: "USD",
name: "Test Plaid Account",
mask: "1234"
)
assert_difference "Account.count" do
PlaidAccount::Processor.new(@plaid_account).process
end
@plaid_account.reload
account = Account.order(created_at: :desc).first
assert_equal "Test Plaid Account", account.name
assert_equal @plaid_account.id, account.plaid_account_id
assert_equal "checking", account.subtype
assert_equal 1000, account.balance
assert_equal 1000, account.cash_balance
assert_equal "USD", account.currency
assert_equal "Depository", account.accountable_type
assert_equal "checking", account.subtype
end
test "processing is idempotent with updates and enrichments" do
expect_default_subprocessor_calls
assert_equal "Plaid Depository Account", @plaid_account.account.name
assert_equal "checking", @plaid_account.account.subtype
@plaid_account.account.update!(
name: "User updated name",
subtype: "savings",
balance: 2000 # User cannot override balance. This will be overridden by the processor on next processing
)
@plaid_account.account.lock_attr!(:name)
@plaid_account.account.lock_attr!(:subtype)
@plaid_account.account.lock_attr!(:balance) # Even if balance somehow becomes locked, Plaid ignores it and overrides it
assert_no_difference "Account.count" do
PlaidAccount::Processor.new(@plaid_account).process
end
@plaid_account.reload
assert_equal "User updated name", @plaid_account.account.name
assert_equal "savings", @plaid_account.account.subtype
assert_equal @plaid_account.current_balance, @plaid_account.account.balance # Overriden by processor
end
test "account processing failure halts further processing" do
Account.any_instance.stubs(:save!).raises(StandardError.new("Test error"))
PlaidAccount::Transactions::Processor.any_instance.expects(:process).never
PlaidAccount::Investments::TransactionsProcessor.any_instance.expects(:process).never
PlaidAccount::Investments::HoldingsProcessor.any_instance.expects(:process).never
expect_no_investment_balance_calculator_calls
expect_no_liability_processor_calls
assert_raises(StandardError) do
PlaidAccount::Processor.new(@plaid_account).process
end
end
test "product processing failure reports exception and continues processing" do
PlaidAccount::Transactions::Processor.any_instance.stubs(:process).raises(StandardError.new("Test error"))
# Subsequent product processors still run
expect_investment_product_processor_calls
assert_nothing_raised do
PlaidAccount::Processor.new(@plaid_account).process
end
end
test "calculates balance using BalanceCalculator for investment accounts" do
@plaid_account.update!(plaid_type: "investment")
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).returns(1000).once
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:cash_balance).returns(1000).once
PlaidAccount::Processor.new(@plaid_account).process
end
test "processes credit liability data" do
expect_investment_product_processor_calls
expect_no_investment_balance_calculator_calls
expect_depository_product_processor_calls
@plaid_account.update!(plaid_type: "credit", plaid_subtype: "credit card")
PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).once
PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).never
PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).never
PlaidAccount::Processor.new(@plaid_account).process
end
test "processes mortgage liability data" do
expect_investment_product_processor_calls
expect_no_investment_balance_calculator_calls
expect_depository_product_processor_calls
@plaid_account.update!(plaid_type: "loan", plaid_subtype: "mortgage")
PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).never
PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).once
PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).never
PlaidAccount::Processor.new(@plaid_account).process
end
test "processes student loan liability data" do
expect_investment_product_processor_calls
expect_no_investment_balance_calculator_calls
expect_depository_product_processor_calls
@plaid_account.update!(plaid_type: "loan", plaid_subtype: "student")
PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).never
PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).never
PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).once
PlaidAccount::Processor.new(@plaid_account).process
end
private
def expect_investment_product_processor_calls
PlaidAccount::Investments::TransactionsProcessor.any_instance.expects(:process).once
PlaidAccount::Investments::HoldingsProcessor.any_instance.expects(:process).once
end
def expect_depository_product_processor_calls
PlaidAccount::Transactions::Processor.any_instance.expects(:process).once
end
def expect_no_investment_balance_calculator_calls
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).never
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:cash_balance).never
end
def expect_no_liability_processor_calls
PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).never
PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).never
PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).never
end
def expect_default_subprocessor_calls
expect_depository_product_processor_calls
expect_investment_product_processor_calls
expect_no_investment_balance_calculator_calls
expect_no_liability_processor_calls
end
end

View file

@ -1,6 +1,6 @@
require "test_helper"
class Provider::Plaid::CategoryAliasMatcherTest < ActiveSupport::TestCase
class PlaidAccount::Transactions::CategoryMatcherTest < ActiveSupport::TestCase
setup do
@family = families(:empty)
@ -32,7 +32,7 @@ class Provider::Plaid::CategoryAliasMatcherTest < ActiveSupport::TestCase
@giving = @family.categories.create!(name: "Giving")
@matcher = Provider::Plaid::CategoryAliasMatcher.new(@family.categories)
@matcher = PlaidAccount::Transactions::CategoryMatcher.new(@family.categories)
end
test "matches expense categories" do

View file

@ -0,0 +1,63 @@
require "test_helper"
class PlaidAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
setup do
@plaid_account = plaid_accounts(:one)
end
test "processes added and modified plaid transactions" do
added_transactions = [ { "transaction_id" => "123" } ]
modified_transactions = [ { "transaction_id" => "456" } ]
@plaid_account.update!(raw_transactions_payload: {
added: added_transactions,
modified: modified_transactions,
removed: []
})
mock_processor = mock("PlaidEntry::Processor")
category_matcher_mock = mock("PlaidAccount::Transactions::CategoryMatcher")
PlaidAccount::Transactions::CategoryMatcher.stubs(:new).returns(category_matcher_mock)
PlaidEntry::Processor.expects(:new)
.with(added_transactions.first, plaid_account: @plaid_account, category_matcher: category_matcher_mock)
.returns(mock_processor)
.once
PlaidEntry::Processor.expects(:new)
.with(modified_transactions.first, plaid_account: @plaid_account, category_matcher: category_matcher_mock)
.returns(mock_processor)
.once
mock_processor.expects(:process).twice
processor = PlaidAccount::Transactions::Processor.new(@plaid_account)
processor.process
end
test "removes transactions no longer in plaid" do
destroyable_transaction_id = "destroy_me"
@plaid_account.account.entries.create!(
plaid_id: destroyable_transaction_id,
date: Date.current,
amount: 100,
name: "Destroy me",
currency: "USD",
entryable: Transaction.new
)
@plaid_account.update!(raw_transactions_payload: {
added: [],
modified: [],
removed: [ { "transaction_id" => destroyable_transaction_id } ]
})
processor = PlaidAccount::Transactions::Processor.new(@plaid_account)
assert_difference [ "Entry.count", "Transaction.count" ], -1 do
processor.process
end
assert_nil Entry.find_by(plaid_id: destroyable_transaction_id)
end
end

View file

@ -0,0 +1,35 @@
require "test_helper"
class PlaidAccount::TypeMappableTest < ActiveSupport::TestCase
setup do
class MockProcessor
include PlaidAccount::TypeMappable
end
@mock_processor = MockProcessor.new
end
test "maps types to accountables" do
assert_instance_of Depository, @mock_processor.map_accountable("depository")
assert_instance_of Investment, @mock_processor.map_accountable("investment")
assert_instance_of CreditCard, @mock_processor.map_accountable("credit")
assert_instance_of Loan, @mock_processor.map_accountable("loan")
assert_instance_of OtherAsset, @mock_processor.map_accountable("other")
end
test "maps subtypes" do
assert_equal "checking", @mock_processor.map_subtype("depository", "checking")
assert_equal "roth_ira", @mock_processor.map_subtype("investment", "roth")
end
test "raises on invalid types" do
assert_raises PlaidAccount::TypeMappable::UnknownAccountTypeError do
@mock_processor.map_accountable("unknown")
end
end
test "handles nil subtypes" do
assert_equal "other", @mock_processor.map_subtype("depository", nil)
assert_equal "other", @mock_processor.map_subtype("depository", "unknown")
end
end

View file

@ -0,0 +1,91 @@
require "test_helper"
class PlaidEntry::ProcessorTest < ActiveSupport::TestCase
setup do
@plaid_account = plaid_accounts(:one)
@category_matcher = mock("PlaidAccount::Transactions::CategoryMatcher")
end
test "creates new entry transaction" do
plaid_transaction = {
"transaction_id" => "123",
"merchant_name" => "Amazon", # this is used for merchant and entry name
"amount" => 100,
"date" => Date.current,
"iso_currency_code" => "USD",
"personal_finance_category" => {
"detailed" => "Food"
},
"merchant_entity_id" => "123"
}
@category_matcher.expects(:match).with("Food").returns(categories(:food_and_drink))
processor = PlaidEntry::Processor.new(
plaid_transaction,
plaid_account: @plaid_account,
category_matcher: @category_matcher
)
assert_difference [ "Entry.count", "Transaction.count", "ProviderMerchant.count" ], 1 do
processor.process
end
entry = Entry.order(created_at: :desc).first
assert_equal 100, entry.amount
assert_equal "USD", entry.currency
assert_equal Date.current, entry.date
assert_equal "Amazon", entry.name
assert_equal categories(:food_and_drink).id, entry.transaction.category_id
provider_merchant = ProviderMerchant.order(created_at: :desc).first
assert_equal "Amazon", provider_merchant.name
end
test "updates existing entry transaction" do
existing_plaid_id = "existing_plaid_id"
plaid_transaction = {
"transaction_id" => existing_plaid_id,
"merchant_name" => "Amazon", # this is used for merchant and entry name
"amount" => 200, # Changed amount will be updated
"date" => 1.day.ago.to_date, # Changed date will be updated
"iso_currency_code" => "USD",
"personal_finance_category" => {
"detailed" => "Food"
}
}
@category_matcher.expects(:match).with("Food").returns(categories(:food_and_drink))
# Create an existing entry
@plaid_account.account.entries.create!(
plaid_id: existing_plaid_id,
amount: 100,
currency: "USD",
date: Date.current,
name: "Amazon",
entryable: Transaction.new
)
processor = PlaidEntry::Processor.new(
plaid_transaction,
plaid_account: @plaid_account,
category_matcher: @category_matcher
)
assert_no_difference [ "Entry.count", "Transaction.count", "ProviderMerchant.count" ] do
processor.process
end
entry = Entry.order(created_at: :desc).first
assert_equal 200, entry.amount
assert_equal "USD", entry.currency
assert_equal 1.day.ago.to_date, entry.date
assert_equal "Amazon", entry.name
assert_equal categories(:food_and_drink).id, entry.transaction.category_id
end
end

View file

@ -1,82 +0,0 @@
require "test_helper"
class PlaidInvestmentSyncTest < ActiveSupport::TestCase
include PlaidTestHelper
setup do
@plaid_account = plaid_accounts(:one)
end
test "syncs basic investments and handles cash holding" do
assert_equal 0, @plaid_account.account.entries.count
assert_equal 0, @plaid_account.account.holdings.count
plaid_aapl_id = "aapl_id"
transactions = [
create_plaid_investment_transaction({
investment_transaction_id: "inv_txn_1",
security_id: plaid_aapl_id,
quantity: 10,
price: 200,
date: 5.days.ago.to_date,
type: "buy"
})
]
holdings = [
create_plaid_cash_holding,
create_plaid_holding({
security_id: plaid_aapl_id,
quantity: 10,
institution_price: 200,
cost_basis: 2000
})
]
securities = [
create_plaid_security({
security_id: plaid_aapl_id,
close_price: 200,
ticker_symbol: "AAPL"
})
]
# Cash holding should be ignored, resulting in 1, NOT 2 total holdings after sync
assert_difference -> { Trade.count } => 1,
-> { Transaction.count } => 0,
-> { Holding.count } => 1,
-> { Security.count } => 0 do
PlaidInvestmentSync.new(@plaid_account).sync!(
transactions: transactions,
holdings: holdings,
securities: securities
)
end
end
# Some cash transactions from Plaid are labeled as type: "cash" while others are linked to a "cash" security
# In both cases, we should treat them as cash-only transactions (not trades)
test "handles cash investment transactions" do
transactions = [
create_plaid_investment_transaction({
price: 1,
quantity: 5,
amount: 5,
type: "fee",
subtype: "miscellaneous fee",
security_id: PLAID_TEST_CASH_SECURITY_ID
})
]
assert_difference -> { Trade.count } => 0,
-> { Transaction.count } => 1,
-> { Security.count } => 0 do
PlaidInvestmentSync.new(@plaid_account).sync!(
transactions: transactions,
holdings: [ create_plaid_cash_holding ],
securities: [ create_plaid_cash_security ]
)
end
end
end

View file

@ -0,0 +1,23 @@
require "test_helper"
require "ostruct"
class PlaidItem::ImporterTest < ActiveSupport::TestCase
setup do
@mock_provider = PlaidMock.new
@plaid_item = plaid_items(:one)
@importer = PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider)
end
test "imports item metadata" do
PlaidAccount::Importer.any_instance.expects(:import).times(PlaidMock::ACCOUNTS.count)
PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider).import
assert_equal PlaidMock::ITEM.institution_id, @plaid_item.institution_id
assert_equal PlaidMock::ITEM.available_products, @plaid_item.available_products
assert_equal PlaidMock::ITEM.billed_products, @plaid_item.billed_products
assert_equal PlaidMock::ITEM.item_id, @plaid_item.raw_payload["item_id"]
assert_equal PlaidMock::INSTITUTION.institution_id, @plaid_item.raw_institution_payload["institution_id"]
end
end

View file

@ -5,11 +5,11 @@ class PlaidItemTest < ActiveSupport::TestCase
setup do
@plaid_item = @syncable = plaid_items(:one)
@plaid_provider = mock
Provider::Registry.stubs(:plaid_provider_for_region).returns(@plaid_provider)
end
test "removes plaid item when destroyed" do
@plaid_provider = mock
@plaid_item.stubs(:plaid_provider).returns(@plaid_provider)
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).once
assert_difference "PlaidItem.count", -1 do
@ -18,8 +18,6 @@ class PlaidItemTest < ActiveSupport::TestCase
end
test "if plaid item not found, silently continues with deletion" do
@plaid_provider = mock
@plaid_item.stubs(:plaid_provider).returns(@plaid_provider)
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).raises(Plaid::ApiError.new("Item not found"))
assert_difference "PlaidItem.count", -1 do

View file

@ -0,0 +1,80 @@
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_109508", item.institution_id
assert_equal "First Platypus Bank", item.institution_name
end
end
test "gets item accounts" do
VCR.use_cassette("plaid/get_item_accounts") do
access_token = get_access_token
accounts_response = @plaid.get_item_accounts(access_token)
assert_equal 4, accounts_response.accounts.size
end
end
test "gets item investments" do
VCR.use_cassette("plaid/get_item_investments") do
access_token = get_access_token
investments_response = @plaid.get_item_investments(access_token)
assert_equal 3, investments_response.holdings.size
assert_equal 4, investments_response.transactions.size
end
end
test "gets item liabilities" do
VCR.use_cassette("plaid/get_item_liabilities") do
access_token = get_access_token
liabilities_response = @plaid.get_item_liabilities(access_token)
assert liabilities_response.credit.count > 0
assert liabilities_response.student.count > 0
end
end
private
def get_access_token
VCR.use_cassette("plaid/access_token") do
public_token = @plaid.create_public_token
exchange_response = @plaid.exchange_public_token(public_token)
exchange_response.access_token
end
end
end

214
test/support/plaid_mock.rb Normal file
View file

@ -0,0 +1,214 @@
require "ostruct"
# Lightweight wrapper that allows Ostruct objects to properly serialize to JSON
# for storage on PlaidItem / PlaidAccount JSONB columns
class MockData < OpenStruct
def as_json(options = {})
@table.as_json(options)
end
end
# A basic Plaid provider mock that returns static payloads for testing
class PlaidMock
TransactionSyncResponse = Struct.new(:added, :modified, :removed, :cursor, keyword_init: true)
InvestmentsResponse = Struct.new(:holdings, :transactions, :securities, keyword_init: true)
ITEM = MockData.new(
item_id: "item_mock_1",
institution_id: "ins_mock",
institution_name: "Mock Institution",
available_products: [],
billed_products: %w[transactions investments liabilities]
)
INSTITUTION = MockData.new(
institution_id: "ins_mock",
institution_name: "Mock Institution"
)
ACCOUNTS = [
MockData.new(
account_id: "acc_mock_1",
name: "Mock Checking",
mask: "1111",
type: "depository",
subtype: "checking",
balances: MockData.new(
current: 1_000.00,
available: 800.00,
iso_currency_code: "USD"
)
),
MockData.new(
account_id: "acc_mock_2",
name: "Mock Brokerage",
mask: "2222",
type: "investment",
subtype: "brokerage",
balances: MockData.new(
current: 15_000.00,
available: 15_000.00,
iso_currency_code: "USD"
)
)
]
SECURITIES = [
MockData.new(
security_id: "sec_mock_1",
ticker_symbol: "AAPL",
proxy_security_id: nil,
market_identifier_code: "XNAS",
type: "equity",
is_cash_equivalent: false
),
# Cash security representation used to exclude cash-equivalent holdings
MockData.new(
security_id: "sec_mock_cash",
ticker_symbol: "CUR:USD",
proxy_security_id: nil,
market_identifier_code: nil,
type: "cash",
is_cash_equivalent: true
)
]
TRANSACTIONS = [
MockData.new(
transaction_id: "txn_mock_1",
account_id: "acc_mock_1",
merchant_name: "Mock Coffee",
original_description: "MOCK COFFEE SHOP",
amount: 4.50,
iso_currency_code: "USD",
date: Date.current.to_s,
personal_finance_category: OpenStruct.new(primary: "FOOD_AND_DRINK", detailed: "COFFEE_SHOP"),
website: "https://coffee.example.com",
logo_url: "https://coffee.example.com/logo.png",
merchant_entity_id: "merch_mock_1"
)
]
INVESTMENT_TRANSACTIONS = [
MockData.new(
investment_transaction_id: "inv_txn_mock_1",
account_id: "acc_mock_2",
security_id: "sec_mock_1",
type: "buy",
name: "BUY AAPL",
quantity: 10,
price: 150.00,
amount: -1_500.00,
iso_currency_code: "USD",
date: Date.current.to_s
),
MockData.new(
investment_transaction_id: "inv_txn_mock_cash",
account_id: "acc_mock_2",
security_id: "sec_mock_cash",
type: "cash",
name: "Cash Dividend",
quantity: 1,
price: 200.00,
amount: 200.00,
iso_currency_code: "USD",
date: Date.current.to_s
)
]
HOLDINGS = [
MockData.new(
account_id: "acc_mock_2",
security_id: "sec_mock_1",
quantity: 10,
institution_price: 150.00,
iso_currency_code: "USD"
),
MockData.new(
account_id: "acc_mock_2",
security_id: "sec_mock_cash",
quantity: 200.0,
institution_price: 1.00,
iso_currency_code: "USD"
)
]
LIABILITIES = {
credit: [
MockData.new(
account_id: "acc_mock_1",
minimum_payment_amount: 25.00,
aprs: [ MockData.new(apr_percentage: 19.99) ]
)
],
mortgage: [
MockData.new(
account_id: "acc_mock_3",
origination_principal_amount: 250_000,
origination_date: 10.years.ago.to_date.to_s,
interest_rate: MockData.new(type: "fixed", percentage: 3.5)
)
],
student: [
MockData.new(
account_id: "acc_mock_4",
origination_principal_amount: 50_000,
origination_date: 6.years.ago.to_date.to_s,
interest_rate_percentage: 4.0
)
]
}
def get_link_token(*, **)
MockData.new(link_token: "link-mock-123")
end
def create_public_token(username: nil)
"public-mock-#{username || 'user'}"
end
def exchange_public_token(_token)
MockData.new(access_token: "access-mock-123")
end
def get_item(_access_token)
MockData.new(
item: ITEM
)
end
def get_institution(institution_id)
MockData.new(
institution: INSTITUTION
)
end
def get_item_accounts(_item_or_token)
MockData.new(accounts: ACCOUNTS)
end
def get_transactions(access_token, next_cursor: nil)
TransactionSyncResponse.new(
added: TRANSACTIONS,
modified: [],
removed: [],
cursor: "cursor-mock-1"
)
end
def get_item_investments(_item_or_token, **)
InvestmentsResponse.new(
holdings: HOLDINGS,
transactions: INVESTMENT_TRANSACTIONS,
securities: SECURITIES
)
end
def get_item_liabilities(_item_or_token)
MockData.new(
credit: LIABILITIES[:credit],
mortgage: LIABILITIES[:mortgage],
student: LIABILITIES[:student]
)
end
end

View file

@ -1,128 +0,0 @@
require "ostruct"
module PlaidTestHelper
PLAID_TEST_ACCOUNT_ID = "plaid_test_account_id"
PLAID_TEST_CASH_SECURITY_ID = "plaid_test_cash_security_id"
# Special case
def create_plaid_cash_security(attributes = {})
default_attributes = {
close_price: nil,
close_price_as_of: nil,
cusip: nil,
fixed_income: nil,
industry: nil,
institution_id: nil,
institution_security_id: nil,
is_cash_equivalent: false, # Plaid sometimes returns false here (bad data), so we should not rely on it
isin: nil,
iso_currency_code: "USD",
market_identifier_code: nil,
name: "US Dollar",
option_contract: nil,
proxy_security_id: nil,
sector: nil,
security_id: PLAID_TEST_CASH_SECURITY_ID,
sedol: nil,
ticker_symbol: "CUR:USD",
type: "cash",
unofficial_currency_code: nil,
update_datetime: nil
}
OpenStruct.new(
default_attributes.merge(attributes)
)
end
def create_plaid_security(attributes = {})
default_attributes = {
close_price: 606.71,
close_price_as_of: Date.current,
cusip: nil,
fixed_income: nil,
industry: "Mutual Funds",
institution_id: nil,
institution_security_id: nil,
is_cash_equivalent: false,
isin: nil,
iso_currency_code: "USD",
market_identifier_code: "XNAS",
name: "iShares S&P 500 Index",
option_contract: nil,
proxy_security_id: nil,
sector: "Financial",
security_id: "plaid_test_security_id",
sedol: "2593025",
ticker_symbol: "IVV",
type: "etf",
unofficial_currency_code: nil,
update_datetime: nil
}
OpenStruct.new(
default_attributes.merge(attributes)
)
end
def create_plaid_cash_holding(attributes = {})
default_attributes = {
account_id: PLAID_TEST_ACCOUNT_ID,
cost_basis: 1000,
institution_price: 1,
institution_price_as_of: Date.current,
iso_currency_code: "USD",
quantity: 1000,
security_id: PLAID_TEST_CASH_SECURITY_ID,
unofficial_currency_code: nil,
vested_quantity: nil,
vested_value: nil
}
OpenStruct.new(
default_attributes.merge(attributes)
)
end
def create_plaid_holding(attributes = {})
default_attributes = {
account_id: PLAID_TEST_ACCOUNT_ID,
cost_basis: 2000,
institution_price: 200,
institution_price_as_of: Date.current,
iso_currency_code: "USD",
quantity: 10,
security_id: "plaid_test_security_id",
unofficial_currency_code: nil,
vested_quantity: nil,
vested_value: nil
}
OpenStruct.new(
default_attributes.merge(attributes)
)
end
def create_plaid_investment_transaction(attributes = {})
default_attributes = {
account_id: PLAID_TEST_ACCOUNT_ID,
amount: 500,
cancel_transaction_id: nil,
date: 5.days.ago.to_date,
fees: 0,
investment_transaction_id: "plaid_test_investment_transaction_id",
iso_currency_code: "USD",
name: "Buy 100 shares of IVV",
price: 606.71,
quantity: 100,
security_id: "plaid_test_security_id",
type: "buy",
subtype: "buy",
unofficial_currency_code: nil
}
OpenStruct.new(
default_attributes.merge(attributes)
)
end
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_109508","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 17:24:03 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:
- '2892'
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-0463cb9d-8bdb-4e01-9b33-243e1370623c",
"request_id": "FaSopSLAyHsZM9O"
}
recorded_at: Mon, 19 May 2025 17:24:03 GMT
- request:
method: post
uri: https://sandbox.plaid.com/item/public_token/exchange
body:
encoding: UTF-8
string: '{"public_token":"public-sandbox-0463cb9d-8bdb-4e01-9b33-243e1370623c"}'
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 17:24:03 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:
- '171'
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-0af2c971-41a6-4fc5-a97d-f2b27ab0a648",
"item_id": "n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP",
"request_id": "2e1nOnm2CoOXVcH"
}
recorded_at: Mon, 19 May 2025 17:24:03 GMT
recorded_with: VCR 6.3.1

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_109508","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 17:24:09 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:
- '3086'
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-29a5644f-001d-4bf5-abae-d26ecf8ee211",
"request_id": "6dz2Xo7zoyT9W9R"
}
recorded_at: Mon, 19 May 2025 17:24:09 GMT
- request:
method: post
uri: https://sandbox.plaid.com/item/public_token/exchange
body:
encoding: UTF-8
string: '{"public_token":"public-sandbox-29a5644f-001d-4bf5-abae-d26ecf8ee211"}'
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 17:24:09 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:
- '152'
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-fb7bb5da-e3e2-464e-8644-4eeafbf6541f",
"item_id": "bd9d3lAbjqhWyRz7bl61s9R7npPJ87HVzAyvn",
"request_id": "GqA99rziFZduKYg"
}
recorded_at: Mon, 19 May 2025 17:24:09 GMT
recorded_with: VCR 6.3.1

View file

@ -0,0 +1,106 @@
---
http_interactions:
- request:
method: post
uri: https://sandbox.plaid.com/item/get
body:
encoding: UTF-8
string: '{"access_token":"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648"}'
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 17:24:03 GMT
Content-Type:
- application/json; charset=utf-8
Content-Length:
- '1050'
Connection:
- keep-alive
Plaid-Version:
- '2020-09-14'
Vary:
- Accept-Encoding
X-Envoy-Upstream-Service-Time:
- '157'
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",
"credit_details",
"identity",
"identity_match",
"income",
"income_verification",
"recurring_transactions",
"signal",
"statements"
],
"billed_products": [
"investments",
"liabilities",
"transactions"
],
"consent_expiration_time": null,
"created_at": "2025-05-19T17:24:00Z",
"error": null,
"institution_id": "ins_109508",
"institution_name": "First Platypus Bank",
"item_id": "n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP",
"products": [
"investments",
"liabilities",
"transactions"
],
"update_type": "background",
"webhook": ""
},
"request_id": "dpcY8geAZ93oxJm",
"status": {
"investments": {
"last_failed_update": null,
"last_successful_update": "2025-05-19T17:24:01.861Z"
},
"last_webhook": null,
"transactions": {
"last_failed_update": null,
"last_successful_update": null
}
}
}
recorded_at: Mon, 19 May 2025 17:24:03 GMT
recorded_with: VCR 6.3.1

View file

@ -0,0 +1,160 @@
---
http_interactions:
- request:
method: post
uri: https://sandbox.plaid.com/accounts/get
body:
encoding: UTF-8
string: '{"access_token":"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648"}'
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 17:24:04 GMT
Content-Type:
- application/json; charset=utf-8
Content-Length:
- '2578'
Connection:
- keep-alive
Plaid-Version:
- '2020-09-14'
Vary:
- Accept-Encoding
X-Envoy-Upstream-Service-Time:
- '191'
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": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
"balances": {
"available": 8000,
"current": 10000,
"iso_currency_code": "USD",
"limit": null,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "1122",
"name": "Test Brokerage Account",
"official_name": "Plaid brokerage",
"subtype": "brokerage",
"type": "investment"
},
{
"account_id": "RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp",
"balances": {
"available": 9372.38,
"current": 1000,
"iso_currency_code": "USD",
"limit": 10500,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "1219",
"name": "Test Credit Card Account",
"official_name": "Plaid credit card",
"subtype": "credit card",
"type": "credit"
},
{
"account_id": "9mvxVZRW7LUD67QbEBm1CPZ6XlqkmkF4oGNBo",
"balances": {
"available": 10000,
"current": 10000,
"iso_currency_code": "USD",
"limit": null,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "4243",
"name": "Test Depository Account",
"official_name": "Plaid checking",
"subtype": "checking",
"type": "depository"
},
{
"account_id": "6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7",
"balances": {
"available": 15000,
"current": 15000,
"iso_currency_code": "USD",
"limit": null,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "9572",
"name": "Test Student Loan Account",
"official_name": "Plaid student",
"subtype": "student",
"type": "loan"
}
],
"item": {
"available_products": [
"assets",
"auth",
"balance",
"credit_details",
"identity",
"identity_match",
"income",
"income_verification",
"recurring_transactions",
"signal",
"statements"
],
"billed_products": [
"investments",
"liabilities",
"transactions"
],
"consent_expiration_time": null,
"error": null,
"institution_id": "ins_109508",
"institution_name": "First Platypus Bank",
"item_id": "n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP",
"products": [
"investments",
"liabilities",
"transactions"
],
"update_type": "background",
"webhook": ""
},
"request_id": "EWD5MMMYV0o9cZ0"
}
recorded_at: Mon, 19 May 2025 17:24:04 GMT
recorded_with: VCR 6.3.1

View file

@ -0,0 +1,570 @@
---
http_interactions:
- request:
method: post
uri: https://sandbox.plaid.com/investments/holdings/get
body:
encoding: UTF-8
string: '{"access_token":"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648"}'
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 17:24:05 GMT
Content-Type:
- application/json; charset=utf-8
Content-Length:
- '6199'
Connection:
- keep-alive
Plaid-Version:
- '2020-09-14'
Vary:
- Accept-Encoding
X-Envoy-Upstream-Service-Time:
- '324'
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": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
"balances": {
"available": 8000,
"current": 10000,
"iso_currency_code": "USD",
"limit": null,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "1122",
"name": "Test Brokerage Account",
"official_name": "Plaid brokerage",
"subtype": "brokerage",
"type": "investment"
},
{
"account_id": "RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp",
"balances": {
"available": 9372.38,
"current": 1000,
"iso_currency_code": "USD",
"limit": 10500,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "1219",
"name": "Test Credit Card Account",
"official_name": "Plaid credit card",
"subtype": "credit card",
"type": "credit"
},
{
"account_id": "9mvxVZRW7LUD67QbEBm1CPZ6XlqkmkF4oGNBo",
"balances": {
"available": 10000,
"current": 10000,
"iso_currency_code": "USD",
"limit": null,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "4243",
"name": "Test Depository Account",
"official_name": "Plaid checking",
"subtype": "checking",
"type": "depository"
},
{
"account_id": "6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7",
"balances": {
"available": 15000,
"current": 15000,
"iso_currency_code": "USD",
"limit": null,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "9572",
"name": "Test Student Loan Account",
"official_name": "Plaid student",
"subtype": "student",
"type": "loan"
}
],
"holdings": [
{
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
"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": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
"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": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
"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",
"credit_details",
"identity",
"identity_match",
"income",
"income_verification",
"recurring_transactions",
"signal",
"statements"
],
"billed_products": [
"investments",
"liabilities",
"transactions"
],
"consent_expiration_time": null,
"error": null,
"institution_id": "ins_109508",
"institution_name": "First Platypus Bank",
"item_id": "n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP",
"products": [
"investments",
"liabilities",
"transactions"
],
"update_type": "background",
"webhook": ""
},
"request_id": "uRzq5c4Y37RCNNj",
"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 17:24:05 GMT
- request:
method: post
uri: https://sandbox.plaid.com/investments/transactions/get
body:
encoding: UTF-8
string: '{"access_token":"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648","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 17:24:05 GMT
Content-Type:
- application/json; charset=utf-8
Content-Length:
- '6964'
Connection:
- keep-alive
Plaid-Version:
- '2020-09-14'
Vary:
- Accept-Encoding
X-Envoy-Upstream-Service-Time:
- '334'
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": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
"balances": {
"available": 8000,
"current": 10000,
"iso_currency_code": "USD",
"limit": null,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "1122",
"name": "Test Brokerage Account",
"official_name": "Plaid brokerage",
"subtype": "brokerage",
"type": "investment"
},
{
"account_id": "RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp",
"balances": {
"available": 9372.38,
"current": 1000,
"iso_currency_code": "USD",
"limit": 10500,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "1219",
"name": "Test Credit Card Account",
"official_name": "Plaid credit card",
"subtype": "credit card",
"type": "credit"
},
{
"account_id": "9mvxVZRW7LUD67QbEBm1CPZ6XlqkmkF4oGNBo",
"balances": {
"available": 10000,
"current": 10000,
"iso_currency_code": "USD",
"limit": null,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "4243",
"name": "Test Depository Account",
"official_name": "Plaid checking",
"subtype": "checking",
"type": "depository"
},
{
"account_id": "6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7",
"balances": {
"available": 15000,
"current": 15000,
"iso_currency_code": "USD",
"limit": null,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "9572",
"name": "Test Student Loan Account",
"official_name": "Plaid student",
"subtype": "student",
"type": "loan"
}
],
"investment_transactions": [
{
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
"amount": -5000,
"cancel_transaction_id": null,
"date": "2025-05-03",
"fees": 0,
"investment_transaction_id": "eBqoazM4XkiXx5gZbmD7UKRZ3jE3ABUreq4R1",
"iso_currency_code": "USD",
"name": "retirement contribution",
"price": 1,
"quantity": -5000,
"security_id": "EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX",
"subtype": "contribution",
"type": "cash",
"unofficial_currency_code": null
},
{
"account_id": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
"amount": 5000,
"cancel_transaction_id": null,
"date": "2025-05-03",
"fees": 0,
"investment_transaction_id": "QLeKVkpQM4ck1qMRGp6PUPp7obKowGtwRN547",
"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": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
"amount": 2000,
"cancel_transaction_id": null,
"date": "2025-05-02",
"fees": 0,
"investment_transaction_id": "ZnxNgJEwM1ig5476JqZxUKeJLXNLnMUe9o6Al",
"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": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
"amount": -5000,
"cancel_transaction_id": null,
"date": "2025-05-01",
"fees": 0,
"investment_transaction_id": "MQ1Awmg943IKyWlQjRXgUqXrxD6xo3CLGjJw1",
"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",
"credit_details",
"identity",
"identity_match",
"income",
"income_verification",
"recurring_transactions",
"signal",
"statements"
],
"billed_products": [
"investments",
"liabilities",
"transactions"
],
"consent_expiration_time": null,
"error": null,
"institution_id": "ins_109508",
"institution_name": "First Platypus Bank",
"item_id": "n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP",
"products": [
"investments",
"liabilities",
"transactions"
],
"update_type": "background",
"webhook": ""
},
"request_id": "dTc49uKiBZWzxHS",
"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 17:24:05 GMT
recorded_with: VCR 6.3.1

View file

@ -0,0 +1,236 @@
---
http_interactions:
- request:
method: post
uri: https://sandbox.plaid.com/liabilities/get
body:
encoding: UTF-8
string: '{"access_token":"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648"}'
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 17:24:04 GMT
Content-Type:
- application/json; charset=utf-8
Content-Length:
- '4907'
Connection:
- keep-alive
Plaid-Version:
- '2020-09-14'
Vary:
- Accept-Encoding
X-Envoy-Upstream-Service-Time:
- '253'
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": "vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe",
"balances": {
"available": 8000,
"current": 10000,
"iso_currency_code": "USD",
"limit": null,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "1122",
"name": "Test Brokerage Account",
"official_name": "Plaid brokerage",
"subtype": "brokerage",
"type": "investment"
},
{
"account_id": "RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp",
"balances": {
"available": 9372.38,
"current": 1000,
"iso_currency_code": "USD",
"limit": 10500,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "1219",
"name": "Test Credit Card Account",
"official_name": "Plaid credit card",
"subtype": "credit card",
"type": "credit"
},
{
"account_id": "9mvxVZRW7LUD67QbEBm1CPZ6XlqkmkF4oGNBo",
"balances": {
"available": 10000,
"current": 10000,
"iso_currency_code": "USD",
"limit": null,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "4243",
"name": "Test Depository Account",
"official_name": "Plaid checking",
"subtype": "checking",
"type": "depository"
},
{
"account_id": "6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7",
"balances": {
"available": 15000,
"current": 15000,
"iso_currency_code": "USD",
"limit": null,
"unofficial_currency_code": null
},
"holder_category": "personal",
"mask": "9572",
"name": "Test Student Loan Account",
"official_name": "Plaid student",
"subtype": "student",
"type": "loan"
}
],
"item": {
"available_products": [
"assets",
"auth",
"balance",
"credit_details",
"identity",
"identity_match",
"income",
"income_verification",
"recurring_transactions",
"signal",
"statements"
],
"billed_products": [
"investments",
"liabilities",
"transactions"
],
"consent_expiration_time": null,
"error": null,
"institution_id": "ins_109508",
"institution_name": "First Platypus Bank",
"item_id": "n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP",
"products": [
"investments",
"liabilities",
"transactions"
],
"update_type": "background",
"webhook": ""
},
"liabilities": {
"credit": [
{
"account_id": "RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp",
"aprs": [
{
"apr_percentage": 12.5,
"apr_type": "purchase_apr",
"balance_subject_to_apr": null,
"interest_charge_amount": null
},
{
"apr_percentage": 27.95,
"apr_type": "cash_apr",
"balance_subject_to_apr": null,
"interest_charge_amount": null
}
],
"is_overdue": false,
"last_payment_amount": null,
"last_payment_date": "2025-04-24",
"last_statement_balance": 1000,
"last_statement_issue_date": "2025-05-19",
"minimum_payment_amount": 50,
"next_payment_due_date": "2025-06-19"
}
],
"mortgage": null,
"student": [
{
"account_id": "6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7",
"account_number": "3117529572",
"disbursement_dates": [
"2023-05-01"
],
"expected_payoff_date": "2036-05-01",
"guarantor": "DEPT OF ED",
"interest_rate_percentage": 5.25,
"is_overdue": false,
"last_payment_amount": null,
"last_payment_date": null,
"last_statement_balance": 16577.16,
"last_statement_issue_date": "2025-05-01",
"loan_name": "Consolidation",
"loan_status": {
"end_date": null,
"type": "in school"
},
"minimum_payment_amount": 25,
"next_payment_due_date": "2025-06-01",
"origination_date": "2023-05-01",
"origination_principal_amount": 15000,
"outstanding_interest_amount": 1577.16,
"payment_reference_number": "3117529572",
"pslf_status": {
"estimated_eligibility_date": null,
"payments_made": null,
"payments_remaining": null
},
"repayment_plan": {
"description": "Standard Repayment",
"type": "standard"
},
"sequence_number": "1",
"servicer_address": {
"city": "San Matias",
"country": "US",
"postal_code": "99415",
"region": "CA",
"street": "123 Relaxation Road"
},
"ytd_interest_paid": 0,
"ytd_principal_paid": 0
}
]
},
"request_id": "nFlL291sKIy1LkJ"
}
recorded_at: Mon, 19 May 2025 17:24:04 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 17:24:04 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:
- '70'
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-19T21:24:04Z",
"link_token": "link-sandbox-33432e02-32e2-415d-8f00-e626c6f4c6a6",
"request_id": "Gys5pGY7tIPDrlL"
}
recorded_at: Mon, 19 May 2025 17:24:04 GMT
recorded_with: VCR 6.3.1