1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-02 20:15:22 +02:00

Basic Plaid Integration (#1433)
Some checks are pending
Publish Docker image / ci (push) Waiting to run
Publish Docker image / Build docker image (push) Blocked by required conditions

* 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:
Zach Gollwitzer 2024-11-15 13:49:37 -05:00 committed by GitHub
parent 3bc9da4105
commit cbba2ba675
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
127 changed files with 1537 additions and 841 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View 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

View file

@ -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")

View file

@ -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

View file

@ -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 ]

View file

@ -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
View 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
View 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

View 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

View 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
View 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