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 09:33:23 -04:00
parent 9f13b5bb83
commit b72a50f0aa
14 changed files with 184 additions and 40 deletions

View file

@ -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

View file

@ -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

View 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

View 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

View file

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

View file

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

View file

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

View 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

View file

@ -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)

View file

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

View file

@ -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"]

View file

@ -0,0 +1,4 @@
class AddRawPayloadToPlaidEntities < ActiveRecord::Migration[7.2]
def change
end
end

View file

@ -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"

View file

@ -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