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:
parent
b72a50f0aa
commit
09b27709c0
16 changed files with 278 additions and 116 deletions
47
app/models/plaid_account/importer.rb
Normal file
47
app/models/plaid_account/importer.rb
Normal 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
|
13
app/models/plaid_account/investments_importer.rb
Normal file
13
app/models/plaid_account/investments_importer.rb
Normal 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
|
13
app/models/plaid_account/liabilities_importer.rb
Normal file
13
app/models/plaid_account/liabilities_importer.rb
Normal 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
|
13
app/models/plaid_account/transactions_importer.rb
Normal file
13
app/models/plaid_account/transactions_importer.rb
Normal 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
|
|
@ -60,18 +60,6 @@ class PlaidItem < ApplicationRecord
|
|||
.exists?
|
||||
end
|
||||
|
||||
def transactions_enabled?
|
||||
true # TODO
|
||||
end
|
||||
|
||||
def investments_enabled?
|
||||
true # TODO
|
||||
end
|
||||
|
||||
def liabilities_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
def auto_match_categories!
|
||||
if family.categories.none?
|
||||
family.categories.bootstrap!
|
||||
|
@ -102,6 +90,13 @@ class PlaidItem < ApplicationRecord
|
|||
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
|
||||
# Silently swallow and report error so that we don't block the user from deleting the item
|
||||
def remove_plaid_item
|
||||
|
|
|
@ -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
|
|
@ -1,38 +1,22 @@
|
|||
class PlaidItem::Importer
|
||||
def initialize(plaid_item)
|
||||
def initialize(plaid_item, plaid_provider:)
|
||||
@plaid_item = plaid_item
|
||||
@plaid_provider = plaid_provider
|
||||
end
|
||||
|
||||
def import_data
|
||||
begin
|
||||
def import
|
||||
import_item_metadata
|
||||
import_institution_metadata
|
||||
import_accounts
|
||||
rescue Plaid::ApiError => e
|
||||
handle_plaid_error(e)
|
||||
end
|
||||
|
||||
import_accounts
|
||||
import_transactions if plaid_item.transactions_enabled?
|
||||
import_investments if plaid_item.investments_enabled?
|
||||
import_liabilities if plaid_item.liabilities_enabled?
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :plaid_item
|
||||
attr_reader :plaid_item, :plaid_provider
|
||||
|
||||
def plaid_provider
|
||||
plaid_item.plaid_provider
|
||||
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.
|
||||
# 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)
|
||||
|
||||
|
@ -45,19 +29,77 @@ class PlaidItem::Importer
|
|||
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
|
||||
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
|
||||
|
||||
def import_transactions
|
||||
PlaidItem::TransactionsImporter.new(plaid_item).import
|
||||
def transactions_supported?
|
||||
plaid_item.supported_products.include?("transactions")
|
||||
end
|
||||
|
||||
def import_investments
|
||||
PlaidItem::InvestmentsImporter.new(plaid_item).import
|
||||
def investments_supported?
|
||||
plaid_item.supported_products.include?("investments")
|
||||
end
|
||||
|
||||
def import_liabilities
|
||||
PlaidItem::LiabilitiesImporter.new(plaid_item).import
|
||||
def liabilities_supported?
|
||||
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
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
class PlaidItem::InvestmentsImporter
|
||||
def initialize(plaid_item)
|
||||
@plaid_item = plaid_item
|
||||
end
|
||||
|
||||
def import_data
|
||||
end
|
||||
end
|
|
@ -1,8 +0,0 @@
|
|||
class PlaidItem::LiabilitiesImporter
|
||||
def initialize(plaid_item)
|
||||
@plaid_item = plaid_item
|
||||
end
|
||||
|
||||
def import_data
|
||||
end
|
||||
end
|
|
@ -10,16 +10,16 @@ class PlaidItem::Syncer
|
|||
import_item_data
|
||||
|
||||
# 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
|
||||
plaid_item.reload.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
|
||||
# plaid_item.reload.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
|
||||
end
|
||||
|
||||
def perform_post_sync
|
||||
|
@ -27,12 +27,12 @@ class PlaidItem::Syncer
|
|||
end
|
||||
|
||||
private
|
||||
def plaid
|
||||
def plaid_provider
|
||||
plaid_item.plaid_provider
|
||||
end
|
||||
|
||||
def import_item_data
|
||||
PlaidItem::Importer.new(plaid_item).import_data
|
||||
PlaidItem::Importer.new(plaid_item, plaid_provider: plaid_provider).import
|
||||
end
|
||||
|
||||
def process_item_data
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
class PlaidItem::TransactionsImporter
|
||||
def initialize(plaid_item)
|
||||
@plaid_item = plaid_item
|
||||
end
|
||||
|
||||
def import_data
|
||||
end
|
||||
end
|
|
@ -1,4 +1,7 @@
|
|||
class AddRawPayloadToPlaidEntities < ActiveRecord::Migration[7.2]
|
||||
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
|
||||
|
|
5
db/schema.rb
generated
5
db/schema.rb
generated
|
@ -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_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
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -429,6 +429,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_16_180846) do
|
|||
t.string "mask"
|
||||
t.datetime "created_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"
|
||||
end
|
||||
|
||||
|
@ -448,6 +449,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_16_180846) 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"
|
||||
end
|
||||
|
||||
|
|
2
test/fixtures/plaid_items.yml
vendored
2
test/fixtures/plaid_items.yml
vendored
|
@ -3,3 +3,5 @@ one:
|
|||
plaid_id: "1234567890"
|
||||
access_token: encrypted_token_1
|
||||
name: "Test Bank"
|
||||
billed_products: ["transactions", "investments", "liabilities"]
|
||||
available_products: []
|
28
test/models/plaid_account/importer_test.rb
Normal file
28
test/models/plaid_account/importer_test.rb
Normal 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
|
62
test/models/plaid_item/importer_test.rb
Normal file
62
test/models/plaid_item/importer_test.rb
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue