mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +02:00
Basic Plaid Integration (#1433)
* Basic plaid data model and linking * Remove institutions, add plaid items * Improve schema and Plaid provider * Add webhook verification sketch * Webhook verification * Item accounts and balances sync setup * Provide test encryption keys * Fix test * Only provide encryption keys in prod * Try defining keys in test env * Consolidate account sync logic * Add back plaid account initialization * Plaid transaction sync * Sync UI overhaul for Plaid * Add liability and investment syncing * Handle investment webhooks and process current day holdings * Remove logs * Remove "all" period select for performance * fix amount calc * Remove todo comment * Coming soon for investment historical data * Document Plaid configuration * Listen for holding updates
This commit is contained in:
parent
3bc9da4105
commit
cbba2ba675
127 changed files with 1537 additions and 841 deletions
|
@ -4,8 +4,8 @@ class Account < ApplicationRecord
|
|||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :institution, optional: true
|
||||
belongs_to :import, optional: true
|
||||
belongs_to :plaid_account, optional: true
|
||||
|
||||
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||
|
@ -14,18 +14,17 @@ class Account < ApplicationRecord
|
|||
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
|
||||
has_many :holdings, dependent: :destroy
|
||||
has_many :balances, dependent: :destroy
|
||||
has_many :syncs, dependent: :destroy
|
||||
has_many :issues, as: :issuable, dependent: :destroy
|
||||
|
||||
monetize :balance
|
||||
|
||||
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
|
||||
|
||||
scope :active, -> { where(is_active: true) }
|
||||
scope :active, -> { where(is_active: true, scheduled_for_deletion: false) }
|
||||
scope :assets, -> { where(classification: "asset") }
|
||||
scope :liabilities, -> { where(classification: "liability") }
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
scope :ungrouped, -> { where(institution_id: nil) }
|
||||
scope :manual, -> { where(plaid_account_id: nil) }
|
||||
|
||||
has_one_attached :logo
|
||||
|
||||
|
@ -87,6 +86,19 @@ class Account < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true)
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
def sync_data(start_date: nil)
|
||||
update!(last_synced_at: Time.current)
|
||||
|
||||
resolve_stale_issues
|
||||
Balance::Syncer.new(self, start_date: start_date).run
|
||||
Holding::Syncer.new(self, start_date: start_date).run
|
||||
end
|
||||
|
||||
def original_balance
|
||||
balance_amount = balances.chronological.first&.balance || balance
|
||||
Money.new(balance_amount, currency)
|
||||
|
|
|
@ -19,7 +19,12 @@ class Account::Balance::Loader
|
|||
|
||||
def update_account_balance!(balances)
|
||||
last_balance = balances.select { |db| db.currency == account.currency }.last&.balance
|
||||
account.update! balance: last_balance if last_balance.present?
|
||||
|
||||
if account.plaid_account.present?
|
||||
account.update! balance: account.plaid_account.current_balance || last_balance
|
||||
else
|
||||
account.update! balance: last_balance if last_balance.present?
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_balances!(balances)
|
||||
|
|
|
@ -87,7 +87,7 @@ class Account::Entry < ApplicationRecord
|
|||
class << self
|
||||
# arbitrary cutoff date to avoid expensive sync operations
|
||||
def min_supported_date
|
||||
20.years.ago.to_date
|
||||
30.years.ago.to_date
|
||||
end
|
||||
|
||||
def daily_totals(entries, currency, period: Period.last_30_days)
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
class Account::Holding::Syncer
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
@sync_date_range = calculate_sync_start_date(start_date)..Date.current
|
||||
end_date = account.plaid_account.present? ? 1.day.ago.to_date : Date.current
|
||||
@sync_date_range = calculate_sync_start_date(start_date)..end_date
|
||||
@portfolio = {}
|
||||
|
||||
load_prior_portfolio if start_date
|
||||
|
|
|
@ -1,82 +0,0 @@
|
|||
class Account::Sync < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" }
|
||||
|
||||
class << self
|
||||
def for(account, start_date: nil)
|
||||
create! account: account, start_date: start_date
|
||||
end
|
||||
|
||||
def latest
|
||||
order(created_at: :desc).first
|
||||
end
|
||||
end
|
||||
|
||||
def run
|
||||
start!
|
||||
|
||||
account.resolve_stale_issues
|
||||
|
||||
sync_balances
|
||||
sync_holdings
|
||||
|
||||
complete!
|
||||
rescue StandardError => error
|
||||
account.observe_unknown_issue(error)
|
||||
fail! error
|
||||
|
||||
raise error if Rails.env.development?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_balances
|
||||
Account::Balance::Syncer.new(account, start_date: start_date).run
|
||||
end
|
||||
|
||||
def sync_holdings
|
||||
Account::Holding::Syncer.new(account, start_date: start_date).run
|
||||
end
|
||||
|
||||
def start!
|
||||
update! status: "syncing", last_ran_at: Time.now
|
||||
broadcast_start
|
||||
end
|
||||
|
||||
def complete!
|
||||
update! status: "completed"
|
||||
|
||||
if account.has_issues?
|
||||
broadcast_result type: "alert", message: account.highest_priority_issue.title
|
||||
else
|
||||
broadcast_result type: "notice", message: "Sync complete"
|
||||
end
|
||||
end
|
||||
|
||||
def fail!(error)
|
||||
update! status: "failed", error: error.message
|
||||
broadcast_result type: "alert", message: I18n.t("account.sync.failed")
|
||||
end
|
||||
|
||||
def broadcast_start
|
||||
broadcast_append_to(
|
||||
[ account.family, :notifications ],
|
||||
target: "notification-tray",
|
||||
partial: "shared/notification",
|
||||
locals: { id: id, type: "processing", message: "Syncing account balances" }
|
||||
)
|
||||
end
|
||||
|
||||
def broadcast_result(type:, message:)
|
||||
broadcast_remove_to account.family, :notifications, target: id # Remove persistent syncing notification
|
||||
broadcast_append_to(
|
||||
[ account.family, :notifications ],
|
||||
target: "notification-tray",
|
||||
partial: "shared/notification",
|
||||
locals: { type: type, message: message }
|
||||
)
|
||||
|
||||
account.family.broadcast_refresh
|
||||
end
|
||||
end
|
|
@ -1,29 +0,0 @@
|
|||
module Account::Syncable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def sync(start_date: nil)
|
||||
all.each { |a| a.sync_later(start_date: start_date) }
|
||||
end
|
||||
end
|
||||
|
||||
def syncing?
|
||||
syncs.syncing.any?
|
||||
end
|
||||
|
||||
def latest_sync_date
|
||||
syncs.where.not(last_ran_at: nil).pluck(:last_ran_at).max&.to_date
|
||||
end
|
||||
|
||||
def needs_sync?
|
||||
latest_sync_date.nil? || latest_sync_date < Date.current
|
||||
end
|
||||
|
||||
def sync_later(start_date: nil)
|
||||
AccountSyncJob.perform_later(self, start_date: start_date)
|
||||
end
|
||||
|
||||
def sync(start_date: nil)
|
||||
Account::Sync.for(self, start_date: start_date).run
|
||||
end
|
||||
end
|
|
@ -12,7 +12,7 @@ class Account::Valuation < ApplicationRecord
|
|||
end
|
||||
|
||||
def name
|
||||
oldest? ? "Initial balance" : entry.name || "Balance update"
|
||||
entry.name || (oldest? ? "Initial balance" : "Balance update")
|
||||
end
|
||||
|
||||
def trend
|
||||
|
|
14
app/models/concerns/plaidable.rb
Normal file
14
app/models/concerns/plaidable.rb
Normal file
|
@ -0,0 +1,14 @@
|
|||
module Plaidable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def plaid_provider
|
||||
Provider::Plaid.new if Rails.application.config.plaid
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def plaid_provider
|
||||
self.class.plaid_provider
|
||||
end
|
||||
end
|
33
app/models/concerns/syncable.rb
Normal file
33
app/models/concerns/syncable.rb
Normal file
|
@ -0,0 +1,33 @@
|
|||
module Syncable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :syncs, as: :syncable, dependent: :destroy
|
||||
end
|
||||
|
||||
def syncing?
|
||||
syncs.where(status: [ :syncing, :pending ]).any?
|
||||
end
|
||||
|
||||
def sync_later(start_date: nil)
|
||||
new_sync = syncs.create!(start_date: start_date)
|
||||
SyncJob.perform_later(new_sync)
|
||||
end
|
||||
|
||||
def sync(start_date: nil)
|
||||
syncs.create!(start_date: start_date).perform
|
||||
end
|
||||
|
||||
def sync_data(start_date: nil)
|
||||
raise NotImplementedError, "Subclasses must implement the `sync_data` method"
|
||||
end
|
||||
|
||||
def sync_error
|
||||
latest_sync.error
|
||||
end
|
||||
|
||||
private
|
||||
def latest_sync
|
||||
syncs.order(created_at: :desc).first
|
||||
end
|
||||
end
|
|
@ -107,8 +107,7 @@ class Demo::Generator
|
|||
accountable: CreditCard.new,
|
||||
name: "Chase Credit Card",
|
||||
balance: 2300,
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Chase")
|
||||
currency: "USD"
|
||||
|
||||
50.times do
|
||||
merchant = random_family_record(Merchant)
|
||||
|
@ -134,8 +133,7 @@ class Demo::Generator
|
|||
accountable: Depository.new,
|
||||
name: "Chase Checking",
|
||||
balance: 15000,
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Chase")
|
||||
currency: "USD"
|
||||
|
||||
10.times do
|
||||
create_transaction! \
|
||||
|
@ -159,8 +157,7 @@ class Demo::Generator
|
|||
name: "Demo Savings",
|
||||
balance: 40000,
|
||||
currency: "USD",
|
||||
subtype: "savings",
|
||||
institution: family.institutions.find_or_create_by(name: "Chase")
|
||||
subtype: "savings"
|
||||
|
||||
income_category = categories.find { |c| c.name == "Income" }
|
||||
income_tag = tags.find { |t| t.name == "Emergency Fund" }
|
||||
|
@ -208,8 +205,7 @@ class Demo::Generator
|
|||
accountable: Investment.new,
|
||||
name: "Robinhood",
|
||||
balance: 100000,
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Robinhood")
|
||||
currency: "USD"
|
||||
|
||||
aapl = Security.find_by(ticker: "AAPL")
|
||||
tm = Security.find_by(ticker: "TM")
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
class Family < ApplicationRecord
|
||||
include Plaidable, Syncable
|
||||
|
||||
DATE_FORMATS = [ "%m-%d-%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ]
|
||||
|
||||
include Providable
|
||||
|
@ -7,17 +9,46 @@ class Family < ApplicationRecord
|
|||
has_many :invitations, dependent: :destroy
|
||||
has_many :tags, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :institutions, dependent: :destroy
|
||||
has_many :imports, dependent: :destroy
|
||||
has_many :transactions, through: :accounts
|
||||
has_many :entries, through: :accounts
|
||||
has_many :categories, dependent: :destroy
|
||||
has_many :merchants, dependent: :destroy
|
||||
has_many :issues, through: :accounts
|
||||
has_many :plaid_items, dependent: :destroy
|
||||
|
||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||
validates :date_format, inclusion: { in: DATE_FORMATS }
|
||||
|
||||
def sync_data(start_date: nil)
|
||||
update!(last_synced_at: Time.current)
|
||||
|
||||
accounts.manual.each do |account|
|
||||
account.sync_data(start_date: start_date)
|
||||
end
|
||||
|
||||
plaid_items.each do |plaid_item|
|
||||
plaid_item.sync_data(start_date: start_date)
|
||||
end
|
||||
end
|
||||
|
||||
def syncing?
|
||||
super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&:syncing?)
|
||||
end
|
||||
|
||||
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil)
|
||||
return nil unless plaid_provider
|
||||
|
||||
plaid_provider.get_link_token(
|
||||
user_id: id,
|
||||
country: country,
|
||||
language: locale,
|
||||
webhooks_url: webhooks_url,
|
||||
redirect_url: redirect_url,
|
||||
accountable_type: accountable_type
|
||||
).link_token
|
||||
end
|
||||
|
||||
def snapshot(period = Period.all)
|
||||
query = accounts.active.joins(:balances)
|
||||
.where("account_balances.currency = ?", self.currency)
|
||||
|
@ -116,20 +147,6 @@ class Family < ApplicationRecord
|
|||
Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
|
||||
end
|
||||
|
||||
def sync(start_date: nil)
|
||||
accounts.active.each do |account|
|
||||
if account.needs_sync?
|
||||
account.sync_later(start_date: start_date || account.last_sync_date)
|
||||
end
|
||||
end
|
||||
|
||||
update! last_synced_at: Time.now
|
||||
end
|
||||
|
||||
def needs_sync?
|
||||
last_synced_at.nil? || last_synced_at.to_date < Date.current
|
||||
end
|
||||
|
||||
def synth_usage
|
||||
self.class.synth_provider&.usage
|
||||
end
|
||||
|
|
|
@ -8,7 +8,7 @@ class Import::AccountMapping < Import::Mapping
|
|||
end
|
||||
|
||||
def selectable_values
|
||||
family_accounts = import.family.accounts.alphabetically.map { |account| [ account.name, account.id ] }
|
||||
family_accounts = import.family.accounts.manual.alphabetically.map { |account| [ account.name, account.id ] }
|
||||
|
||||
unless key.blank?
|
||||
family_accounts.unshift [ "Add as new account", CREATE_NEW_KEY ]
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
class Institution < ApplicationRecord
|
||||
belongs_to :family
|
||||
has_many :accounts, dependent: :nullify
|
||||
has_one_attached :logo
|
||||
|
||||
scope :alphabetically, -> { order(name: :asc) }
|
||||
|
||||
def sync
|
||||
accounts.active.each do |account|
|
||||
if account.needs_sync?
|
||||
account.sync
|
||||
end
|
||||
end
|
||||
|
||||
update! last_synced_at: Time.now
|
||||
end
|
||||
|
||||
def syncing?
|
||||
accounts.active.any? { |account| account.syncing? }
|
||||
end
|
||||
|
||||
def has_issues?
|
||||
accounts.active.any? { |account| account.has_issues? }
|
||||
end
|
||||
end
|
208
app/models/plaid_account.rb
Normal file
208
app/models/plaid_account.rb
Normal file
|
@ -0,0 +1,208 @@
|
|||
class PlaidAccount < ApplicationRecord
|
||||
include Plaidable
|
||||
|
||||
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
|
||||
|
||||
class << self
|
||||
def find_or_create_from_plaid_data!(plaid_data, family)
|
||||
find_or_create_by!(plaid_id: plaid_data.account_id) do |a|
|
||||
a.account = family.accounts.new(
|
||||
name: plaid_data.name,
|
||||
balance: plaid_data.balances.current,
|
||||
currency: plaid_data.balances.iso_currency_code,
|
||||
accountable: TYPE_MAPPING[plaid_data.type].new
|
||||
)
|
||||
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,
|
||||
balance: plaid_account_data.balances.current
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def sync_investments!(transactions:, holdings:, securities:)
|
||||
transactions.each do |transaction|
|
||||
if transaction.type == "cash"
|
||||
new_transaction = 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.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal])
|
||||
t.entryable = Account::Transaction.new
|
||||
end
|
||||
else
|
||||
security = get_security(transaction.security, securities)
|
||||
next if security.nil?
|
||||
new_transaction = 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 = Account::Trade.new(
|
||||
security: security,
|
||||
qty: transaction.quantity,
|
||||
price: transaction.price,
|
||||
currency: transaction.iso_currency_code
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Update only the current day holdings. The account sync will populate historical values based on trades.
|
||||
holdings.each do |holding|
|
||||
internal_security = get_security(holding.security, securities)
|
||||
next if internal_security.nil?
|
||||
|
||||
existing_holding = 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 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
|
||||
}
|
||||
)
|
||||
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
|
||||
}
|
||||
)
|
||||
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
|
||||
}
|
||||
)
|
||||
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.name
|
||||
t.amount = plaid_txn.amount
|
||||
t.currency = plaid_txn.iso_currency_code
|
||||
t.date = plaid_txn.date
|
||||
t.marked_as_transfer = transfer?(plaid_txn)
|
||||
t.entryable = Account::Transaction.new(
|
||||
category: get_category(plaid_txn.personal_finance_category.primary),
|
||||
merchant: get_merchant(plaid_txn.merchant_name)
|
||||
)
|
||||
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
|
||||
)
|
||||
end
|
||||
|
||||
removed.each do |plaid_txn|
|
||||
account.entries.find_by(plaid_id: plaid_txn.transaction_id)&.destroy
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
plaid_item.family
|
||||
end
|
||||
|
||||
def get_security(plaid_security, securities)
|
||||
security = nil
|
||||
|
||||
if plaid_security.ticker_symbol.present?
|
||||
security = plaid_security
|
||||
else
|
||||
security = securities.find { |s| s.security_id == plaid_security.proxy_security_id }
|
||||
end
|
||||
|
||||
Security.find_or_create_by!(
|
||||
ticker: security.ticker_symbol,
|
||||
exchange_mic: security.market_identifier_code || "XNAS",
|
||||
country_code: "US"
|
||||
) if security.present?
|
||||
end
|
||||
|
||||
def transfer?(plaid_txn)
|
||||
transfer_categories = [ "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS" ]
|
||||
|
||||
transfer_categories.include?(plaid_txn.personal_finance_category.primary)
|
||||
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 = Account::Valuation.new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# See https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv
|
||||
def get_category(plaid_category)
|
||||
ignored_categories = [ "BANK_FEES", "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS", "OTHER" ]
|
||||
|
||||
return nil if ignored_categories.include?(plaid_category)
|
||||
|
||||
family.categories.find_or_create_by!(name: plaid_category.titleize)
|
||||
end
|
||||
|
||||
def get_merchant(plaid_merchant_name)
|
||||
return nil if plaid_merchant_name.blank?
|
||||
|
||||
family.merchants.find_or_create_by!(name: plaid_merchant_name)
|
||||
end
|
||||
end
|
127
app/models/plaid_item.rb
Normal file
127
app/models/plaid_item.rb
Normal file
|
@ -0,0 +1,127 @@
|
|||
class PlaidItem < ApplicationRecord
|
||||
include Plaidable, Syncable
|
||||
|
||||
encrypts :access_token, deterministic: true
|
||||
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) }
|
||||
|
||||
class << self
|
||||
def create_from_public_token(token, item_name:)
|
||||
response = plaid_provider.exchange_public_token(token)
|
||||
|
||||
new_plaid_item = create!(
|
||||
name: item_name,
|
||||
plaid_id: response.item_id,
|
||||
access_token: response.access_token,
|
||||
)
|
||||
|
||||
new_plaid_item.sync_later
|
||||
end
|
||||
end
|
||||
|
||||
def sync_data(start_date: nil)
|
||||
update!(last_synced_at: Time.current)
|
||||
|
||||
fetch_and_load_plaid_data
|
||||
|
||||
accounts.each do |account|
|
||||
account.sync_data(start_date: start_date)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true)
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
def has_investment_accounts?
|
||||
available_products.include?("investments") || billed_products.include?("investments")
|
||||
end
|
||||
|
||||
def has_liability_accounts?
|
||||
available_products.include?("liabilities") || billed_products.include?("liabilities")
|
||||
end
|
||||
|
||||
private
|
||||
def fetch_and_load_plaid_data
|
||||
item = plaid_provider.get_item(access_token).item
|
||||
update!(available_products: item.available_products, billed_products: item.billed_products)
|
||||
|
||||
fetched_accounts = plaid_provider.get_item_accounts(self).accounts
|
||||
|
||||
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
|
||||
|
||||
fetched_transactions = safe_fetch_plaid_data(:get_item_transactions) unless has_investment_accounts?
|
||||
|
||||
if fetched_transactions
|
||||
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
|
||||
|
||||
fetched_investments = safe_fetch_plaid_data(:get_item_investments) if has_investment_accounts?
|
||||
|
||||
if fetched_investments
|
||||
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
|
||||
|
||||
fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities) if has_liability_accounts?
|
||||
|
||||
if fetched_liabilities
|
||||
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
|
||||
|
||||
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)
|
||||
end
|
||||
end
|
220
app/models/provider/plaid.rb
Normal file
220
app/models/provider/plaid.rb
Normal file
|
@ -0,0 +1,220 @@
|
|||
class Provider::Plaid
|
||||
attr_reader :client
|
||||
|
||||
PLAID_COUNTRY_CODES = %w[US GB ES NL FR IE CA DE IT PL DK NO SE EE LT LV PT BE].freeze
|
||||
PLAID_LANGUAGES = %w[da nl en et fr de hi it lv lt no pl pt ro es sv vi].freeze
|
||||
PLAID_PRODUCTS = %w[transactions investments liabilities].freeze
|
||||
MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730
|
||||
|
||||
class << self
|
||||
def process_webhook(webhook_body)
|
||||
parsed = JSON.parse(webhook_body)
|
||||
type = parsed["webhook_type"]
|
||||
code = parsed["webhook_code"]
|
||||
|
||||
item = PlaidItem.find_by(plaid_id: parsed["item_id"])
|
||||
|
||||
case [ type, code ]
|
||||
when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ]
|
||||
item.sync_later
|
||||
when [ "INVESTMENTS_TRANSACTIONS", "DEFAULT_UPDATE" ]
|
||||
item.sync_later
|
||||
when [ "HOLDINGS", "DEFAULT_UPDATE" ]
|
||||
item.sync_later
|
||||
else
|
||||
Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_webhook!(verification_header, raw_body)
|
||||
jwks_loader = ->(options) do
|
||||
key_id = options[:kid]
|
||||
|
||||
jwk_response = client.webhook_verification_key_get(
|
||||
Plaid::WebhookVerificationKeyGetRequest.new(key_id: key_id)
|
||||
)
|
||||
|
||||
jwks = JWT::JWK::Set.new([ jwk_response.key.to_hash ])
|
||||
|
||||
jwks.filter! { |key| key[:use] == "sig" }
|
||||
jwks
|
||||
end
|
||||
|
||||
payload, _header = JWT.decode(
|
||||
verification_header, nil, true,
|
||||
{
|
||||
algorithms: [ "ES256" ],
|
||||
jwks: jwks_loader,
|
||||
verify_expiration: false
|
||||
}
|
||||
)
|
||||
|
||||
issued_at = Time.at(payload["iat"])
|
||||
raise JWT::VerificationError, "Webhook is too old" if Time.now - issued_at > 5.minutes
|
||||
|
||||
expected_hash = payload["request_body_sha256"]
|
||||
actual_hash = Digest::SHA256.hexdigest(raw_body)
|
||||
raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash)
|
||||
end
|
||||
|
||||
def client
|
||||
api_client = Plaid::ApiClient.new(
|
||||
Rails.application.config.plaid
|
||||
)
|
||||
|
||||
Plaid::PlaidApi.new(api_client)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize
|
||||
@client = self.class.client
|
||||
end
|
||||
|
||||
def get_link_token(user_id:, country:, language: "en", webhooks_url:, redirect_url:, accountable_type: nil)
|
||||
request = Plaid::LinkTokenCreateRequest.new({
|
||||
user: { client_user_id: user_id },
|
||||
client_name: "Maybe Finance",
|
||||
products: get_products(accountable_type),
|
||||
country_codes: [ get_plaid_country_code(country) ],
|
||||
language: get_plaid_language(language),
|
||||
webhook: webhooks_url,
|
||||
redirect_uri: redirect_url,
|
||||
transactions: { days_requested: MAX_HISTORY_DAYS }
|
||||
})
|
||||
|
||||
client.link_token_create(request)
|
||||
end
|
||||
|
||||
def exchange_public_token(token)
|
||||
request = Plaid::ItemPublicTokenExchangeRequest.new(
|
||||
public_token: token
|
||||
)
|
||||
|
||||
client.item_public_token_exchange(request)
|
||||
end
|
||||
|
||||
def get_item(access_token)
|
||||
request = Plaid::ItemGetRequest.new(access_token: access_token)
|
||||
client.item_get(request)
|
||||
end
|
||||
|
||||
def remove_item(access_token)
|
||||
request = Plaid::ItemRemoveRequest.new(access_token: access_token)
|
||||
client.item_remove(request)
|
||||
end
|
||||
|
||||
def get_item_accounts(item)
|
||||
request = Plaid::AccountsGetRequest.new(access_token: item.access_token)
|
||||
client.accounts_get(request)
|
||||
end
|
||||
|
||||
def get_item_transactions(item)
|
||||
cursor = item.next_cursor
|
||||
added = []
|
||||
modified = []
|
||||
removed = []
|
||||
has_more = true
|
||||
|
||||
while has_more
|
||||
request = Plaid::TransactionsSyncRequest.new(
|
||||
access_token: item.access_token,
|
||||
cursor: cursor
|
||||
)
|
||||
|
||||
response = client.transactions_sync(request)
|
||||
|
||||
added += response.added
|
||||
modified += response.modified
|
||||
removed += response.removed
|
||||
has_more = response.has_more
|
||||
cursor = response.next_cursor
|
||||
end
|
||||
|
||||
TransactionSyncResponse.new(added:, modified:, removed:, cursor:)
|
||||
end
|
||||
|
||||
def get_item_investments(item, start_date: nil, end_date: Date.current)
|
||||
start_date = start_date || MAX_HISTORY_DAYS.days.ago.to_date
|
||||
holdings = get_item_holdings(item)
|
||||
transactions, securities = get_item_investment_transactions(item, start_date:, end_date:)
|
||||
|
||||
InvestmentsResponse.new(holdings:, transactions:, securities:)
|
||||
end
|
||||
|
||||
def get_item_liabilities(item)
|
||||
request = Plaid::LiabilitiesGetRequest.new({ access_token: item.access_token })
|
||||
response = client.liabilities_get(request)
|
||||
response.liabilities
|
||||
end
|
||||
|
||||
private
|
||||
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 })
|
||||
response = client.investments_holdings_get(request)
|
||||
|
||||
securities_by_id = response.securities.index_by(&:security_id)
|
||||
accounts_by_id = response.accounts.index_by(&:account_id)
|
||||
|
||||
response.holdings.each do |holding|
|
||||
holding.define_singleton_method(:security) { securities_by_id[holding.security_id] }
|
||||
holding.define_singleton_method(:account) { accounts_by_id[holding.account_id] }
|
||||
end
|
||||
|
||||
response.holdings
|
||||
end
|
||||
|
||||
def get_item_investment_transactions(item, start_date:, end_date:)
|
||||
transactions = []
|
||||
securities = []
|
||||
offset = 0
|
||||
|
||||
loop do
|
||||
request = Plaid::InvestmentsTransactionsGetRequest.new(
|
||||
access_token: item.access_token,
|
||||
start_date: start_date.to_s,
|
||||
end_date: end_date.to_s,
|
||||
options: { offset: offset }
|
||||
)
|
||||
|
||||
response = client.investments_transactions_get(request)
|
||||
securities_by_id = response.securities.index_by(&:security_id)
|
||||
accounts_by_id = response.accounts.index_by(&:account_id)
|
||||
|
||||
response.investment_transactions.each do |t|
|
||||
t.define_singleton_method(:security) { securities_by_id[t.security_id] }
|
||||
t.define_singleton_method(:account) { accounts_by_id[t.account_id] }
|
||||
transactions << t
|
||||
end
|
||||
|
||||
securities += response.securities
|
||||
|
||||
break if transactions.length >= response.total_investment_transactions
|
||||
offset = transactions.length
|
||||
end
|
||||
|
||||
[ transactions, securities ]
|
||||
end
|
||||
|
||||
def get_products(accountable_type)
|
||||
case accountable_type
|
||||
when "Investment"
|
||||
%w[investments]
|
||||
when "CreditCard", "Loan"
|
||||
%w[liabilities]
|
||||
else
|
||||
%w[transactions]
|
||||
end
|
||||
end
|
||||
|
||||
def get_plaid_country_code(country_code)
|
||||
PLAID_COUNTRY_CODES.include?(country_code) ? country_code : "US"
|
||||
end
|
||||
|
||||
def get_plaid_language(locale = "en")
|
||||
language = locale.split("-").first
|
||||
PLAID_LANGUAGES.include?(language) ? language : "en"
|
||||
end
|
||||
end
|
28
app/models/provider/plaid_sandbox.rb
Normal file
28
app/models/provider/plaid_sandbox.rb
Normal file
|
@ -0,0 +1,28 @@
|
|||
class Provider::PlaidSandbox < Provider::Plaid
|
||||
attr_reader :client
|
||||
|
||||
def initialize
|
||||
@client = create_client
|
||||
end
|
||||
|
||||
def fire_webhook(item, type: "TRANSACTIONS", code: "SYNC_UPDATES_AVAILABLE")
|
||||
client.sandbox_item_fire_webhook(
|
||||
Plaid::SandboxItemFireWebhookRequest.new(
|
||||
access_token: item.access_token,
|
||||
webhook_type: type,
|
||||
webhook_code: code,
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def create_client
|
||||
raise "Plaid sandbox is not supported in production" if Rails.env.production?
|
||||
|
||||
api_client = Plaid::ApiClient.new(
|
||||
Rails.application.config.plaid
|
||||
)
|
||||
|
||||
Plaid::PlaidApi.new(api_client)
|
||||
end
|
||||
end
|
39
app/models/sync.rb
Normal file
39
app/models/sync.rb
Normal file
|
@ -0,0 +1,39 @@
|
|||
class Sync < ApplicationRecord
|
||||
belongs_to :syncable, polymorphic: true
|
||||
|
||||
enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" }
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
def perform
|
||||
start!
|
||||
|
||||
syncable.sync_data(start_date: start_date)
|
||||
|
||||
complete!
|
||||
rescue StandardError => error
|
||||
fail! error
|
||||
raise error if Rails.env.development?
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
syncable.is_a?(Family) ? syncable : syncable.family
|
||||
end
|
||||
|
||||
def start!
|
||||
update! status: :syncing
|
||||
end
|
||||
|
||||
def complete!
|
||||
update! status: :completed, last_ran_at: Time.current
|
||||
|
||||
family.broadcast_refresh
|
||||
end
|
||||
|
||||
def fail!(error)
|
||||
update! status: :failed, error: error.message, last_ran_at: Time.current
|
||||
|
||||
family.broadcast_refresh
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue