mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
Save work
This commit is contained in:
parent
9f13b5bb83
commit
b72a50f0aa
14 changed files with 184 additions and 40 deletions
|
@ -6,9 +6,7 @@ module Family::PlaidConnectable
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_plaid_item!(public_token:, item_name:, region:)
|
def create_plaid_item!(public_token:, item_name:, region:)
|
||||||
provider = plaid_provider_for_region(region)
|
public_token_response = plaid(region).exchange_public_token(public_token)
|
||||||
|
|
||||||
public_token_response = provider.exchange_public_token(public_token)
|
|
||||||
|
|
||||||
plaid_item = plaid_items.create!(
|
plaid_item = plaid_items.create!(
|
||||||
name: item_name,
|
name: item_name,
|
||||||
|
@ -23,11 +21,9 @@ module Family::PlaidConnectable
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil)
|
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil)
|
||||||
return nil unless plaid_us || plaid_eu
|
return nil unless plaid(region)
|
||||||
|
|
||||||
provider = plaid_provider_for_region(region)
|
plaid(region).get_link_token(
|
||||||
|
|
||||||
provider.get_link_token(
|
|
||||||
user_id: self.id,
|
user_id: self.id,
|
||||||
webhooks_url: webhooks_url,
|
webhooks_url: webhooks_url,
|
||||||
redirect_url: redirect_url,
|
redirect_url: redirect_url,
|
||||||
|
@ -37,15 +33,7 @@ module Family::PlaidConnectable
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def plaid_us
|
def plaid(region)
|
||||||
@plaid ||= Provider::Registry.get_provider(:plaid_us)
|
@plaid ||= Provider::Registry.plaid_provider_for_region(region)
|
||||||
end
|
|
||||||
|
|
||||||
def plaid_eu
|
|
||||||
@plaid_eu ||= Provider::Registry.get_provider(:plaid_eu)
|
|
||||||
end
|
|
||||||
|
|
||||||
def plaid_provider_for_region(region)
|
|
||||||
region.to_sym == :eu ? plaid_eu : plaid_us
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class PlaidItem < ApplicationRecord
|
class PlaidItem < ApplicationRecord
|
||||||
include Syncable
|
include Syncable, Provided
|
||||||
|
|
||||||
enum :plaid_region, { us: "us", eu: "eu" }
|
enum :plaid_region, { us: "us", eu: "eu" }
|
||||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||||
|
@ -60,6 +60,18 @@ 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!
|
||||||
|
@ -91,10 +103,11 @@ class PlaidItem < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
# 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
|
||||||
plaid_provider.remove_item(access_token)
|
plaid_provider.remove_item(access_token)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}")
|
Sentry.capture_exception(e)
|
||||||
end
|
end
|
||||||
|
|
||||||
class PlaidConnectionLostError < StandardError; end
|
class PlaidConnectionLostError < StandardError; end
|
||||||
|
|
35
app/models/plaid_item/accounts_importer.rb
Normal file
35
app/models/plaid_item/accounts_importer.rb
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
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
|
63
app/models/plaid_item/importer.rb
Normal file
63
app/models/plaid_item/importer.rb
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
class PlaidItem::Importer
|
||||||
|
def initialize(plaid_item)
|
||||||
|
@plaid_item = plaid_item
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_data
|
||||||
|
begin
|
||||||
|
import_item_metadata
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
def handle_plaid_error(error)
|
||||||
|
error_body = JSON.parse(error.response_body)
|
||||||
|
|
||||||
|
case error_body["error_code"]
|
||||||
|
when "ITEM_LOGIN_REQUIRED"
|
||||||
|
plaid_item.update!(status: :requires_update)
|
||||||
|
raise error
|
||||||
|
else
|
||||||
|
raise error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_accounts
|
||||||
|
PlaidItem::AccountsImporter.new(plaid_item).import
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_transactions
|
||||||
|
PlaidItem::TransactionsImporter.new(plaid_item).import
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_investments
|
||||||
|
PlaidItem::InvestmentsImporter.new(plaid_item).import
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_liabilities
|
||||||
|
PlaidItem::LiabilitiesImporter.new(plaid_item).import
|
||||||
|
end
|
||||||
|
end
|
8
app/models/plaid_item/investments_importer.rb
Normal file
8
app/models/plaid_item/investments_importer.rb
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
class PlaidItem::InvestmentsImporter
|
||||||
|
def initialize(plaid_item)
|
||||||
|
@plaid_item = plaid_item
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_data
|
||||||
|
end
|
||||||
|
end
|
8
app/models/plaid_item/liabilities_importer.rb
Normal file
8
app/models/plaid_item/liabilities_importer.rb
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
class PlaidItem::LiabilitiesImporter
|
||||||
|
def initialize(plaid_item)
|
||||||
|
@plaid_item = plaid_item
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_data
|
||||||
|
end
|
||||||
|
end
|
8
app/models/plaid_item/processor.rb
Normal file
8
app/models/plaid_item/processor.rb
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
class PlaidItem::Processor
|
||||||
|
def initialize(plaid_item)
|
||||||
|
@plaid_item = plaid_item
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_data
|
||||||
|
end
|
||||||
|
end
|
7
app/models/plaid_item/provided.rb
Normal file
7
app/models/plaid_item/provided.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
module PlaidItem::Provided
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def plaid_provider
|
||||||
|
@plaid_provider ||= Provider::Registry.plaid_provider_for_region(self.plaid_region)
|
||||||
|
end
|
||||||
|
end
|
|
@ -6,19 +6,19 @@ class PlaidItem::Syncer
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform_sync(sync)
|
def perform_sync(sync)
|
||||||
begin
|
# Loads item metadata, accounts, transactions, and other data to our DB
|
||||||
Rails.logger.info("Fetching and loading Plaid data")
|
import_item_data
|
||||||
fetch_and_load_plaid_data
|
|
||||||
plaid_item.update!(status: :good) if plaid_item.requires_update?
|
|
||||||
|
|
||||||
plaid_item.accounts.each do |account|
|
# Processes the raw Plaid data and updates internal domain objects
|
||||||
account.sync_later(parent_sync: sync, window_start_date: sync.window_start_date, window_end_date: sync.window_end_date)
|
process_item_data
|
||||||
end
|
|
||||||
|
|
||||||
Rails.logger.info("Plaid data fetched and loaded")
|
# All data is synced, so we can now run an account sync to calculate historical balances and more
|
||||||
rescue Plaid::ApiError => e
|
plaid_item.reload.accounts.each do |account|
|
||||||
handle_plaid_error(e)
|
account.sync_later(
|
||||||
raise e
|
parent_sync: sync,
|
||||||
|
window_start_date: sync.window_start_date,
|
||||||
|
window_end_date: sync.window_end_date
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -28,15 +28,15 @@ class PlaidItem::Syncer
|
||||||
|
|
||||||
private
|
private
|
||||||
def plaid
|
def plaid
|
||||||
plaid_item.plaid_region == "eu" ? plaid_eu : plaid_us
|
plaid_item.plaid_provider
|
||||||
end
|
end
|
||||||
|
|
||||||
def plaid_eu
|
def import_item_data
|
||||||
@plaid_eu ||= Provider::Registry.get_provider(:plaid_eu)
|
PlaidItem::Importer.new(plaid_item).import_data
|
||||||
end
|
end
|
||||||
|
|
||||||
def plaid_us
|
def process_item_data
|
||||||
@plaid_us ||= Provider::Registry.get_provider(:plaid_us)
|
PlaidItem::Processor.new(plaid_item).process_data
|
||||||
end
|
end
|
||||||
|
|
||||||
def safe_fetch_plaid_data(method)
|
def safe_fetch_plaid_data(method)
|
||||||
|
|
8
app/models/plaid_item/transactions_importer.rb
Normal file
8
app/models/plaid_item/transactions_importer.rb
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
class PlaidItem::TransactionsImporter
|
||||||
|
def initialize(plaid_item)
|
||||||
|
@plaid_item = plaid_item
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_data
|
||||||
|
end
|
||||||
|
end
|
|
@ -18,6 +18,10 @@ class Provider::Registry
|
||||||
raise Error.new("Provider '#{name}' not found in registry")
|
raise Error.new("Provider '#{name}' not found in registry")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def plaid_provider_for_region(region)
|
||||||
|
region.to_sym == :us ? plaid_us : plaid_eu
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def stripe
|
def stripe
|
||||||
secret_key = ENV["STRIPE_SECRET_KEY"]
|
secret_key = ENV["STRIPE_SECRET_KEY"]
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
class AddRawPayloadToPlaidEntities < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
end
|
||||||
|
end
|
|
@ -8,7 +8,7 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
test "create" do
|
test "create" do
|
||||||
@plaid_provider = mock
|
@plaid_provider = mock
|
||||||
Provider::Registry.expects(:get_provider).with(:plaid_us).returns(@plaid_provider)
|
Provider::Registry.expects(:plaid_provider_for_region).with("us").returns(@plaid_provider)
|
||||||
|
|
||||||
public_token = "public-sandbox-1234"
|
public_token = "public-sandbox-1234"
|
||||||
|
|
||||||
|
|
|
@ -5,11 +5,11 @@ class PlaidItemTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
@plaid_item = @syncable = plaid_items(:one)
|
@plaid_item = @syncable = plaid_items(:one)
|
||||||
|
@plaid_provider = mock
|
||||||
|
Provider::Registry.stubs(:plaid_provider_for_region).returns(@plaid_provider)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "removes plaid item when destroyed" do
|
test "removes plaid item when destroyed" do
|
||||||
@plaid_provider = mock
|
|
||||||
@plaid_item.stubs(:plaid_provider).returns(@plaid_provider)
|
|
||||||
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).once
|
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).once
|
||||||
|
|
||||||
assert_difference "PlaidItem.count", -1 do
|
assert_difference "PlaidItem.count", -1 do
|
||||||
|
@ -18,8 +18,6 @@ class PlaidItemTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "if plaid item not found, silently continues with deletion" do
|
test "if plaid item not found, silently continues with deletion" do
|
||||||
@plaid_provider = mock
|
|
||||||
@plaid_item.stubs(:plaid_provider).returns(@plaid_provider)
|
|
||||||
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).raises(Plaid::ApiError.new("Item not found"))
|
@plaid_provider.expects(:remove_item).with(@plaid_item.access_token).raises(Plaid::ApiError.new("Item not found"))
|
||||||
|
|
||||||
assert_difference "PlaidItem.count", -1 do
|
assert_difference "PlaidItem.count", -1 do
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue