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
9f13b5bb83
commit
b72a50f0aa
14 changed files with 184 additions and 40 deletions
|
@ -6,9 +6,7 @@ module Family::PlaidConnectable
|
|||
end
|
||||
|
||||
def create_plaid_item!(public_token:, item_name:, region:)
|
||||
provider = plaid_provider_for_region(region)
|
||||
|
||||
public_token_response = provider.exchange_public_token(public_token)
|
||||
public_token_response = plaid(region).exchange_public_token(public_token)
|
||||
|
||||
plaid_item = plaid_items.create!(
|
||||
name: item_name,
|
||||
|
@ -23,11 +21,9 @@ module Family::PlaidConnectable
|
|||
end
|
||||
|
||||
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)
|
||||
|
||||
provider.get_link_token(
|
||||
plaid(region).get_link_token(
|
||||
user_id: self.id,
|
||||
webhooks_url: webhooks_url,
|
||||
redirect_url: redirect_url,
|
||||
|
@ -37,15 +33,7 @@ module Family::PlaidConnectable
|
|||
end
|
||||
|
||||
private
|
||||
def plaid_us
|
||||
@plaid ||= Provider::Registry.get_provider(:plaid_us)
|
||||
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
|
||||
def plaid(region)
|
||||
@plaid ||= Provider::Registry.plaid_provider_for_region(region)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class PlaidItem < ApplicationRecord
|
||||
include Syncable
|
||||
include Syncable, Provided
|
||||
|
||||
enum :plaid_region, { us: "us", eu: "eu" }
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
@ -60,6 +60,18 @@ 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!
|
||||
|
@ -91,10 +103,11 @@ class PlaidItem < ApplicationRecord
|
|||
end
|
||||
|
||||
private
|
||||
# Silently swallow and report error so that we don't block the user from deleting the item
|
||||
def remove_plaid_item
|
||||
plaid_provider.remove_item(access_token)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}")
|
||||
Sentry.capture_exception(e)
|
||||
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
|
||||
|
||||
def perform_sync(sync)
|
||||
begin
|
||||
Rails.logger.info("Fetching and loading Plaid data")
|
||||
fetch_and_load_plaid_data
|
||||
plaid_item.update!(status: :good) if plaid_item.requires_update?
|
||||
# Loads item metadata, accounts, transactions, and other data to our DB
|
||||
import_item_data
|
||||
|
||||
plaid_item.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
|
||||
# Processes the raw Plaid data and updates internal domain objects
|
||||
process_item_data
|
||||
|
||||
Rails.logger.info("Plaid data fetched and loaded")
|
||||
rescue Plaid::ApiError => e
|
||||
handle_plaid_error(e)
|
||||
raise e
|
||||
# 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
|
||||
end
|
||||
|
||||
|
@ -28,15 +28,15 @@ class PlaidItem::Syncer
|
|||
|
||||
private
|
||||
def plaid
|
||||
plaid_item.plaid_region == "eu" ? plaid_eu : plaid_us
|
||||
plaid_item.plaid_provider
|
||||
end
|
||||
|
||||
def plaid_eu
|
||||
@plaid_eu ||= Provider::Registry.get_provider(:plaid_eu)
|
||||
def import_item_data
|
||||
PlaidItem::Importer.new(plaid_item).import_data
|
||||
end
|
||||
|
||||
def plaid_us
|
||||
@plaid_us ||= Provider::Registry.get_provider(:plaid_us)
|
||||
def process_item_data
|
||||
PlaidItem::Processor.new(plaid_item).process_data
|
||||
end
|
||||
|
||||
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")
|
||||
end
|
||||
|
||||
def plaid_provider_for_region(region)
|
||||
region.to_sym == :us ? plaid_us : plaid_eu
|
||||
end
|
||||
|
||||
private
|
||||
def stripe
|
||||
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
|
||||
@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"
|
||||
|
||||
|
|
|
@ -5,11 +5,11 @@ class PlaidItemTest < ActiveSupport::TestCase
|
|||
|
||||
setup do
|
||||
@plaid_item = @syncable = plaid_items(:one)
|
||||
@plaid_provider = mock
|
||||
Provider::Registry.stubs(:plaid_provider_for_region).returns(@plaid_provider)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
assert_difference "PlaidItem.count", -1 do
|
||||
|
@ -18,8 +18,6 @@ class PlaidItemTest < ActiveSupport::TestCase
|
|||
end
|
||||
|
||||
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"))
|
||||
|
||||
assert_difference "PlaidItem.count", -1 do
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue