diff --git a/app/views/trades/show.html.erb b/app/views/trades/show.html.erb
index 97c48e2d..26f467ad 100644
--- a/app/views/trades/show.html.erb
+++ b/app/views/trades/show.html.erb
@@ -15,20 +15,22 @@
<%= f.date_field :date,
label: t(".date_label"),
max: Date.current,
+ disabled: @entry.linked?,
"data-auto-submit-form-target": "auto" %>
<%= f.select :nature,
[["Buy", "outflow"], ["Sell", "inflow"]],
{ container_class: "w-1/3", label: "Type", selected: @entry.amount.negative? ? "inflow" : "outflow" },
- { data: { "auto-submit-form-target": "auto" } } %>
+ { data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? } %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.number_field :qty,
label: t(".quantity_label"),
step: "any",
value: trade.qty.abs,
- "data-auto-submit-form-target": "auto" %>
+ "data-auto-submit-form-target": "auto",
+ disabled: @entry.linked? %>
<% end %>
@@ -37,7 +39,8 @@
label: t(".cost_per_share_label"),
disable_currency: true,
auto_submit: true,
- min: 0 %>
+ min: 0,
+ disabled: @entry.linked? %>
<% end %>
<% end %>
diff --git a/app/views/transactions/_header.html.erb b/app/views/transactions/_header.html.erb
index 565892d5..6c7b1615 100644
--- a/app/views/transactions/_header.html.erb
+++ b/app/views/transactions/_header.html.erb
@@ -15,6 +15,12 @@
<% if entry.transaction.transfer? %>
<%= icon "arrow-left-right", class: "mt-1" %>
<% end %>
+
+ <% if entry.linked? %>
+
+ <%= icon("refresh-ccw", size: "sm") %>
+
+ <% end %>
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb
index 00d32d63..d2adc414 100644
--- a/app/views/transactions/show.html.erb
+++ b/app/views/transactions/show.html.erb
@@ -18,6 +18,7 @@
<%= f.date_field :date,
label: t(".date_label"),
max: Date.current,
+ disabled: @entry.linked?,
"data-auto-submit-form-target": "auto" %>
<% unless @entry.transaction.transfer? %>
@@ -25,13 +26,15 @@
<%= f.select :nature,
[["Expense", "outflow"], ["Income", "inflow"]],
{ container_class: "w-1/3", label: t(".nature"), selected: @entry.amount.negative? ? "inflow" : "outflow" },
- { data: { "auto-submit-form-target": "auto" } } %>
+ { data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? } %>
<%= f.money_field :amount, label: t(".amount"),
container_class: "w-2/3",
auto_submit: true,
min: 0,
- value: @entry.amount.abs %>
+ value: @entry.amount.abs,
+ disabled: @entry.linked?,
+ disable_currency: @entry.linked? %>
<%= f.fields_for :entryable do |ef| %>
@@ -66,7 +69,7 @@
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :merchant_id,
- Current.family.merchants.alphabetically,
+ [@entry.transaction.merchant, *Current.family.merchants.alphabetically].compact,
:id, :name,
{ include_blank: t(".none"),
label: t(".merchant_label"),
diff --git a/db/migrate/20250523131455_add_raw_payloads_to_plaid_accounts.rb b/db/migrate/20250523131455_add_raw_payloads_to_plaid_accounts.rb
new file mode 100644
index 00000000..981daef0
--- /dev/null
+++ b/db/migrate/20250523131455_add_raw_payloads_to_plaid_accounts.rb
@@ -0,0 +1,24 @@
+class AddRawPayloadsToPlaidAccounts < ActiveRecord::Migration[7.2]
+ def change
+ add_column :plaid_items, :raw_payload, :jsonb, default: {}
+ add_column :plaid_items, :raw_institution_payload, :jsonb, default: {}
+
+ change_column_null :plaid_items, :plaid_id, false
+ add_index :plaid_items, :plaid_id, unique: true
+
+ 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: {}
+
+ change_column_null :plaid_accounts, :plaid_id, false
+ change_column_null :plaid_accounts, :plaid_type, false
+ change_column_null :plaid_accounts, :currency, false
+ change_column_null :plaid_accounts, :name, false
+ add_index :plaid_accounts, :plaid_id, unique: true
+
+ # No longer need to store on transaction model because it is stored in raw_transactions_payload
+ remove_column :transactions, :plaid_category, :string
+ remove_column :transactions, :plaid_category_detailed, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 11612da4..b5f41eab 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2025_05_22_201031) do
+ActiveRecord::Schema[7.2].define(version: 2025_05_23_131455) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -420,23 +420,28 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_22_201031) do
create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "plaid_item_id", null: false
- t.string "plaid_id"
- t.string "plaid_type"
+ t.string "plaid_id", null: false
+ t.string "plaid_type", null: false
t.string "plaid_subtype"
t.decimal "current_balance", precision: 19, scale: 4
t.decimal "available_balance", precision: 19, scale: 4
- t.string "currency"
- t.string "name"
+ t.string "currency", null: false
+ t.string "name", null: false
t.string "mask"
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_id"], name: "index_plaid_accounts_on_plaid_id", unique: true
t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id"
end
create_table "plaid_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false
t.string "access_token"
- t.string "plaid_id"
+ t.string "plaid_id", null: false
t.string "name"
t.string "next_cursor"
t.boolean "scheduled_for_deletion", default: false
@@ -449,7 +454,10 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_22_201031) do
t.string "institution_id"
t.string "institution_color"
t.string "status", default: "good", null: false
+ t.jsonb "raw_payload", default: {}
+ t.jsonb "raw_institution_payload", default: {}
t.index ["family_id"], name: "index_plaid_items_on_family_id"
+ t.index ["plaid_id"], name: "index_plaid_items_on_plaid_id", unique: true
end
create_table "properties", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -637,8 +645,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_22_201031) do
t.uuid "category_id"
t.uuid "merchant_id"
t.jsonb "locked_attributes", default: {}
- t.string "plaid_category"
- t.string "plaid_category_detailed"
t.index ["category_id"], name: "index_transactions_on_category_id"
t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
end
diff --git a/test/controllers/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb
index 4cf8e10a..f04f9c4d 100644
--- a/test/controllers/plaid_items_controller_test.rb
+++ b/test/controllers/plaid_items_controller_test.rb
@@ -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"
diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml
index 810dbf41..ec8668e5 100644
--- a/test/fixtures/accounts.yml
+++ b/test/fixtures/accounts.yml
@@ -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
diff --git a/test/fixtures/plaid_accounts.yml b/test/fixtures/plaid_accounts.yml
index 2a911104..3679e767 100644
--- a/test/fixtures/plaid_accounts.yml
+++ b/test/fixtures/plaid_accounts.yml
@@ -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
\ No newline at end of file
diff --git a/test/fixtures/plaid_items.yml b/test/fixtures/plaid_items.yml
index 21a0b460..03e7cdfb 100644
--- a/test/fixtures/plaid_items.yml
+++ b/test/fixtures/plaid_items.yml
@@ -1,5 +1,7 @@
one:
family: dylan_family
- plaid_id: "1234567890"
+ plaid_id: "item_mock_1"
access_token: encrypted_token_1
- name: "Test Bank"
\ No newline at end of file
+ name: "Test Bank"
+ billed_products: ["transactions", "investments", "liabilities"]
+ available_products: []
\ No newline at end of file
diff --git a/test/models/plaid_account/importer_test.rb b/test/models/plaid_account/importer_test.rb
new file mode 100644
index 00000000..68f61041
--- /dev/null
+++ b/test/models/plaid_account/importer_test.rb
@@ -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
diff --git a/test/models/plaid_account/investments/balance_calculator_test.rb b/test/models/plaid_account/investments/balance_calculator_test.rb
new file mode 100644
index 00000000..c4cd5d10
--- /dev/null
+++ b/test/models/plaid_account/investments/balance_calculator_test.rb
@@ -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
diff --git a/test/models/plaid_account/investments/holdings_processor_test.rb b/test/models/plaid_account/investments/holdings_processor_test.rb
new file mode 100644
index 00000000..ac5b5895
--- /dev/null
+++ b/test/models/plaid_account/investments/holdings_processor_test.rb
@@ -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
diff --git a/test/models/plaid_account/investments/security_resolver_test.rb b/test/models/plaid_account/investments/security_resolver_test.rb
new file mode 100644
index 00000000..a32430c6
--- /dev/null
+++ b/test/models/plaid_account/investments/security_resolver_test.rb
@@ -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
diff --git a/test/models/plaid_account/investments/transactions_processor_test.rb b/test/models/plaid_account/investments/transactions_processor_test.rb
new file mode 100644
index 00000000..8a0c9efd
--- /dev/null
+++ b/test/models/plaid_account/investments/transactions_processor_test.rb
@@ -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
diff --git a/test/models/plaid_account/liabilities/credit_processor_test.rb b/test/models/plaid_account/liabilities/credit_processor_test.rb
new file mode 100644
index 00000000..d51e79db
--- /dev/null
+++ b/test/models/plaid_account/liabilities/credit_processor_test.rb
@@ -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
diff --git a/test/models/plaid_account/liabilities/mortgage_processor_test.rb b/test/models/plaid_account/liabilities/mortgage_processor_test.rb
new file mode 100644
index 00000000..feb9ce8c
--- /dev/null
+++ b/test/models/plaid_account/liabilities/mortgage_processor_test.rb
@@ -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
diff --git a/test/models/plaid_account/liabilities/student_loan_processor_test.rb b/test/models/plaid_account/liabilities/student_loan_processor_test.rb
new file mode 100644
index 00000000..40fa5b23
--- /dev/null
+++ b/test/models/plaid_account/liabilities/student_loan_processor_test.rb
@@ -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
diff --git a/test/models/plaid_account/processor_test.rb b/test/models/plaid_account/processor_test.rb
new file mode 100644
index 00000000..ec75296d
--- /dev/null
+++ b/test/models/plaid_account/processor_test.rb
@@ -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
diff --git a/test/models/provider/plaid/category_alias_matcher_test.rb b/test/models/plaid_account/transactions/category_matcher_test.rb
similarity index 98%
rename from test/models/provider/plaid/category_alias_matcher_test.rb
rename to test/models/plaid_account/transactions/category_matcher_test.rb
index 11881dea..35bcf8fe 100644
--- a/test/models/provider/plaid/category_alias_matcher_test.rb
+++ b/test/models/plaid_account/transactions/category_matcher_test.rb
@@ -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
diff --git a/test/models/plaid_account/transactions/processor_test.rb b/test/models/plaid_account/transactions/processor_test.rb
new file mode 100644
index 00000000..85272b11
--- /dev/null
+++ b/test/models/plaid_account/transactions/processor_test.rb
@@ -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
diff --git a/test/models/plaid_account/type_mappable_test.rb b/test/models/plaid_account/type_mappable_test.rb
new file mode 100644
index 00000000..b3bc6708
--- /dev/null
+++ b/test/models/plaid_account/type_mappable_test.rb
@@ -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
diff --git a/test/models/plaid_entry/processor_test.rb b/test/models/plaid_entry/processor_test.rb
new file mode 100644
index 00000000..bce448b0
--- /dev/null
+++ b/test/models/plaid_entry/processor_test.rb
@@ -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
diff --git a/test/models/plaid_investment_sync_test.rb b/test/models/plaid_investment_sync_test.rb
deleted file mode 100644
index f7a3e4e1..00000000
--- a/test/models/plaid_investment_sync_test.rb
+++ /dev/null
@@ -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
diff --git a/test/models/plaid_item/importer_test.rb b/test/models/plaid_item/importer_test.rb
new file mode 100644
index 00000000..085517c9
--- /dev/null
+++ b/test/models/plaid_item/importer_test.rb
@@ -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
diff --git a/test/models/plaid_item_test.rb b/test/models/plaid_item_test.rb
index 8d680707..c9c80059 100644
--- a/test/models/plaid_item_test.rb
+++ b/test/models/plaid_item_test.rb
@@ -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
diff --git a/test/models/provider/plaid_test.rb b/test/models/provider/plaid_test.rb
new file mode 100644
index 00000000..25fce87b
--- /dev/null
+++ b/test/models/provider/plaid_test.rb
@@ -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
diff --git a/test/support/plaid_mock.rb b/test/support/plaid_mock.rb
new file mode 100644
index 00000000..eddc54b8
--- /dev/null
+++ b/test/support/plaid_mock.rb
@@ -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
diff --git a/test/support/plaid_test_helper.rb b/test/support/plaid_test_helper.rb
deleted file mode 100644
index b732bb97..00000000
--- a/test/support/plaid_test_helper.rb
+++ /dev/null
@@ -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
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 7eac9dde..65f5db35 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -29,6 +29,8 @@ VCR.configure do |config|
config.filter_sensitive_data("") { ENV["OPENAI_ORGANIZATION_ID"] }
config.filter_sensitive_data("") { ENV["STRIPE_SECRET_KEY"] }
config.filter_sensitive_data("") { ENV["STRIPE_WEBHOOK_SECRET"] }
+ config.filter_sensitive_data("") { ENV["PLAID_CLIENT_ID"] }
+ config.filter_sensitive_data("") { ENV["PLAID_SECRET"] }
end
module ActiveSupport
diff --git a/test/vcr_cassettes/plaid/access_token.yml b/test/vcr_cassettes/plaid/access_token.yml
new file mode 100644
index 00000000..5855687b
--- /dev/null
+++ b/test/vcr_cassettes/plaid/access_token.yml
@@ -0,0 +1,124 @@
+---
+http_interactions:
+- request:
+ method: post
+ uri: https://sandbox.plaid.com/sandbox/public_token/create
+ body:
+ encoding: UTF-8
+ string: '{"institution_id":"ins_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-Version:
+ - '2020-09-14'
+ Plaid-Secret:
+ - ""
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx
+ Date:
+ - Mon, 19 May 2025 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-Version:
+ - '2020-09-14'
+ Plaid-Secret:
+ - ""
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx
+ Date:
+ - Mon, 19 May 2025 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
diff --git a/test/vcr_cassettes/plaid/exchange_public_token.yml b/test/vcr_cassettes/plaid/exchange_public_token.yml
new file mode 100644
index 00000000..f5e9047c
--- /dev/null
+++ b/test/vcr_cassettes/plaid/exchange_public_token.yml
@@ -0,0 +1,124 @@
+---
+http_interactions:
+- request:
+ method: post
+ uri: https://sandbox.plaid.com/sandbox/public_token/create
+ body:
+ encoding: UTF-8
+ string: '{"institution_id":"ins_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-Version:
+ - '2020-09-14'
+ Plaid-Secret:
+ - ""
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx
+ Date:
+ - Mon, 19 May 2025 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-Version:
+ - '2020-09-14'
+ Plaid-Secret:
+ - ""
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx
+ Date:
+ - Mon, 19 May 2025 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
diff --git a/test/vcr_cassettes/plaid/get_item.yml b/test/vcr_cassettes/plaid/get_item.yml
new file mode 100644
index 00000000..eae6bc32
--- /dev/null
+++ b/test/vcr_cassettes/plaid/get_item.yml
@@ -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-Version:
+ - '2020-09-14'
+ Plaid-Secret:
+ - ""
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx
+ Date:
+ - Mon, 19 May 2025 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
diff --git a/test/vcr_cassettes/plaid/get_item_accounts.yml b/test/vcr_cassettes/plaid/get_item_accounts.yml
new file mode 100644
index 00000000..8594dc7a
--- /dev/null
+++ b/test/vcr_cassettes/plaid/get_item_accounts.yml
@@ -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-Version:
+ - '2020-09-14'
+ Plaid-Secret:
+ - ""
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx
+ Date:
+ - Mon, 19 May 2025 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
diff --git a/test/vcr_cassettes/plaid/get_item_investments.yml b/test/vcr_cassettes/plaid/get_item_investments.yml
new file mode 100644
index 00000000..c1703a86
--- /dev/null
+++ b/test/vcr_cassettes/plaid/get_item_investments.yml
@@ -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-Version:
+ - '2020-09-14'
+ Plaid-Secret:
+ - ""
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx
+ Date:
+ - Mon, 19 May 2025 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-Version:
+ - '2020-09-14'
+ Plaid-Secret:
+ - ""
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx
+ Date:
+ - Mon, 19 May 2025 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
diff --git a/test/vcr_cassettes/plaid/get_item_liabilities.yml b/test/vcr_cassettes/plaid/get_item_liabilities.yml
new file mode 100644
index 00000000..933c126d
--- /dev/null
+++ b/test/vcr_cassettes/plaid/get_item_liabilities.yml
@@ -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-Version:
+ - '2020-09-14'
+ Plaid-Secret:
+ - ""
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx
+ Date:
+ - Mon, 19 May 2025 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
diff --git a/test/vcr_cassettes/plaid/link_token.yml b/test/vcr_cassettes/plaid/link_token.yml
new file mode 100644
index 00000000..4b7fcf96
--- /dev/null
+++ b/test/vcr_cassettes/plaid/link_token.yml
@@ -0,0 +1,64 @@
+---
+http_interactions:
+- request:
+ method: post
+ uri: https://sandbox.plaid.com/link/token/create
+ body:
+ encoding: UTF-8
+ string: '{"client_name":"Maybe Finance","language":"en","country_codes":["US","CA"],"user":{"client_user_id":"test-user-id"},"products":["transactions"],"additional_consented_products":["investments","liabilities"],"webhook":"https://example.com/webhooks","redirect_uri":"http://localhost:3000/accounts","transactions":{"days_requested":730}}'
+ headers:
+ Content-Type:
+ - application/json
+ User-Agent:
+ - Plaid Ruby v38.0.0
+ Accept:
+ - application/json
+ Plaid-Client-Id:
+ - ""
+ Plaid-Version:
+ - '2020-09-14'
+ Plaid-Secret:
+ - ""
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx
+ Date:
+ - Mon, 19 May 2025 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