From 03a146222d8aa48c4612b9bd2f7ddd7ae47d7e66 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 23 May 2025 18:58:22 -0400 Subject: [PATCH] Plaid sync domain improvements (#2267) 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 --- .github/workflows/ci.yml | 2 + app/controllers/webhooks_controller.rb | 4 +- app/models/concerns/enrichable.rb | 15 +- app/models/credit_card.rb | 4 + app/models/depository.rb | 5 +- app/models/entry.rb | 4 + app/models/family/auto_categorizer.rb | 3 +- app/models/family/plaid_connectable.rb | 22 +- app/models/investment.rb | 3 +- app/models/loan.rb | 7 + app/models/plaid_account.rb | 177 +----- app/models/plaid_account/importer.rb | 34 ++ .../investments/balance_calculator.rb | 71 +++ .../investments/holdings_processor.rb | 39 ++ .../investments/security_resolver.rb | 93 +++ .../investments/transactions_processor.rb | 90 +++ .../liabilities/credit_processor.rb | 25 + .../liabilities/mortgage_processor.rb | 25 + .../liabilities/student_loan_processor.rb | 50 ++ app/models/plaid_account/processor.rb | 99 +++ .../transactions/category_matcher.rb} | 6 +- .../transactions}/category_taxonomy.rb | 2 +- .../plaid_account/transactions/processor.rb | 60 ++ app/models/plaid_account/type_mappable.rb | 77 +++ app/models/plaid_entry/processor.rb | 95 +++ app/models/plaid_investment_sync.rb | 115 ---- app/models/plaid_item.rb | 89 ++- app/models/plaid_item/accounts_snapshot.rb | 79 +++ app/models/plaid_item/importer.rb | 53 ++ app/models/plaid_item/provided.rb | 7 + app/models/plaid_item/syncer.rb | 145 +---- app/models/provider/plaid.rb | 28 +- app/models/provider/plaid_sandbox.rb | 13 + app/models/provider/registry.rb | 4 + app/views/shared/_money_field.html.erb | 1 + app/views/trades/_header.html.erb | 6 + app/views/trades/show.html.erb | 9 +- app/views/transactions/_header.html.erb | 6 + app/views/transactions/show.html.erb | 9 +- ...1455_add_raw_payloads_to_plaid_accounts.rb | 24 + db/schema.rb | 22 +- .../plaid_items_controller_test.rb | 2 +- test/fixtures/accounts.yml | 3 +- test/fixtures/plaid_accounts.yml | 8 +- test/fixtures/plaid_items.yml | 6 +- test/models/plaid_account/importer_test.rb | 35 ++ .../investments/balance_calculator_test.rb | 83 +++ .../investments/holdings_processor_test.rb | 49 ++ .../investments/security_resolver_test.rb | 115 ++++ .../transactions_processor_test.rb | 111 ++++ .../liabilities/credit_processor_test.rb | 39 ++ .../liabilities/mortgage_processor_test.rb | 44 ++ .../student_loan_processor_test.rb | 68 +++ test/models/plaid_account/processor_test.rb | 172 ++++++ .../transactions/category_matcher_test.rb} | 4 +- .../transactions/processor_test.rb | 63 ++ .../plaid_account/type_mappable_test.rb | 35 ++ test/models/plaid_entry/processor_test.rb | 91 +++ test/models/plaid_investment_sync_test.rb | 82 --- test/models/plaid_item/importer_test.rb | 23 + test/models/plaid_item_test.rb | 6 +- test/models/provider/plaid_test.rb | 80 +++ test/support/plaid_mock.rb | 214 +++++++ test/support/plaid_test_helper.rb | 128 ---- test/test_helper.rb | 2 + test/vcr_cassettes/plaid/access_token.yml | 124 ++++ .../plaid/exchange_public_token.yml | 124 ++++ test/vcr_cassettes/plaid/get_item.yml | 106 ++++ .../vcr_cassettes/plaid/get_item_accounts.yml | 160 +++++ .../plaid/get_item_investments.yml | 570 ++++++++++++++++++ .../plaid/get_item_liabilities.yml | 236 ++++++++ test/vcr_cassettes/plaid/link_token.yml | 64 ++ 72 files changed, 3763 insertions(+), 706 deletions(-) create mode 100644 app/models/plaid_account/importer.rb create mode 100644 app/models/plaid_account/investments/balance_calculator.rb create mode 100644 app/models/plaid_account/investments/holdings_processor.rb create mode 100644 app/models/plaid_account/investments/security_resolver.rb create mode 100644 app/models/plaid_account/investments/transactions_processor.rb create mode 100644 app/models/plaid_account/liabilities/credit_processor.rb create mode 100644 app/models/plaid_account/liabilities/mortgage_processor.rb create mode 100644 app/models/plaid_account/liabilities/student_loan_processor.rb create mode 100644 app/models/plaid_account/processor.rb rename app/models/{provider/plaid/category_alias_matcher.rb => plaid_account/transactions/category_matcher.rb} (96%) rename app/models/{provider/plaid => plaid_account/transactions}/category_taxonomy.rb (99%) create mode 100644 app/models/plaid_account/transactions/processor.rb create mode 100644 app/models/plaid_account/type_mappable.rb create mode 100644 app/models/plaid_entry/processor.rb delete mode 100644 app/models/plaid_investment_sync.rb create mode 100644 app/models/plaid_item/accounts_snapshot.rb create mode 100644 app/models/plaid_item/importer.rb create mode 100644 app/models/plaid_item/provided.rb create mode 100644 db/migrate/20250523131455_add_raw_payloads_to_plaid_accounts.rb create mode 100644 test/models/plaid_account/importer_test.rb create mode 100644 test/models/plaid_account/investments/balance_calculator_test.rb create mode 100644 test/models/plaid_account/investments/holdings_processor_test.rb create mode 100644 test/models/plaid_account/investments/security_resolver_test.rb create mode 100644 test/models/plaid_account/investments/transactions_processor_test.rb create mode 100644 test/models/plaid_account/liabilities/credit_processor_test.rb create mode 100644 test/models/plaid_account/liabilities/mortgage_processor_test.rb create mode 100644 test/models/plaid_account/liabilities/student_loan_processor_test.rb create mode 100644 test/models/plaid_account/processor_test.rb rename test/models/{provider/plaid/category_alias_matcher_test.rb => plaid_account/transactions/category_matcher_test.rb} (98%) create mode 100644 test/models/plaid_account/transactions/processor_test.rb create mode 100644 test/models/plaid_account/type_mappable_test.rb create mode 100644 test/models/plaid_entry/processor_test.rb delete mode 100644 test/models/plaid_investment_sync_test.rb create mode 100644 test/models/plaid_item/importer_test.rb create mode 100644 test/models/provider/plaid_test.rb create mode 100644 test/support/plaid_mock.rb delete mode 100644 test/support/plaid_test_helper.rb create mode 100644 test/vcr_cassettes/plaid/access_token.yml create mode 100644 test/vcr_cassettes/plaid/exchange_public_token.yml create mode 100644 test/vcr_cassettes/plaid/get_item.yml create mode 100644 test/vcr_cassettes/plaid/get_item_accounts.yml create mode 100644 test/vcr_cassettes/plaid/get_item_investments.yml create mode 100644 test/vcr_cassettes/plaid/get_item_liabilities.yml create mode 100644 test/vcr_cassettes/plaid/link_token.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6551335b..2306ec69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,8 @@ jobs: timeout-minutes: 10 env: + PLAID_CLIENT_ID: foo + PLAID_SECRET: bar DATABASE_URL: postgres://postgres:postgres@localhost:5432 RAILS_ENV: test diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index ff1ae08c..1ef75db6 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -6,7 +6,7 @@ class WebhooksController < ApplicationController webhook_body = request.body.read plaid_verification_header = request.headers["Plaid-Verification"] - client = Provider::Plaid.new(Rails.application.config.plaid, region: :us) + client = Provider::Registry.plaid_provider_for_region(:us) client.validate_webhook!(plaid_verification_header, webhook_body) client.process_webhook(webhook_body) @@ -21,7 +21,7 @@ class WebhooksController < ApplicationController webhook_body = request.body.read plaid_verification_header = request.headers["Plaid-Verification"] - client = Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu) + client = Provider::Registry.plaid_provider_for_region(:eu) client.validate_webhook!(plaid_verification_header, webhook_body) client.process_webhook(webhook_body) diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb index 4c373b01..be813066 100644 --- a/app/models/concerns/enrichable.rb +++ b/app/models/concerns/enrichable.rb @@ -27,14 +27,23 @@ module Enrichable enrich_attributes({ attr => value }, source:, metadata:) end - # Enriches all attributes that haven't been locked yet + # Enriches and logs all attributes that: + # - Are not locked + # - Are not ignored + # - Have changed value from the last saved value def enrich_attributes(attrs, source:, metadata: {}) - enrichable_attrs = Array(attrs).reject { |k, _v| locked?(k) } + enrichable_attrs = Array(attrs).reject do |attr_key, attr_value| + locked?(attr_key) || ignored_enrichable_attributes.include?(attr_key) || self[attr_key.to_s] == attr_value + end ActiveRecord::Base.transaction do enrichable_attrs.each do |attr, value| self.send("#{attr}=", value) - log_enrichment(attribute_name: attr, attribute_value: value, source: source, metadata: metadata) + + # If it's a new record, this isn't technically an "enrichment". No logging necessary. + unless self.new_record? + log_enrichment(attribute_name: attr, attribute_value: value, source: source, metadata: metadata) + end end save diff --git a/app/models/credit_card.rb b/app/models/credit_card.rb index fa621546..05bf7746 100644 --- a/app/models/credit_card.rb +++ b/app/models/credit_card.rb @@ -1,6 +1,10 @@ class CreditCard < ApplicationRecord include Accountable + SUBTYPES = { + "credit_card" => { short: "Credit Card", long: "Credit Card" } + }.freeze + class << self def color "#F13636" diff --git a/app/models/depository.rb b/app/models/depository.rb index 577a061c..b788a6d4 100644 --- a/app/models/depository.rb +++ b/app/models/depository.rb @@ -3,7 +3,10 @@ class Depository < ApplicationRecord SUBTYPES = { "checking" => { short: "Checking", long: "Checking" }, - "savings" => { short: "Savings", long: "Savings" } + "savings" => { short: "Savings", long: "Savings" }, + "hsa" => { short: "HSA", long: "Health Savings Account" }, + "cd" => { short: "CD", long: "Certificate of Deposit" }, + "money_market" => { short: "MM", long: "Money Market" } }.freeze class << self diff --git a/app/models/entry.rb b/app/models/entry.rb index 5b14987a..0426c9f5 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -56,6 +56,10 @@ class Entry < ApplicationRecord Balance::TrendCalculator.new(self, entries, balances).trend end + def linked? + plaid_id.present? + end + class << self def search(params) EntrySearch.new(params).build_query(all) diff --git a/app/models/family/auto_categorizer.rb b/app/models/family/auto_categorizer.rb index 25fde493..1ac8b874 100644 --- a/app/models/family/auto_categorizer.rb +++ b/app/models/family/auto_categorizer.rb @@ -70,8 +70,7 @@ class Family::AutoCategorizer amount: transaction.entry.amount.abs, classification: transaction.entry.classification, description: transaction.entry.name, - merchant: transaction.merchant&.name, - hint: transaction.plaid_category_detailed + merchant: transaction.merchant&.name } end end diff --git a/app/models/family/plaid_connectable.rb b/app/models/family/plaid_connectable.rb index f2a997c8..6eb432ba 100644 --- a/app/models/family/plaid_connectable.rb +++ b/app/models/family/plaid_connectable.rb @@ -6,9 +6,7 @@ module Family::PlaidConnectable end def create_plaid_item!(public_token:, item_name:, region:) - provider = plaid_provider_for_region(region) - - public_token_response = provider.exchange_public_token(public_token) + public_token_response = plaid(region).exchange_public_token(public_token) plaid_item = plaid_items.create!( name: item_name, @@ -23,11 +21,9 @@ module Family::PlaidConnectable end def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil) - return nil unless plaid_us || plaid_eu + return nil unless plaid(region) - provider = plaid_provider_for_region(region) - - provider.get_link_token( + plaid(region).get_link_token( user_id: self.id, webhooks_url: webhooks_url, redirect_url: redirect_url, @@ -37,15 +33,7 @@ module Family::PlaidConnectable end private - def plaid_us - @plaid ||= Provider::Registry.get_provider(:plaid_us) - end - - def plaid_eu - @plaid_eu ||= Provider::Registry.get_provider(:plaid_eu) - end - - def plaid_provider_for_region(region) - region.to_sym == :eu ? plaid_eu : plaid_us + def plaid(region) + @plaid ||= Provider::Registry.plaid_provider_for_region(region) end end diff --git a/app/models/investment.rb b/app/models/investment.rb index 6b2518ce..4e4c25c8 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -6,12 +6,11 @@ class Investment < ApplicationRecord "pension" => { short: "Pension", long: "Pension" }, "retirement" => { short: "Retirement", long: "Retirement" }, "401k" => { short: "401(k)", long: "401(k)" }, - "traditional_401k" => { short: "Traditional 401(k)", long: "Traditional 401(k)" }, "roth_401k" => { short: "Roth 401(k)", long: "Roth 401(k)" }, "529_plan" => { short: "529 Plan", long: "529 Plan" }, "hsa" => { short: "HSA", long: "Health Savings Account" }, "mutual_fund" => { short: "Mutual Fund", long: "Mutual Fund" }, - "traditional_ira" => { short: "Traditional IRA", long: "Traditional IRA" }, + "ira" => { short: "IRA", long: "Traditional IRA" }, "roth_ira" => { short: "Roth IRA", long: "Roth IRA" }, "angel" => { short: "Angel", long: "Angel" } }.freeze diff --git a/app/models/loan.rb b/app/models/loan.rb index 283e112e..5a206e7a 100644 --- a/app/models/loan.rb +++ b/app/models/loan.rb @@ -1,6 +1,13 @@ class Loan < ApplicationRecord include Accountable + SUBTYPES = { + "mortgage" => { short: "Mortgage", long: "Mortgage" }, + "student" => { short: "Student", long: "Student Loan" }, + "auto" => { short: "Auto", long: "Auto Loan" }, + "other" => { short: "Other", long: "Other Loan" } + }.freeze + def monthly_payment return nil if term_months.nil? || interest_rate.nil? || rate_type.nil? || rate_type != "fixed" return Money.new(0, account.currency) if account.loan.original_balance.amount.zero? || term_months.zero? diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 4730985d..949167ce 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -1,167 +1,54 @@ class PlaidAccount < ApplicationRecord - TYPE_MAPPING = { - "depository" => Depository, - "credit" => CreditCard, - "loan" => Loan, - "investment" => Investment, - "other" => OtherAsset - } - belongs_to :plaid_item has_one :account, dependent: :destroy - accepts_nested_attributes_for :account + validates :name, :plaid_type, :currency, presence: true + validate :has_balance - class << self - def find_or_create_from_plaid_data!(plaid_data, family) - PlaidAccount.transaction do - plaid_account = find_or_create_by!(plaid_id: plaid_data.account_id) - - internal_account = family.accounts.find_or_initialize_by(plaid_account_id: plaid_account.id) - - # Only set the name for new records or if the name is not locked - if internal_account.new_record? || internal_account.enrichable?(:name) - internal_account.name = plaid_data.name - end - internal_account.balance = plaid_data.balances.current || plaid_data.balances.available - internal_account.currency = plaid_data.balances.iso_currency_code - internal_account.accountable = TYPE_MAPPING[plaid_data.type].new - - internal_account.save! - plaid_account.save! - - plaid_account - end - end - end - - def sync_account_data!(plaid_account_data) - update!( - current_balance: plaid_account_data.balances.current, - available_balance: plaid_account_data.balances.available, - currency: plaid_account_data.balances.iso_currency_code, - plaid_type: plaid_account_data.type, - plaid_subtype: plaid_account_data.subtype, - account_attributes: { - id: account.id, - # Plaid guarantees at least 1 of these - balance: plaid_account_data.balances.current || plaid_account_data.balances.available, - cash_balance: derive_plaid_cash_balance(plaid_account_data.balances) - } + def upsert_plaid_snapshot!(account_snapshot) + assign_attributes( + current_balance: account_snapshot.balances.current, + available_balance: account_snapshot.balances.available, + currency: account_snapshot.balances.iso_currency_code, + plaid_type: account_snapshot.type, + plaid_subtype: account_snapshot.subtype, + name: account_snapshot.name, + mask: account_snapshot.mask, + raw_payload: account_snapshot ) + + save! end - def sync_investments!(transactions:, holdings:, securities:) - PlaidInvestmentSync.new(self).sync!(transactions:, holdings:, securities:) - end - - def sync_credit_data!(plaid_credit_data) - account.update!( - accountable_attributes: { - id: account.accountable_id, - minimum_payment: plaid_credit_data.minimum_payment_amount, - apr: plaid_credit_data.aprs.first&.apr_percentage - } + def upsert_plaid_transactions_snapshot!(transactions_snapshot) + assign_attributes( + raw_transactions_payload: transactions_snapshot ) + + save! end - def sync_mortgage_data!(plaid_mortgage_data) - create_initial_loan_balance(plaid_mortgage_data) - - account.update!( - accountable_attributes: { - id: account.accountable_id, - rate_type: plaid_mortgage_data.interest_rate&.type, - interest_rate: plaid_mortgage_data.interest_rate&.percentage - } + def upsert_plaid_investments_snapshot!(investments_snapshot) + assign_attributes( + raw_investments_payload: investments_snapshot ) + + save! end - def sync_student_loan_data!(plaid_student_loan_data) - create_initial_loan_balance(plaid_student_loan_data) - - account.update!( - accountable_attributes: { - id: account.accountable_id, - rate_type: "fixed", - interest_rate: plaid_student_loan_data.interest_rate_percentage - } + def upsert_plaid_liabilities_snapshot!(liabilities_snapshot) + assign_attributes( + raw_liabilities_payload: liabilities_snapshot ) - end - def sync_transactions!(added:, modified:, removed:) - added.each do |plaid_txn| - account.entries.find_or_create_by!(plaid_id: plaid_txn.transaction_id) do |t| - t.name = plaid_txn.merchant_name || plaid_txn.original_description - t.amount = plaid_txn.amount - t.currency = plaid_txn.iso_currency_code - t.date = plaid_txn.date - t.entryable = Transaction.new( - plaid_category: plaid_txn.personal_finance_category.primary, - plaid_category_detailed: plaid_txn.personal_finance_category.detailed, - merchant: find_or_create_merchant(plaid_txn) - ) - end - end - - modified.each do |plaid_txn| - existing_txn = account.entries.find_by(plaid_id: plaid_txn.transaction_id) - - existing_txn.update!( - amount: plaid_txn.amount, - date: plaid_txn.date, - entryable_attributes: { - plaid_category: plaid_txn.personal_finance_category.primary, - plaid_category_detailed: plaid_txn.personal_finance_category.detailed, - merchant: find_or_create_merchant(plaid_txn) - } - ) - end - - removed.each do |plaid_txn| - account.entries.find_by(plaid_id: plaid_txn.transaction_id)&.destroy - end + save! end private - def family - plaid_item.family - end - - def create_initial_loan_balance(loan_data) - if loan_data.origination_principal_amount.present? && loan_data.origination_date.present? - account.entries.find_or_create_by!(plaid_id: loan_data.account_id) do |e| - e.name = "Initial Principal" - e.amount = loan_data.origination_principal_amount - e.currency = account.currency - e.date = loan_data.origination_date - e.entryable = Valuation.new - end - end - end - - def find_or_create_merchant(plaid_txn) - unless plaid_txn.merchant_entity_id.present? && plaid_txn.merchant_name.present? - return nil - end - - ProviderMerchant.find_or_create_by!( - source: "plaid", - name: plaid_txn.merchant_name, - ) do |m| - m.provider_merchant_id = plaid_txn.merchant_entity_id - m.website_url = plaid_txn.website - m.logo_url = plaid_txn.logo_url - end - end - - def derive_plaid_cash_balance(plaid_balances) - if account.investment? - plaid_balances.available || 0 - else - # For now, we will not distinguish between "cash" and "overall" balance for non-investment accounts - plaid_balances.current || plaid_balances.available - end + # Plaid guarantees at least one of these. This validation is a sanity check for that guarantee. + def has_balance + return if current_balance.present? || available_balance.present? + errors.add(:base, "Plaid account must have either current or available balance") end end diff --git a/app/models/plaid_account/importer.rb b/app/models/plaid_account/importer.rb new file mode 100644 index 00000000..1306278f --- /dev/null +++ b/app/models/plaid_account/importer.rb @@ -0,0 +1,34 @@ +class PlaidAccount::Importer + def initialize(plaid_account, account_snapshot:) + @plaid_account = plaid_account + @account_snapshot = account_snapshot + end + + def import + PlaidAccount.transaction do + import_account_info + import_transactions if account_snapshot.transactions_data.present? + import_investments if account_snapshot.investments_data.present? + import_liabilities if account_snapshot.liabilities_data.present? + end + end + + private + attr_reader :plaid_account, :account_snapshot + + def import_account_info + plaid_account.upsert_plaid_snapshot!(account_snapshot.account_data) + end + + def import_transactions + plaid_account.upsert_plaid_transactions_snapshot!(account_snapshot.transactions_data) + end + + def import_investments + plaid_account.upsert_plaid_investments_snapshot!(account_snapshot.investments_data) + end + + def import_liabilities + plaid_account.upsert_plaid_liabilities_snapshot!(account_snapshot.liabilities_data) + end +end diff --git a/app/models/plaid_account/investments/balance_calculator.rb b/app/models/plaid_account/investments/balance_calculator.rb new file mode 100644 index 00000000..ba713c19 --- /dev/null +++ b/app/models/plaid_account/investments/balance_calculator.rb @@ -0,0 +1,71 @@ +# Plaid Investment balances have a ton of edge cases. This processor is responsible +# for deriving "brokerage cash" vs. "total value" based on Plaid's reported balances and holdings. +class PlaidAccount::Investments::BalanceCalculator + NegativeCashBalanceError = Class.new(StandardError) + NegativeTotalValueError = Class.new(StandardError) + + def initialize(plaid_account, security_resolver:) + @plaid_account = plaid_account + @security_resolver = security_resolver + end + + def balance + total_value = total_investment_account_value + + if total_value.negative? + Sentry.capture_exception( + NegativeTotalValueError.new("Total value is negative for plaid investment account"), + level: :warning + ) + end + + total_value + end + + # Plaid considers "brokerage cash" and "cash equivalent holdings" to all be part of "cash balance" + # + # Internally, we DO NOT. Maybe clearly distinguishes between "brokerage cash" vs. "holdings (i.e. invested cash)" + # For this reason, we must manually calculate the cash balance based on "total value" and "holdings value" + # See PlaidAccount::Investments::SecurityResolver for more details. + def cash_balance + cash_balance = calculate_investment_brokerage_cash + + if cash_balance.negative? + Sentry.capture_exception( + NegativeCashBalanceError.new("Cash balance is negative for plaid investment account"), + level: :warning + ) + end + + cash_balance + end + + private + attr_reader :plaid_account, :security_resolver + + def holdings + plaid_account.raw_investments_payload["holdings"] || [] + end + + def calculate_investment_brokerage_cash + total_investment_account_value - true_holdings_value + end + + # This is our source of truth. We assume Plaid's `current_balance` reporting is 100% accurate + # Plaid guarantees `current_balance` AND/OR `available_balance` is always present, and based on the docs, + # `current_balance` should represent "total account value". + def total_investment_account_value + plaid_account.current_balance || plaid_account.available_balance + end + + # Plaid holdings summed up, LESS "brokerage cash" holdings (that we've manually identified) + def true_holdings_value + # True holdings are holdings *less* Plaid's "pseudo-securities" (e.g. `CUR:USD` brokerage cash "holding") + true_holdings = holdings.reject do |h| + security = security_resolver.resolve(plaid_security_id: h["security_id"]) + security.brokerage_cash? + end + + true_holdings.sum { |h| h["quantity"] * h["institution_price"] } + end +end diff --git a/app/models/plaid_account/investments/holdings_processor.rb b/app/models/plaid_account/investments/holdings_processor.rb new file mode 100644 index 00000000..cfaaa5b3 --- /dev/null +++ b/app/models/plaid_account/investments/holdings_processor.rb @@ -0,0 +1,39 @@ +class PlaidAccount::Investments::HoldingsProcessor + def initialize(plaid_account, security_resolver:) + @plaid_account = plaid_account + @security_resolver = security_resolver + end + + def process + holdings.each do |plaid_holding| + resolved_security_result = security_resolver.resolve(plaid_security_id: plaid_holding["security_id"]) + + return unless resolved_security_result.security.present? + + holding = account.holdings.find_or_initialize_by( + security: resolved_security_result.security, + date: Date.current, + currency: plaid_holding["iso_currency_code"] + ) + + holding.assign_attributes( + qty: plaid_holding["quantity"], + price: plaid_holding["institution_price"], + amount: plaid_holding["quantity"] * plaid_holding["institution_price"] + ) + + holding.save! + end + end + + private + attr_reader :plaid_account, :security_resolver + + def account + plaid_account.account + end + + def holdings + plaid_account.raw_investments_payload["holdings"] || [] + end +end diff --git a/app/models/plaid_account/investments/security_resolver.rb b/app/models/plaid_account/investments/security_resolver.rb new file mode 100644 index 00000000..a6b0515b --- /dev/null +++ b/app/models/plaid_account/investments/security_resolver.rb @@ -0,0 +1,93 @@ +# Resolves a Plaid security to an internal Security record, or nil +class PlaidAccount::Investments::SecurityResolver + UnresolvablePlaidSecurityError = Class.new(StandardError) + + def initialize(plaid_account) + @plaid_account = plaid_account + @security_cache = {} + end + + # Resolves an internal Security record for a given Plaid security ID + def resolve(plaid_security_id:) + response = @security_cache[plaid_security_id] + return response if response.present? + + plaid_security = get_plaid_security(plaid_security_id) + + if plaid_security.nil? + report_unresolvable_security(plaid_security_id) + response = Response.new(security: nil, cash_equivalent?: false, brokerage_cash?: false) + elsif brokerage_cash?(plaid_security) + response = Response.new(security: nil, cash_equivalent?: true, brokerage_cash?: true) + else + security = Security::Resolver.new( + plaid_security["ticker_symbol"], + exchange_operating_mic: plaid_security["market_identifier_code"] + ).resolve + + response = Response.new( + security: security, + cash_equivalent?: cash_equivalent?(plaid_security), + brokerage_cash?: false + ) + end + + @security_cache[plaid_security_id] = response + + response + end + + private + attr_reader :plaid_account, :security_cache + + Response = Struct.new(:security, :cash_equivalent?, :brokerage_cash?, keyword_init: true) + + def securities + plaid_account.raw_investments_payload["securities"] || [] + end + + # Tries to find security, or returns the "proxy security" (common with options contracts that have underlying securities) + def get_plaid_security(plaid_security_id) + security = securities.find { |s| s["security_id"] == plaid_security_id && s["ticker_symbol"].present? } + + return security if security.present? + + securities.find { |s| s["proxy_security_id"] == plaid_security_id } + end + + def report_unresolvable_security(plaid_security_id) + Sentry.capture_exception(UnresolvablePlaidSecurityError.new("Could not resolve Plaid security from provided data")) do |scope| + scope.set_context("plaid_security", { + plaid_security_id: plaid_security_id + }) + end + end + + # Plaid treats "brokerage cash" differently than us. Internally, Maybe treats "brokerage cash" + # as "uninvested cash" (i.e. cash that doesn't have a corresponding Security and can be withdrawn). + # + # Plaid treats everything as a "holding" with a corresponding Security. For example, "brokerage cash" (USD) + # in Plaids data model would be represented as: + # + # - A Security with ticker `CUR:USD` + # - A holding, linked to the `CUR:USD` Security, with an institution price of $1 + # + # Internally, we store brokerage cash balance as `account.cash_balance`, NOT as a holding + security. + # This allows us to properly build historical cash balances and holdings values separately and accurately. + # + # These help identify these "special case" securities for various calculations. + # + def known_plaid_brokerage_cash_tickers + [ "CUR:USD" ] + end + + def brokerage_cash?(plaid_security) + return false unless plaid_security["ticker_symbol"].present? + known_plaid_brokerage_cash_tickers.include?(plaid_security["ticker_symbol"]) + end + + def cash_equivalent?(plaid_security) + return false unless plaid_security["type"].present? + plaid_security["type"] == "cash" || plaid_security["is_cash_equivalent"] == true + end +end diff --git a/app/models/plaid_account/investments/transactions_processor.rb b/app/models/plaid_account/investments/transactions_processor.rb new file mode 100644 index 00000000..9dcebdb0 --- /dev/null +++ b/app/models/plaid_account/investments/transactions_processor.rb @@ -0,0 +1,90 @@ +class PlaidAccount::Investments::TransactionsProcessor + SecurityNotFoundError = Class.new(StandardError) + + def initialize(plaid_account, security_resolver:) + @plaid_account = plaid_account + @security_resolver = security_resolver + end + + def process + transactions.each do |transaction| + if cash_transaction?(transaction) + find_or_create_cash_entry(transaction) + else + find_or_create_trade_entry(transaction) + end + end + end + + private + attr_reader :plaid_account, :security_resolver + + def account + plaid_account.account + end + + def cash_transaction?(transaction) + transaction["type"] == "cash" || transaction["type"] == "fee" + end + + def find_or_create_trade_entry(transaction) + resolved_security_result = security_resolver.resolve(plaid_security_id: transaction["security_id"]) + + unless resolved_security_result.security.present? + Sentry.capture_exception(SecurityNotFoundError.new("Could not find security for plaid trade")) do |scope| + scope.set_tags(plaid_account_id: plaid_account.id) + end + + return # We can't process a non-cash transaction without a security + end + + entry = account.entries.find_or_initialize_by(plaid_id: transaction["investment_transaction_id"]) do |e| + e.entryable = Trade.new + end + + entry.assign_attributes( + amount: transaction["quantity"] * transaction["price"], + currency: transaction["iso_currency_code"], + date: transaction["date"] + ) + + entry.trade.assign_attributes( + security: resolved_security_result.security, + qty: transaction["quantity"], + price: transaction["price"], + currency: transaction["iso_currency_code"] + ) + + entry.enrich_attribute( + :name, + transaction["name"], + source: "plaid" + ) + + entry.save! + end + + def find_or_create_cash_entry(transaction) + entry = account.entries.find_or_initialize_by(plaid_id: transaction["investment_transaction_id"]) do |e| + e.entryable = Transaction.new + end + + entry.assign_attributes( + amount: transaction["amount"], + currency: transaction["iso_currency_code"], + date: transaction["date"] + ) + + entry.enrich_attribute( + :name, + transaction["name"], + source: "plaid" + ) + + entry.save! + end + + def transactions + plaid_account.raw_investments_payload["transactions"] || [] + end +end diff --git a/app/models/plaid_account/liabilities/credit_processor.rb b/app/models/plaid_account/liabilities/credit_processor.rb new file mode 100644 index 00000000..cc487295 --- /dev/null +++ b/app/models/plaid_account/liabilities/credit_processor.rb @@ -0,0 +1,25 @@ +class PlaidAccount::Liabilities::CreditProcessor + def initialize(plaid_account) + @plaid_account = plaid_account + end + + def process + return unless credit_data.present? + + account.credit_card.update!( + minimum_payment: credit_data.dig("minimum_payment_amount"), + apr: credit_data.dig("aprs", 0, "apr_percentage") + ) + end + + private + attr_reader :plaid_account + + def account + plaid_account.account + end + + def credit_data + plaid_account.raw_liabilities_payload["credit"] + end +end diff --git a/app/models/plaid_account/liabilities/mortgage_processor.rb b/app/models/plaid_account/liabilities/mortgage_processor.rb new file mode 100644 index 00000000..d4610362 --- /dev/null +++ b/app/models/plaid_account/liabilities/mortgage_processor.rb @@ -0,0 +1,25 @@ +class PlaidAccount::Liabilities::MortgageProcessor + def initialize(plaid_account) + @plaid_account = plaid_account + end + + def process + return unless mortgage_data.present? + + account.loan.update!( + rate_type: mortgage_data.dig("interest_rate", "type"), + interest_rate: mortgage_data.dig("interest_rate", "percentage") + ) + end + + private + attr_reader :plaid_account + + def account + plaid_account.account + end + + def mortgage_data + plaid_account.raw_liabilities_payload["mortgage"] + end +end diff --git a/app/models/plaid_account/liabilities/student_loan_processor.rb b/app/models/plaid_account/liabilities/student_loan_processor.rb new file mode 100644 index 00000000..c3c3b4f2 --- /dev/null +++ b/app/models/plaid_account/liabilities/student_loan_processor.rb @@ -0,0 +1,50 @@ +class PlaidAccount::Liabilities::StudentLoanProcessor + def initialize(plaid_account) + @plaid_account = plaid_account + end + + def process + return unless student_loan_data.present? + + account.loan.update!( + rate_type: "fixed", + interest_rate: student_loan_data["interest_rate_percentage"], + initial_balance: student_loan_data["origination_principal_amount"], + term_months: term_months + ) + end + + private + attr_reader :plaid_account + + def account + plaid_account.account + end + + def term_months + return nil unless origination_date && expected_payoff_date + + ((expected_payoff_date - origination_date).to_i / 30).to_i + end + + def origination_date + parse_date(student_loan_data["origination_date"]) + end + + def expected_payoff_date + parse_date(student_loan_data["expected_payoff_date"]) + end + + def parse_date(value) + return value if value.is_a?(Date) + return nil unless value.present? + + Date.parse(value.to_s) + rescue ArgumentError + nil + end + + def student_loan_data + plaid_account.raw_liabilities_payload["student"] + end +end diff --git a/app/models/plaid_account/processor.rb b/app/models/plaid_account/processor.rb new file mode 100644 index 00000000..1a8205a1 --- /dev/null +++ b/app/models/plaid_account/processor.rb @@ -0,0 +1,99 @@ +class PlaidAccount::Processor + include PlaidAccount::TypeMappable + + attr_reader :plaid_account + + def initialize(plaid_account) + @plaid_account = plaid_account + end + + # Each step represents a different Plaid API endpoint / "product" + # + # Processing the account is the first step and if it fails, we halt the entire processor + # Each subsequent step can fail independently, but we continue processing the rest of the steps + def process + process_account! + process_transactions + process_investments + process_liabilities + end + + private + def family + plaid_account.plaid_item.family + end + + # Shared securities reader and resolver + def security_resolver + @security_resolver ||= PlaidAccount::Investments::SecurityResolver.new(plaid_account) + end + + def process_account! + PlaidAccount.transaction do + account = family.accounts.find_or_initialize_by( + plaid_account_id: plaid_account.id + ) + + # Name and subtype are the only attributes a user can override for Plaid accounts + account.enrich_attributes( + { + name: plaid_account.name, + subtype: map_subtype(plaid_account.plaid_type, plaid_account.plaid_subtype) + }, + source: "plaid" + ) + + account.assign_attributes( + accountable: map_accountable(plaid_account.plaid_type), + balance: balance_calculator.balance, + currency: plaid_account.currency, + cash_balance: balance_calculator.cash_balance + ) + + account.save! + end + end + + def process_transactions + PlaidAccount::Transactions::Processor.new(plaid_account).process + rescue => e + report_exception(e) + end + + def process_investments + PlaidAccount::Investments::TransactionsProcessor.new(plaid_account, security_resolver: security_resolver).process + PlaidAccount::Investments::HoldingsProcessor.new(plaid_account, security_resolver: security_resolver).process + rescue => e + report_exception(e) + end + + def process_liabilities + case [ plaid_account.plaid_type, plaid_account.plaid_subtype ] + when [ "credit", "credit card" ] + PlaidAccount::Liabilities::CreditProcessor.new(plaid_account).process + when [ "loan", "mortgage" ] + PlaidAccount::Liabilities::MortgageProcessor.new(plaid_account).process + when [ "loan", "student" ] + PlaidAccount::Liabilities::StudentLoanProcessor.new(plaid_account).process + end + rescue => e + report_exception(e) + end + + def balance_calculator + if plaid_account.plaid_type == "investment" + @balance_calculator ||= PlaidAccount::Investments::BalanceCalculator.new(plaid_account, security_resolver: security_resolver) + else + OpenStruct.new( + balance: plaid_account.current_balance || plaid_account.available_balance, + cash_balance: plaid_account.available_balance || 0 + ) + end + end + + def report_exception(error) + Sentry.capture_exception(error) do |scope| + scope.set_tags(plaid_account_id: plaid_account.id) + end + end +end diff --git a/app/models/provider/plaid/category_alias_matcher.rb b/app/models/plaid_account/transactions/category_matcher.rb similarity index 96% rename from app/models/provider/plaid/category_alias_matcher.rb rename to app/models/plaid_account/transactions/category_matcher.rb index 41ada3a6..c4081a68 100644 --- a/app/models/provider/plaid/category_alias_matcher.rb +++ b/app/models/plaid_account/transactions/category_matcher.rb @@ -10,10 +10,10 @@ # # This class is simply a FAST and CHEAP way to match categories that are high confidence. # Edge cases will be handled by user-defined rules. -class Provider::Plaid::CategoryAliasMatcher - include Provider::Plaid::CategoryTaxonomy +class PlaidAccount::Transactions::CategoryMatcher + include PlaidAccount::Transactions::CategoryTaxonomy - def initialize(user_categories) + def initialize(user_categories = []) @user_categories = user_categories end diff --git a/app/models/provider/plaid/category_taxonomy.rb b/app/models/plaid_account/transactions/category_taxonomy.rb similarity index 99% rename from app/models/provider/plaid/category_taxonomy.rb rename to app/models/plaid_account/transactions/category_taxonomy.rb index 9766c724..84b4b8c1 100644 --- a/app/models/provider/plaid/category_taxonomy.rb +++ b/app/models/plaid_account/transactions/category_taxonomy.rb @@ -1,5 +1,5 @@ # https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv -module Provider::Plaid::CategoryTaxonomy +module PlaidAccount::Transactions::CategoryTaxonomy CATEGORIES_MAP = { income: { classification: :income, diff --git a/app/models/plaid_account/transactions/processor.rb b/app/models/plaid_account/transactions/processor.rb new file mode 100644 index 00000000..8aa07162 --- /dev/null +++ b/app/models/plaid_account/transactions/processor.rb @@ -0,0 +1,60 @@ +class PlaidAccount::Transactions::Processor + def initialize(plaid_account) + @plaid_account = plaid_account + end + + def process + # Each entry is processed inside a transaction, but to avoid locking up the DB when + # there are hundreds or thousands of transactions, we process them individually. + modified_transactions.each do |transaction| + PlaidEntry::Processor.new( + transaction, + plaid_account: plaid_account, + category_matcher: category_matcher + ).process + end + + PlaidAccount.transaction do + removed_transactions.each do |transaction| + remove_plaid_transaction(transaction) + end + end + end + + private + attr_reader :plaid_account + + def category_matcher + @category_matcher ||= PlaidAccount::Transactions::CategoryMatcher.new(family_categories) + end + + def family_categories + @family_categories ||= begin + if account.family.categories.none? + account.family.categories.bootstrap! + end + + account.family.categories + end + end + + def account + plaid_account.account + end + + def remove_plaid_transaction(raw_transaction) + account.entries.find_by(plaid_id: raw_transaction["transaction_id"])&.destroy + end + + # Since we find_or_create_by transactions, we don't need a distinction between added/modified + def modified_transactions + modified = plaid_account.raw_transactions_payload["modified"] || [] + added = plaid_account.raw_transactions_payload["added"] || [] + + modified + added + end + + def removed_transactions + plaid_account.raw_transactions_payload["removed"] || [] + end +end diff --git a/app/models/plaid_account/type_mappable.rb b/app/models/plaid_account/type_mappable.rb new file mode 100644 index 00000000..e91b66d8 --- /dev/null +++ b/app/models/plaid_account/type_mappable.rb @@ -0,0 +1,77 @@ +module PlaidAccount::TypeMappable + extend ActiveSupport::Concern + + UnknownAccountTypeError = Class.new(StandardError) + + def map_accountable(plaid_type) + accountable_class = TYPE_MAPPING.dig( + plaid_type.to_sym, + :accountable + ) + + unless accountable_class + raise UnknownAccountTypeError, "Unknown account type: #{plaid_type}" + end + + accountable_class.new + end + + def map_subtype(plaid_type, plaid_subtype) + TYPE_MAPPING.dig( + plaid_type.to_sym, + :subtype_mapping, + plaid_subtype + ) || "other" + end + + # Plaid Account Types -> Accountable Types + # https://plaid.com/docs/api/accounts/#account-type-schema + TYPE_MAPPING = { + depository: { + accountable: Depository, + subtype_mapping: { + "checking" => "checking", + "savings" => "savings", + "hsa" => "hsa", + "cd" => "cd", + "money market" => "money_market" + } + }, + credit: { + accountable: CreditCard, + subtype_mapping: { + "credit card" => "credit_card" + } + }, + loan: { + accountable: Loan, + subtype_mapping: { + "mortgage" => "mortgage", + "student" => "student", + "auto" => "auto", + "business" => "business", + "home equity" => "home_equity", + "line of credit" => "line_of_credit" + } + }, + investment: { + accountable: Investment, + subtype_mapping: { + "brokerage" => "brokerage", + "pension" => "pension", + "retirement" => "retirement", + "401k" => "401k", + "roth 401k" => "roth_401k", + "529" => "529_plan", + "hsa" => "hsa", + "mutual fund" => "mutual_fund", + "roth" => "roth_ira", + "ira" => "ira" + } + }, + other: { + accountable: OtherAsset, + subtype_mapping: {} + } + } +end diff --git a/app/models/plaid_entry/processor.rb b/app/models/plaid_entry/processor.rb new file mode 100644 index 00000000..182e382d --- /dev/null +++ b/app/models/plaid_entry/processor.rb @@ -0,0 +1,95 @@ +class PlaidEntry::Processor + # plaid_transaction is the raw hash fetched from Plaid API and converted to JSONB + def initialize(plaid_transaction, plaid_account:, category_matcher:) + @plaid_transaction = plaid_transaction + @plaid_account = plaid_account + @category_matcher = category_matcher + end + + def process + PlaidAccount.transaction do + entry = account.entries.find_or_initialize_by(plaid_id: plaid_id) do |e| + e.entryable = Transaction.new + end + + entry.assign_attributes( + amount: amount, + currency: currency, + date: date + ) + + entry.enrich_attribute( + :name, + name, + source: "plaid" + ) + + if detailed_category + matched_category = category_matcher.match(detailed_category) + + if matched_category + entry.transaction.enrich_attribute( + :category_id, + matched_category.id, + source: "plaid" + ) + end + end + + if merchant + entry.transaction.enrich_attribute( + :merchant_id, + merchant.id, + source: "plaid" + ) + end + end + end + + private + attr_reader :plaid_transaction, :plaid_account, :category_matcher + + def account + plaid_account.account + end + + def plaid_id + plaid_transaction["transaction_id"] + end + + def name + plaid_transaction["merchant_name"] || plaid_transaction["original_description"] + end + + def amount + plaid_transaction["amount"] + end + + def currency + plaid_transaction["iso_currency_code"] + end + + def date + plaid_transaction["date"] + end + + def detailed_category + plaid_transaction.dig("personal_finance_category", "detailed") + end + + def merchant + merchant_id = plaid_transaction["merchant_entity_id"] + merchant_name = plaid_transaction["merchant_name"] + + return nil unless merchant_id.present? && merchant_name.present? + + ProviderMerchant.find_or_create_by!( + source: "plaid", + name: merchant_name, + ) do |m| + m.provider_merchant_id = merchant_id + m.website_url = plaid_transaction["website"] + m.logo_url = plaid_transaction["logo_url"] + end + end +end diff --git a/app/models/plaid_investment_sync.rb b/app/models/plaid_investment_sync.rb deleted file mode 100644 index cc0d56a6..00000000 --- a/app/models/plaid_investment_sync.rb +++ /dev/null @@ -1,115 +0,0 @@ -class PlaidInvestmentSync - attr_reader :plaid_account - - def initialize(plaid_account) - @plaid_account = plaid_account - end - - def sync!(transactions: [], holdings: [], securities: []) - @transactions = transactions - @holdings = holdings - @securities = securities - - PlaidAccount.transaction do - normalize_cash_balance! - sync_transactions! - sync_holdings! - end - end - - private - attr_reader :transactions, :holdings, :securities - - # Plaid considers "brokerage cash" and "cash equivalent holdings" to all be part of "cash balance" - # Internally, we DO NOT. - # Maybe clearly distinguishes between "brokerage cash" vs. "holdings (i.e. invested cash)" - # For this reason, we must back out cash + cash equivalent holdings from the reported cash balance to avoid double counting - def normalize_cash_balance! - excludable_cash_holdings = holdings.select do |h| - internal_security, plaid_security = get_security(h.security_id, securities) - internal_security.present? && (plaid_security&.is_cash_equivalent || plaid_security&.type == "cash") - end - - excludable_cash_holdings_value = excludable_cash_holdings.sum { |h| h.quantity * h.institution_price } - - plaid_account.account.update!( - cash_balance: plaid_account.account.cash_balance - excludable_cash_holdings_value - ) - end - - def sync_transactions! - transactions.each do |transaction| - security, plaid_security = get_security(transaction.security_id, securities) - - next if security.nil? && plaid_security.nil? - - if transaction.type == "cash" || plaid_security.ticker_symbol == "CUR:USD" - new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| - t.name = transaction.name - t.amount = transaction.amount - t.currency = transaction.iso_currency_code - t.date = transaction.date - t.entryable = Transaction.new - end - else - new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| - t.name = transaction.name - t.amount = transaction.quantity * transaction.price - t.currency = transaction.iso_currency_code - t.date = transaction.date - t.entryable = Trade.new( - security: security, - qty: transaction.quantity, - price: transaction.price, - currency: transaction.iso_currency_code - ) - end - end - end - end - - def sync_holdings! - # Update only the current day holdings. The account sync will populate historical values based on trades. - holdings.each do |holding| - internal_security, _plaid_security = get_security(holding.security_id, securities) - - next if internal_security.nil? - - existing_holding = plaid_account.account.holdings.find_or_initialize_by( - security: internal_security, - date: Date.current, - currency: holding.iso_currency_code - ) - - existing_holding.qty = holding.quantity - existing_holding.price = holding.institution_price - existing_holding.amount = holding.quantity * holding.institution_price - existing_holding.save! - end - end - - def get_security(plaid_security_id, securities) - plaid_security = securities.find { |s| s.security_id == plaid_security_id } - - return [ nil, nil ] if plaid_security.nil? - - plaid_security = if plaid_security.ticker_symbol.present? - plaid_security - else - securities.find { |s| s.security_id == plaid_security.proxy_security_id } - end - - return [ nil, nil ] if plaid_security.nil? || plaid_security.ticker_symbol.blank? - return [ nil, plaid_security ] if plaid_security.ticker_symbol == "CUR:USD" # internally, we do not consider cash a security and track it separately - - operating_mic = plaid_security.market_identifier_code - - # Find any matching security - security = Security.find_or_create_by!( - ticker: plaid_security.ticker_symbol&.upcase, - exchange_operating_mic: operating_mic&.upcase - ) - - [ security, plaid_security ] - end -end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index e693e69a..ce828288 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -1,5 +1,5 @@ class PlaidItem < ApplicationRecord - include Syncable + include Syncable, Provided enum :plaid_region, { us: "us", eu: "eu" } enum :status, { good: "good", requires_update: "requires_update" }, default: :good @@ -43,10 +43,6 @@ class PlaidItem < ApplicationRecord end end - def build_category_alias_matcher(user_categories) - Provider::Plaid::CategoryAliasMatcher.new(user_categories) - end - def destroy_later update!(scheduled_for_deletion: true) DestroyJob.perform_later(self) @@ -60,41 +56,70 @@ class PlaidItem < ApplicationRecord .exists? end - def auto_match_categories! - if family.categories.none? - family.categories.bootstrap! - end + def import_latest_plaid_data + PlaidItem::Importer.new(self, plaid_provider: plaid_provider).import + end - alias_matcher = build_category_alias_matcher(family.categories) - - accounts.each do |account| - matchable_transactions = account.transactions - .where(category_id: nil) - .where.not(plaid_category: nil) - .enrichable(:category_id) - - matchable_transactions.each do |transaction| - category = alias_matcher.match(transaction.plaid_category_detailed) - - if category.present? - # Matcher could either return a string or a Category object - user_category = if category.is_a?(String) - family.categories.find_or_create_by!(name: category) - else - category - end - - transaction.enrich_attribute(:category_id, user_category.id, source: "plaid") - end - end + # Reads the fetched data and updates internal domain objects + # Generally, this should only be called within a "sync", but can be called + # manually to "re-sync" the already fetched data + def process_accounts + plaid_accounts.each do |plaid_account| + PlaidAccount::Processor.new(plaid_account).process end end + # Once all the data is fetched, we can schedule account syncs to calculate historical balances + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + accounts.each do |account| + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + end + end + + # Saves the raw data fetched from Plaid API for this item + def upsert_plaid_snapshot!(item_snapshot) + assign_attributes( + available_products: item_snapshot.available_products, + billed_products: item_snapshot.billed_products, + raw_payload: item_snapshot, + ) + + save! + end + + # Saves the raw data fetched from Plaid API for this item's institution + def upsert_plaid_institution_snapshot!(institution_snapshot) + assign_attributes( + institution_id: institution_snapshot.institution_id, + institution_url: institution_snapshot.url, + institution_color: institution_snapshot.primary_color, + raw_institution_payload: institution_snapshot + ) + + save! + end + + def supports_product?(product) + supported_products.include?(product) + end + private + # Silently swallow and report error so that we don't block the user from deleting the item def remove_plaid_item plaid_provider.remove_item(access_token) rescue StandardError => e - Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}") + Sentry.capture_exception(e) + end + + # Plaid returns mutually exclusive arrays here. If the item has made a request for a product, + # it is put in the billed_products array. If it is supported, but not yet used, it goes in the + # available_products array. + def supported_products + available_products + billed_products end class PlaidConnectionLostError < StandardError; end diff --git a/app/models/plaid_item/accounts_snapshot.rb b/app/models/plaid_item/accounts_snapshot.rb new file mode 100644 index 00000000..a6d2c355 --- /dev/null +++ b/app/models/plaid_item/accounts_snapshot.rb @@ -0,0 +1,79 @@ +# All Plaid data is fetched at the item-level. This class is a simple wrapper that +# providers a convenience method, get_account_data which scopes the item-level payload +# to each Plaid Account +class PlaidItem::AccountsSnapshot + def initialize(plaid_item, plaid_provider:) + @plaid_item = plaid_item + @plaid_provider = plaid_provider + end + + def accounts + @accounts ||= plaid_provider.get_item_accounts(plaid_item.access_token).accounts + end + + def get_account_data(account_id) + AccountData.new( + account_data: accounts.find { |a| a.account_id == account_id }, + transactions_data: account_scoped_transactions_data(account_id), + investments_data: account_scoped_investments_data(account_id), + liabilities_data: account_scoped_liabilities_data(account_id) + ) + end + + private + attr_reader :plaid_item, :plaid_provider + + TransactionsData = Data.define(:added, :modified, :removed) + LiabilitiesData = Data.define(:credit, :mortgage, :student) + InvestmentsData = Data.define(:transactions, :holdings, :securities) + AccountData = Data.define(:account_data, :transactions_data, :investments_data, :liabilities_data) + + def account_scoped_transactions_data(account_id) + return nil unless transactions_data + + TransactionsData.new( + added: transactions_data.added.select { |t| t.account_id == account_id }, + modified: transactions_data.modified.select { |t| t.account_id == account_id }, + removed: transactions_data.removed.select { |t| t.account_id == account_id } + ) + end + + def account_scoped_investments_data(account_id) + return nil unless investments_data + + transactions = investments_data.transactions.select { |t| t.account_id == account_id } + holdings = investments_data.holdings.select { |h| h.account_id == account_id } + securities = transactions.count > 0 && holdings.count > 0 ? investments_data.securities : [] + + InvestmentsData.new( + transactions: transactions, + holdings: holdings, + securities: securities + ) + end + + def account_scoped_liabilities_data(account_id) + return nil unless liabilities_data + + LiabilitiesData.new( + credit: liabilities_data.credit&.find { |c| c.account_id == account_id }, + mortgage: liabilities_data.mortgage&.find { |m| m.account_id == account_id }, + student: liabilities_data.student&.find { |s| s.account_id == account_id } + ) + end + + def transactions_data + return nil unless plaid_item.supports_product?("transactions") + @transactions_data ||= plaid_provider.get_transactions(plaid_item.access_token) + end + + def investments_data + return nil unless plaid_item.supports_product?("investments") + @investments_data ||= plaid_provider.get_item_investments(plaid_item.access_token) + end + + def liabilities_data + return nil unless plaid_item.supports_product?("liabilities") + @liabilities_data ||= plaid_provider.get_item_liabilities(plaid_item.access_token) + end +end diff --git a/app/models/plaid_item/importer.rb b/app/models/plaid_item/importer.rb new file mode 100644 index 00000000..9a4b295e --- /dev/null +++ b/app/models/plaid_item/importer.rb @@ -0,0 +1,53 @@ +class PlaidItem::Importer + def initialize(plaid_item, plaid_provider:) + @plaid_item = plaid_item + @plaid_provider = plaid_provider + end + + def import + fetch_and_import_item_data + fetch_and_import_accounts_data + rescue Plaid::ApiError => e + handle_plaid_error(e) + end + + private + attr_reader :plaid_item, :plaid_provider + + # All errors that should halt the import should be re-raised after handling + # These errors will propagate up to the Sync record and mark it as failed. + def handle_plaid_error(error) + error_body = JSON.parse(error.response_body) + + case error_body["error_code"] + when "ITEM_LOGIN_REQUIRED" + plaid_item.update!(status: :requires_update) + raise error + else + raise error + end + end + + def fetch_and_import_item_data + item_data = plaid_provider.get_item(plaid_item.access_token).item + institution_data = plaid_provider.get_institution(item_data.institution_id).institution + + plaid_item.upsert_plaid_snapshot!(item_data) + plaid_item.upsert_plaid_institution_snapshot!(institution_data) + end + + def fetch_and_import_accounts_data + snapshot = PlaidItem::AccountsSnapshot.new(plaid_item, plaid_provider: plaid_provider) + + snapshot.accounts.each do |raw_account| + plaid_account = plaid_item.plaid_accounts.find_or_initialize_by( + plaid_id: raw_account.account_id + ) + + PlaidAccount::Importer.new( + plaid_account, + account_snapshot: snapshot.get_account_data(raw_account.account_id) + ).import + end + end +end diff --git a/app/models/plaid_item/provided.rb b/app/models/plaid_item/provided.rb new file mode 100644 index 00000000..dc370b23 --- /dev/null +++ b/app/models/plaid_item/provided.rb @@ -0,0 +1,7 @@ +module PlaidItem::Provided + extend ActiveSupport::Concern + + def plaid_provider + @plaid_provider ||= Provider::Registry.plaid_provider_for_region(self.plaid_region) + end +end diff --git a/app/models/plaid_item/syncer.rb b/app/models/plaid_item/syncer.rb index e32b9ed4..b76c37b6 100644 --- a/app/models/plaid_item/syncer.rb +++ b/app/models/plaid_item/syncer.rb @@ -6,144 +6,21 @@ class PlaidItem::Syncer end def perform_sync(sync) - begin - Rails.logger.info("Fetching and loading Plaid data") - fetch_and_load_plaid_data - plaid_item.update!(status: :good) if plaid_item.requires_update? + # Loads item metadata, accounts, transactions, and other data to our DB + plaid_item.import_latest_plaid_data - plaid_item.accounts.each do |account| - account.sync_later(parent_sync: sync, window_start_date: sync.window_start_date, window_end_date: sync.window_end_date) - end + # Processes the raw Plaid data and updates internal domain objects + plaid_item.process_accounts - Rails.logger.info("Plaid data fetched and loaded") - rescue Plaid::ApiError => e - handle_plaid_error(e) - raise e - end + # All data is synced, so we can now run an account sync to calculate historical balances and more + plaid_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) end def perform_post_sync - plaid_item.auto_match_categories! + # no-op end - - private - def plaid - plaid_item.plaid_region == "eu" ? plaid_eu : plaid_us - end - - def plaid_eu - @plaid_eu ||= Provider::Registry.get_provider(:plaid_eu) - end - - def plaid_us - @plaid_us ||= Provider::Registry.get_provider(:plaid_us) - end - - def safe_fetch_plaid_data(method) - begin - plaid.send(method, plaid_item) - rescue Plaid::ApiError => e - Rails.logger.warn("Error fetching #{method} for item #{plaid_item.id}: #{e.message}") - nil - end - end - - def handle_plaid_error(error) - error_body = JSON.parse(error.response_body) - - if error_body["error_code"] == "ITEM_LOGIN_REQUIRED" - plaid_item.update!(status: :requires_update) - end - end - - def fetch_and_load_plaid_data - data = {} - - # Log what we're about to fetch - Rails.logger.info "Starting Plaid data fetch (accounts, transactions, investments, liabilities)" - - item = plaid.get_item(plaid_item.access_token).item - plaid_item.update!(available_products: item.available_products, billed_products: item.billed_products) - - # Institution details - if item.institution_id.present? - begin - Rails.logger.info "Fetching Plaid institution details for #{item.institution_id}" - institution = plaid.get_institution(item.institution_id) - plaid_item.update!( - institution_id: item.institution_id, - institution_url: institution.institution.url, - institution_color: institution.institution.primary_color - ) - rescue Plaid::ApiError => e - Rails.logger.warn "Failed to fetch Plaid institution details: #{e.message}" - end - end - - # Accounts - fetched_accounts = plaid.get_item_accounts(plaid_item).accounts - data[:accounts] = fetched_accounts || [] - Rails.logger.info "Processing Plaid accounts (count: #{fetched_accounts.size})" - - internal_plaid_accounts = fetched_accounts.map do |account| - internal_plaid_account = plaid_item.plaid_accounts.find_or_create_from_plaid_data!(account, plaid_item.family) - internal_plaid_account.sync_account_data!(account) - internal_plaid_account - end - - # Transactions - fetched_transactions = safe_fetch_plaid_data(:get_item_transactions) - data[:transactions] = fetched_transactions || [] - - if fetched_transactions - Rails.logger.info "Processing Plaid transactions (added: #{fetched_transactions.added.size}, modified: #{fetched_transactions.modified.size}, removed: #{fetched_transactions.removed.size})" - PlaidItem.transaction do - internal_plaid_accounts.each do |internal_plaid_account| - added = fetched_transactions.added.select { |t| t.account_id == internal_plaid_account.plaid_id } - modified = fetched_transactions.modified.select { |t| t.account_id == internal_plaid_account.plaid_id } - removed = fetched_transactions.removed.select { |t| t.account_id == internal_plaid_account.plaid_id } - - internal_plaid_account.sync_transactions!(added:, modified:, removed:) - end - - plaid_item.update!(next_cursor: fetched_transactions.cursor) - end - end - - # Investments - fetched_investments = safe_fetch_plaid_data(:get_item_investments) - data[:investments] = fetched_investments || [] - - if fetched_investments - Rails.logger.info "Processing Plaid investments (transactions: #{fetched_investments.transactions.size}, holdings: #{fetched_investments.holdings.size}, securities: #{fetched_investments.securities.size})" - PlaidItem.transaction do - internal_plaid_accounts.each do |internal_plaid_account| - transactions = fetched_investments.transactions.select { |t| t.account_id == internal_plaid_account.plaid_id } - holdings = fetched_investments.holdings.select { |h| h.account_id == internal_plaid_account.plaid_id } - securities = fetched_investments.securities - - internal_plaid_account.sync_investments!(transactions:, holdings:, securities:) - end - end - end - - # Liabilities - fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities) - data[:liabilities] = fetched_liabilities || [] - - if fetched_liabilities - Rails.logger.info "Processing Plaid liabilities (credit: #{fetched_liabilities.credit&.size || 0}, mortgage: #{fetched_liabilities.mortgage&.size || 0}, student: #{fetched_liabilities.student&.size || 0})" - PlaidItem.transaction do - internal_plaid_accounts.each do |internal_plaid_account| - credit = fetched_liabilities.credit&.find { |l| l.account_id == internal_plaid_account.plaid_id } - mortgage = fetched_liabilities.mortgage&.find { |l| l.account_id == internal_plaid_account.plaid_id } - student = fetched_liabilities.student&.find { |l| l.account_id == internal_plaid_account.plaid_id } - - internal_plaid_account.sync_credit_data!(credit) if credit - internal_plaid_account.sync_mortgage_data!(mortgage) if mortgage - internal_plaid_account.sync_student_loan_data!(student) if student - end - end - end - end end diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index ac5fe0f4..17b286cb 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -106,13 +106,13 @@ class Provider::Plaid client.item_remove(request) end - def get_item_accounts(item) - request = Plaid::AccountsGetRequest.new(access_token: item.access_token) + def get_item_accounts(access_token) + request = Plaid::AccountsGetRequest.new(access_token: access_token) client.accounts_get(request) end - def get_item_transactions(item) - cursor = item.next_cursor + def get_transactions(access_token, next_cursor: nil) + cursor = next_cursor added = [] modified = [] removed = [] @@ -120,7 +120,7 @@ class Provider::Plaid while has_more request = Plaid::TransactionsSyncRequest.new( - access_token: item.access_token, + access_token: access_token, cursor: cursor, options: { include_original_description: true @@ -139,18 +139,18 @@ class Provider::Plaid TransactionSyncResponse.new(added:, modified:, removed:, cursor:) end - def get_item_investments(item, start_date: nil, end_date: Date.current) + def get_item_investments(access_token, start_date: nil, end_date: Date.current) start_date = start_date || MAX_HISTORY_DAYS.days.ago.to_date - holdings, holding_securities = get_item_holdings(item) - transactions, transaction_securities = get_item_investment_transactions(item, start_date:, end_date:) + holdings, holding_securities = get_item_holdings(access_token: access_token) + transactions, transaction_securities = get_item_investment_transactions(access_token: access_token, start_date:, end_date:) merged_securities = ((holding_securities || []) + (transaction_securities || [])).uniq { |s| s.security_id } InvestmentsResponse.new(holdings:, transactions:, securities: merged_securities) end - def get_item_liabilities(item) - request = Plaid::LiabilitiesGetRequest.new({ access_token: item.access_token }) + def get_item_liabilities(access_token) + request = Plaid::LiabilitiesGetRequest.new({ access_token: access_token }) response = client.liabilities_get(request) response.liabilities end @@ -170,21 +170,21 @@ class Provider::Plaid TransactionSyncResponse = Struct.new :added, :modified, :removed, :cursor, keyword_init: true InvestmentsResponse = Struct.new :holdings, :transactions, :securities, keyword_init: true - def get_item_holdings(item) - request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: item.access_token }) + def get_item_holdings(access_token:) + request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: access_token }) response = client.investments_holdings_get(request) [ response.holdings, response.securities ] end - def get_item_investment_transactions(item, start_date:, end_date:) + def get_item_investment_transactions(access_token:, start_date:, end_date:) transactions = [] securities = [] offset = 0 loop do request = Plaid::InvestmentsTransactionsGetRequest.new( - access_token: item.access_token, + access_token: access_token, start_date: start_date.to_s, end_date: end_date.to_s, options: { offset: offset } diff --git a/app/models/provider/plaid_sandbox.rb b/app/models/provider/plaid_sandbox.rb index 132b4422..cec6e065 100644 --- a/app/models/provider/plaid_sandbox.rb +++ b/app/models/provider/plaid_sandbox.rb @@ -3,6 +3,19 @@ class Provider::PlaidSandbox < Provider::Plaid def initialize @client = create_client + @region = :us + end + + def create_public_token(username: nil) + client.sandbox_public_token_create( + Plaid::SandboxPublicTokenCreateRequest.new( + institution_id: "ins_109508", # "First Platypus Bank" (Plaid's sandbox institution that works with all products) + initial_products: [ "transactions", "investments", "liabilities" ], + options: { + override_username: username || "custom_test" + } + ) + ).public_token end def fire_webhook(item, type: "TRANSACTIONS", code: "SYNC_UPDATES_AVAILABLE") diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index 16fa81a2..c2d37db0 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -18,6 +18,10 @@ class Provider::Registry raise Error.new("Provider '#{name}' not found in registry") end + def plaid_provider_for_region(region) + region.to_sym == :us ? plaid_us : plaid_eu + end + private def stripe secret_key = ENV["STRIPE_SECRET_KEY"] diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb index ac8e6e55..3f427c71 100644 --- a/app/views/shared/_money_field.html.erb +++ b/app/views/shared/_money_field.html.erb @@ -34,6 +34,7 @@ min: options[:min] || -99999999999999, max: options[:max] || 99999999999999, step: currency.step, + disabled: options[:disabled], data: { "money-field-target": "amount", "auto-submit-form-target": ("auto" if options[:auto_submit]) diff --git a/app/views/trades/_header.html.erb b/app/views/trades/_header.html.erb index 7fcf1a47..5a8515c7 100644 --- a/app/views/trades/_header.html.erb +++ b/app/views/trades/_header.html.erb @@ -16,6 +16,12 @@ <%= entry.currency %> + + <% if entry.linked? %> + + <%= icon("refresh-ccw", size: "sm") %> + + <% end %> 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