diff --git a/app/models/family/plaid_connectable.rb b/app/models/family/plaid_connectable.rb index f2a997c8..6eb432ba 100644 --- a/app/models/family/plaid_connectable.rb +++ b/app/models/family/plaid_connectable.rb @@ -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 diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 2ba10599..c92f1bed 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -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 diff --git a/app/models/plaid_item/accounts_importer.rb b/app/models/plaid_item/accounts_importer.rb new file mode 100644 index 00000000..307bb1a1 --- /dev/null +++ b/app/models/plaid_item/accounts_importer.rb @@ -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 diff --git a/app/models/plaid_item/importer.rb b/app/models/plaid_item/importer.rb new file mode 100644 index 00000000..296fbd37 --- /dev/null +++ b/app/models/plaid_item/importer.rb @@ -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 diff --git a/app/models/plaid_item/investments_importer.rb b/app/models/plaid_item/investments_importer.rb new file mode 100644 index 00000000..a2f4c0b5 --- /dev/null +++ b/app/models/plaid_item/investments_importer.rb @@ -0,0 +1,8 @@ +class PlaidItem::InvestmentsImporter + def initialize(plaid_item) + @plaid_item = plaid_item + end + + def import_data + end +end diff --git a/app/models/plaid_item/liabilities_importer.rb b/app/models/plaid_item/liabilities_importer.rb new file mode 100644 index 00000000..3404da82 --- /dev/null +++ b/app/models/plaid_item/liabilities_importer.rb @@ -0,0 +1,8 @@ +class PlaidItem::LiabilitiesImporter + def initialize(plaid_item) + @plaid_item = plaid_item + end + + def import_data + end +end diff --git a/app/models/plaid_item/processor.rb b/app/models/plaid_item/processor.rb new file mode 100644 index 00000000..a236c3df --- /dev/null +++ b/app/models/plaid_item/processor.rb @@ -0,0 +1,8 @@ +class PlaidItem::Processor + def initialize(plaid_item) + @plaid_item = plaid_item + end + + def process_data + end +end diff --git a/app/models/plaid_item/provided.rb b/app/models/plaid_item/provided.rb new file mode 100644 index 00000000..dc370b23 --- /dev/null +++ b/app/models/plaid_item/provided.rb @@ -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 diff --git a/app/models/plaid_item/syncer.rb b/app/models/plaid_item/syncer.rb index e32b9ed4..354c0ff4 100644 --- a/app/models/plaid_item/syncer.rb +++ b/app/models/plaid_item/syncer.rb @@ -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) diff --git a/app/models/plaid_item/transactions_importer.rb b/app/models/plaid_item/transactions_importer.rb new file mode 100644 index 00000000..45b9564f --- /dev/null +++ b/app/models/plaid_item/transactions_importer.rb @@ -0,0 +1,8 @@ +class PlaidItem::TransactionsImporter + def initialize(plaid_item) + @plaid_item = plaid_item + end + + def import_data + end +end diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index 16fa81a2..c2d37db0 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -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"] diff --git a/db/migrate/20250518133020_add_raw_payload_to_plaid_entities.rb b/db/migrate/20250518133020_add_raw_payload_to_plaid_entities.rb new file mode 100644 index 00000000..a86bc1d9 --- /dev/null +++ b/db/migrate/20250518133020_add_raw_payload_to_plaid_entities.rb @@ -0,0 +1,4 @@ +class AddRawPayloadToPlaidEntities < ActiveRecord::Migration[7.2] + def change + end +end diff --git a/test/controllers/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb index 4cf8e10a..f04f9c4d 100644 --- a/test/controllers/plaid_items_controller_test.rb +++ b/test/controllers/plaid_items_controller_test.rb @@ -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" diff --git a/test/models/plaid_item_test.rb b/test/models/plaid_item_test.rb index 8d680707..c9c80059 100644 --- a/test/models/plaid_item_test.rb +++ b/test/models/plaid_item_test.rb @@ -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