1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 20:59:39 +02:00
Maybe/app/models/plaid_item.rb
Zach Gollwitzer f65b93a352
Data provider simplification, tests, and documentation (#1997)
* Ignore env.test from source control

* Simplification of providers interface

* Synth tests

* Update money to use new find rates method

* Remove unused issues code

* Additional issue feature removals

* Update price data fetching and tests

* Update documentation for providers

* Security test fixes

* Fix self host test

* Update synth usage data access

* Remove AI pr schema changes
2025-03-17 11:54:53 -04:00

209 lines
7.3 KiB
Ruby

class PlaidItem < ApplicationRecord
include Provided, Syncable
enum :plaid_region, { us: "us", eu: "eu" }
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
if Rails.application.credentials.active_record_encryption.present?
encrypts :access_token, deterministic: true
end
validates :name, :access_token, presence: true
before_destroy :remove_plaid_item
belongs_to :family
has_one_attached :logo
has_many :plaid_accounts, dependent: :destroy
has_many :accounts, through: :plaid_accounts
scope :active, -> { where(scheduled_for_deletion: false) }
scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) }
class << self
def create_from_public_token(token, item_name:, region:)
response = plaid_provider_for_region(region).exchange_public_token(token)
new_plaid_item = create!(
name: item_name,
plaid_id: response.item_id,
access_token: response.access_token,
plaid_region: region
)
new_plaid_item.sync_later
end
end
def sync_data(start_date: nil)
update!(last_synced_at: Time.current)
begin
Rails.logger.info("Fetching and loading Plaid data")
plaid_data = fetch_and_load_plaid_data
update!(status: :good) if requires_update?
# Schedule account syncs
accounts.each do |account|
account.sync_later(start_date: start_date)
end
Rails.logger.info("Plaid data fetched and loaded")
plaid_data
rescue Plaid::ApiError => e
handle_plaid_error(e)
raise e
end
end
def get_update_link_token(webhooks_url:, redirect_url:)
begin
family.get_link_token(
webhooks_url: webhooks_url,
redirect_url: redirect_url,
region: plaid_region,
access_token: access_token
)
rescue Plaid::ApiError => e
error_body = JSON.parse(e.response_body)
if error_body["error_code"] == "ITEM_NOT_FOUND"
# Mark the connection as invalid but don't auto-delete
update!(status: :requires_update)
raise PlaidConnectionLostError
else
raise e
end
end
end
def post_sync
family.broadcast_refresh
end
def destroy_later
update!(scheduled_for_deletion: true)
DestroyJob.perform_later(self)
end
private
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_provider.get_item(access_token).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_provider.get_institution(item.institution_id)
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_provider.get_item_accounts(self).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_accounts.find_or_create_from_plaid_data!(account, 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})"
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
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})"
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})"
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
data
end
def safe_fetch_plaid_data(method)
begin
plaid_provider.send(method, self)
rescue Plaid::ApiError => e
Rails.logger.warn("Error fetching #{method} for item #{id}: #{e.message}")
nil
end
end
def remove_plaid_item
plaid_provider.remove_item(access_token)
rescue StandardError => e
Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}")
end
def handle_plaid_error(error)
error_body = JSON.parse(error.response_body)
if error_body["error_code"] == "ITEM_LOGIN_REQUIRED"
update!(status: :requires_update)
end
end
class PlaidConnectionLostError < StandardError; end
end