1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-08 06:55:21 +02:00

Save work

This commit is contained in:
Zach Gollwitzer 2025-05-18 13:55:47 -04:00
parent b72a50f0aa
commit 09b27709c0
16 changed files with 278 additions and 116 deletions

View file

@ -0,0 +1,47 @@
# PlaidItem::Importer passes a raw payload retrieved from accounts_get API call, along with the PlaidAccount entity
# This class is responsible for making sense of that raw data, persisting it, and triggering transaction/investment/liability data imports for this account
class PlaidAccount::Importer
def initialize(plaid_account, account_data:, transactions_data:, investments_data:, liabilities_data:)
@plaid_account = plaid_account
@account_data = account_data
@transactions_data = transactions_data
@investments_data = investments_data
@liabilities_data = liabilities_data
end
def import
update_account_info
import_transactions if transactions_data.present?
import_investments if investments_data.present?
import_liabilities if liabilities_data.present?
end
private
attr_reader :plaid_account, :account_data, :transactions_data, :investments_data, :liabilities_data
def update_account_info
plaid_account.raw_payload = account_data
plaid_account.current_balance = account_data.balances.current
plaid_account.available_balance = account_data.balances.available
plaid_account.currency = account_data.balances.iso_currency_code
plaid_account.plaid_type = account_data.type
plaid_account.plaid_subtype = account_data.subtype
plaid_account.name = account_data.name
plaid_account.mask = account_data.mask
plaid_account.save!
end
def import_transactions
PlaidAccount::TransactionsImporter.new(plaid_account, transactions_data: transactions_data).import
end
def import_investments
PlaidAccount::InvestmentsImporter.new(plaid_account, investments_data: investments_data).import
end
def import_liabilities
PlaidAccount::LiabilitiesImporter.new(plaid_account, liabilities_data: liabilities_data).import
end
end

View file

@ -0,0 +1,13 @@
class PlaidAccount::InvestmentsImporter
def initialize(plaid_account, plaid_provider:)
@plaid_account = plaid_account
@plaid_provider = plaid_provider
end
def import
# TODO
end
private
attr_reader :plaid_account, :plaid_provider
end

View file

@ -0,0 +1,13 @@
class PlaidAccount::LiabilitiesImporter
def initialize(plaid_account, plaid_provider:)
@plaid_account = plaid_account
@plaid_provider = plaid_provider
end
def import
# TODO
end
private
attr_reader :plaid_account, :plaid_provider
end

View file

@ -0,0 +1,13 @@
class PlaidAccount::TransactionsImporter
def initialize(plaid_account, plaid_provider:)
@plaid_account = plaid_account
@plaid_provider = plaid_provider
end
def import
# TODO
end
private
attr_reader :plaid_account, :plaid_provider
end

View file

@ -60,18 +60,6 @@ class PlaidItem < ApplicationRecord
.exists? .exists?
end end
def transactions_enabled?
true # TODO
end
def investments_enabled?
true # TODO
end
def liabilities_enabled?
true
end
def auto_match_categories! def auto_match_categories!
if family.categories.none? if family.categories.none?
family.categories.bootstrap! family.categories.bootstrap!
@ -102,6 +90,13 @@ class PlaidItem < ApplicationRecord
end end
end 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
private private
# Silently swallow and report error so that we don't block the user from deleting the item # Silently swallow and report error so that we don't block the user from deleting the item
def remove_plaid_item def remove_plaid_item

View file

@ -1,35 +0,0 @@
class PlaidItem::AccountsImporter
def initialize(plaid_item)
@plaid_item = plaid_item
end
def import
raw_accounts_data = plaid_provider.get_item_accounts(plaid_item).accounts
raw_accounts_data.each do |raw_account_data|
PlaidAccount.transaction do
plaid_account = plaid_item.plaid_accounts.find_or_initialize_by(
plaid_id: raw_account_data.account_id
)
plaid_account.current_balance = raw_account_data.balances.current
plaid_account.available_balance = raw_account_data.balances.available
plaid_account.currency = raw_account_data.balances.iso_currency_code
plaid_account.plaid_type = raw_account_data.type
plaid_account.plaid_subtype = raw_account_data.subtype
# Save raw payload for audit trail
plaid_account.raw_payload = raw_account_data.to_h
plaid_account.save!
end
end
end
private
attr_reader :plaid_item
def plaid_provider
plaid_item.plaid_provider
end
end

View file

@ -1,38 +1,22 @@
class PlaidItem::Importer class PlaidItem::Importer
def initialize(plaid_item) def initialize(plaid_item, plaid_provider:)
@plaid_item = plaid_item @plaid_item = plaid_item
@plaid_provider = plaid_provider
end end
def import_data def import
begin import_item_metadata
import_item_metadata import_institution_metadata
rescue Plaid::ApiError => e
handle_plaid_error(e)
end
import_accounts import_accounts
import_transactions if plaid_item.transactions_enabled? rescue Plaid::ApiError => e
import_investments if plaid_item.investments_enabled? handle_plaid_error(e)
import_liabilities if plaid_item.liabilities_enabled?
end end
private private
attr_reader :plaid_item attr_reader :plaid_item, :plaid_provider
def plaid_provider # All errors that should halt the import should be re-raised after handling
plaid_item.plaid_provider # These errors will propagate up to the Sync record and mark it as failed.
end
def import_item_metadata
raw_item_data = plaid_provider.get_item(plaid_item.access_token)
plaid_item.update!(
available_products: raw_item_data.available_products,
billed_products: raw_item_data.billed_products
)
end
# Re-raise all errors that should halt data importing. Raising will propagate to
# the sync and mark it as failed.
def handle_plaid_error(error) def handle_plaid_error(error)
error_body = JSON.parse(error.response_body) error_body = JSON.parse(error.response_body)
@ -45,19 +29,77 @@ class PlaidItem::Importer
end end
end end
def import_item_metadata
item_response = plaid_provider.get_item(plaid_item.access_token)
item_data = item_response.item
# plaid_item.raw_payload = item_response
plaid_item.available_products = item_data.available_products
plaid_item.billed_products = item_data.billed_products
plaid_item.institution_id = item_data.institution_id
plaid_item.save!
end
def import_institution_metadata
institution_response = plaid_provider.get_institution(plaid_item.institution_id)
institution_data = institution_response.institution
# plaid_item.raw_institution_payload = institution_response
plaid_item.institution_id = institution_data.institution_id
plaid_item.institution_url = institution_data.url
plaid_item.institution_color = institution_data.primary_color
plaid_item.save!
end
def import_accounts def import_accounts
PlaidItem::AccountsImporter.new(plaid_item).import accounts_data = plaid_provider.get_item_accounts(plaid_item).accounts
PlaidItem.transaction do
accounts_data.each do |raw_account_payload|
plaid_account = plaid_item.plaid_accounts.find_or_initialize_by(
plaid_id: raw_account_payload.account_id
)
PlaidAccount::Importer.new(
plaid_account,
accounts_data: accounts_data,
transactions_data: transactions_data,
investments_data: investments_data,
liabilities_data: liabilities_data
).import
end
end
end end
def import_transactions def transactions_supported?
PlaidItem::TransactionsImporter.new(plaid_item).import plaid_item.supported_products.include?("transactions")
end end
def import_investments def investments_supported?
PlaidItem::InvestmentsImporter.new(plaid_item).import plaid_item.supported_products.include?("investments")
end end
def import_liabilities def liabilities_supported?
PlaidItem::LiabilitiesImporter.new(plaid_item).import plaid_item.supported_products.include?("liabilities")
end
def transactions_data
return nil unless transactions_supported?
plaid_provider.get_item_transactions(plaid_item).transactions
end
def investments_data
return nil unless investments_supported?
plaid_provider.get_item_investments(plaid_item).investments
end
def liabilities_data
return nil unless liabilities_supported?
plaid_provider.get_item_liabilities(plaid_item).liabilities
end end
end end

View file

@ -1,8 +0,0 @@
class PlaidItem::InvestmentsImporter
def initialize(plaid_item)
@plaid_item = plaid_item
end
def import_data
end
end

View file

@ -1,8 +0,0 @@
class PlaidItem::LiabilitiesImporter
def initialize(plaid_item)
@plaid_item = plaid_item
end
def import_data
end
end

View file

@ -10,16 +10,16 @@ class PlaidItem::Syncer
import_item_data import_item_data
# Processes the raw Plaid data and updates internal domain objects # Processes the raw Plaid data and updates internal domain objects
process_item_data # process_item_data
# All data is synced, so we can now run an account sync to calculate historical balances and more # All data is synced, so we can now run an account sync to calculate historical balances and more
plaid_item.reload.accounts.each do |account| # plaid_item.reload.accounts.each do |account|
account.sync_later( # account.sync_later(
parent_sync: sync, # parent_sync: sync,
window_start_date: sync.window_start_date, # window_start_date: sync.window_start_date,
window_end_date: sync.window_end_date # window_end_date: sync.window_end_date
) # )
end # end
end end
def perform_post_sync def perform_post_sync
@ -27,12 +27,12 @@ class PlaidItem::Syncer
end end
private private
def plaid def plaid_provider
plaid_item.plaid_provider plaid_item.plaid_provider
end end
def import_item_data def import_item_data
PlaidItem::Importer.new(plaid_item).import_data PlaidItem::Importer.new(plaid_item, plaid_provider: plaid_provider).import
end end
def process_item_data def process_item_data

View file

@ -1,8 +0,0 @@
class PlaidItem::TransactionsImporter
def initialize(plaid_item)
@plaid_item = plaid_item
end
def import_data
end
end

View file

@ -1,4 +1,7 @@
class AddRawPayloadToPlaidEntities < ActiveRecord::Migration[7.2] class AddRawPayloadToPlaidEntities < ActiveRecord::Migration[7.2]
def change def change
add_column :plaid_accounts, :raw_payload, :jsonb, default: {}
add_column :plaid_items, :raw_payload, :jsonb, default: {}
add_column :plaid_items, :raw_institution_payload, :jsonb, default: {}
end end
end end

5
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_05_16_180846) do ActiveRecord::Schema[7.2].define(version: 2025_05_18_133020) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@ -429,6 +429,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_16_180846) do
t.string "mask" t.string "mask"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.jsonb "raw_payload", default: {}
t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id"
end end
@ -448,6 +449,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_16_180846) do
t.string "institution_id" t.string "institution_id"
t.string "institution_color" t.string "institution_color"
t.string "status", default: "good", null: false 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 ["family_id"], name: "index_plaid_items_on_family_id"
end end

View file

@ -2,4 +2,6 @@ one:
family: dylan_family family: dylan_family
plaid_id: "1234567890" plaid_id: "1234567890"
access_token: encrypted_token_1 access_token: encrypted_token_1
name: "Test Bank" name: "Test Bank"
billed_products: ["transactions", "investments", "liabilities"]
available_products: []

View file

@ -0,0 +1,28 @@
require "test_helper"
class PlaidAccount::ImporterTest < ActiveSupport::TestCase
setup do
@mock_provider = mock("Provider::Plaid")
@plaid_account = plaid_accounts(:one)
end
test "imports account data" do
raw_payload = OpenStruct.new(
account_id: "123",
name: "Test Account",
mask: "1234",
type: "checking",
subtype: "checking",
)
PlaidAccount::Importer.new(@plaid_account, raw_payload, plaid_provider: @mock_provider).import
@plaid_account.reload
assert_equal "123", @plaid_account.plaid_id
assert_equal "Test Account", @plaid_account.name
assert_equal "1234", @plaid_account.mask
assert_equal "checking", @plaid_account.plaid_type
assert_equal "checking", @plaid_account.plaid_subtype
end
end

View file

@ -0,0 +1,62 @@
require "test_helper"
require "ostruct"
class PlaidItem::ImporterTest < ActiveSupport::TestCase
setup do
@mock_provider = mock("Provider::Plaid")
@plaid_item = plaid_items(:one)
end
test "imports item metadata" do
mock_institution_id = "123"
raw_item_payload = OpenStruct.new(
item: OpenStruct.new(
available_products: [],
billed_products: %w[transactions investments liabilities],
institution_id: mock_institution_id
)
)
raw_institution_payload = OpenStruct.new(
institution: OpenStruct.new(
institution_id: mock_institution_id,
url: "https://example.com",
primary_color: "#000000"
)
)
raw_accounts_payload = OpenStruct.new(
accounts: [
OpenStruct.new(
account_id: "123",
name: "Test Account",
mask: "1234",
type: "checking",
subtype: "checking",
)
]
)
@mock_provider.expects(:get_item).returns(raw_item_payload)
@mock_provider.expects(:get_institution).with(mock_institution_id).returns(raw_institution_payload)
@mock_provider.expects(:get_item_accounts).with(@plaid_item).returns(raw_accounts_payload)
@mock_provider.expects(:get_item_transactions).with(@plaid_item).returns(OpenStruct.new(transactions: []))
@mock_provider.expects(:get_item_investments).with(@plaid_item).returns(OpenStruct.new(investments: []))
@mock_provider.expects(:get_item_liabilities).with(@plaid_item).returns(OpenStruct.new(liabilities: []))
PlaidAccount::Importer.any_instance.expects(:import).times(raw_accounts_payload.accounts.count)
PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider).import
@plaid_item.reload
assert_equal mock_institution_id, @plaid_item.institution_id
assert_equal "https://example.com", @plaid_item.institution_url
assert_equal "#000000", @plaid_item.institution_color
assert_equal %w[transactions investments liabilities], @plaid_item.available_products
assert_equal %w[transactions investments liabilities], @plaid_item.billed_products
assert_not_nil @plaid_item.raw_payload
assert_not_nil @plaid_item.raw_institution_payload
end
end