From 3796d7e9e954e3a8cc1cd2843d664027579ac9f6 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 29 Oct 2024 14:09:18 -0400 Subject: [PATCH 01/23] Basic plaid data model and linking --- Gemfile | 1 + Gemfile.lock | 4 ++ app/controllers/accounts_controller.rb | 11 ++++ app/controllers/application_controller.rb | 8 +++ app/controllers/plaid_items_controller.rb | 17 +++++ app/controllers/webhooks_controller.rb | 3 + .../controllers/plaid_controller.js | 63 +++++++++++++++++++ app/models/family.rb | 1 + app/models/plaid_item.rb | 6 ++ app/views/accounts/new.html.erb | 8 ++- app/views/layouts/application.html.erb | 1 + config/credentials.yml.enc | 2 +- config/initializers/plaid.rb | 6 ++ config/locales/views/accounts/en.yml | 2 +- config/routes.rb | 8 ++- db/migrate/20241028210231_add_plaid_item.rb | 18 ++++++ db/schema.rb | 26 +++++++- test/models/plaid_item_test.rb | 10 +++ 18 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 app/controllers/plaid_items_controller.rb create mode 100644 app/javascript/controllers/plaid_controller.js create mode 100644 app/models/plaid_item.rb create mode 100644 config/initializers/plaid.rb create mode 100644 db/migrate/20241028210231_add_plaid_item.rb create mode 100644 test/models/plaid_item_test.rb diff --git a/Gemfile b/Gemfile index cdb9fca059f..50d68c62ed3 100644 --- a/Gemfile +++ b/Gemfile @@ -49,6 +49,7 @@ gem "redcarpet" gem "stripe" gem "intercom-rails" gem "holidays" +gem "plaid" group :development, :test do gem "debug", platforms: %i[mri windows] diff --git a/Gemfile.lock b/Gemfile.lock index dc2c8dee305..3c81f5c5aa1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -278,6 +278,9 @@ GEM ast (~> 2.4.1) racc pg (1.5.9) + plaid (33.0.0) + faraday (>= 1.0.1, < 3.0) + faraday-multipart (>= 1.0.1, < 2.0) prism (1.2.0) propshaft (1.1.0) actionpack (>= 7.0.0) @@ -496,6 +499,7 @@ DEPENDENCIES octokit pagy pg (~> 1.5) + plaid propshaft puma (>= 5.0) rails (~> 7.2.1) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 214193656fe..7b7dbc4f1c2 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -23,6 +23,7 @@ def list end def new + @link_token = plaid.link_token_create(plaid_link_token_request).link_token @account = Account.new(currency: Current.family.currency) @account.accountable = Accountable.from_type(params[:type])&.new if params[:type].present? @account.accountable.address = Address.new if @account.accountable.is_a?(Property) @@ -73,6 +74,16 @@ def sync_all end private + def plaid_link_token_request + Plaid::LinkTokenCreateRequest.new({ + user: { client_user_id: Current.user.family.id }, + client_name: "Maybe", + products: %w[transactions], + country_codes: [ Current.user.family.country ], + language: "en", + webhook: webhooks_plaid_url + }) + end def set_account @account = Current.family.accounts.find(params[:id]) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e978892045e..43b87dc5e40 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -14,6 +14,14 @@ def require_upgrade? true end + def plaid + api_client = Plaid::ApiClient.new( + Rails.application.config.plaid + ) + + Plaid::PlaidApi.new(api_client) + end + def subscription_pending? subscribed_at = Current.session.subscribed_at subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb new file mode 100644 index 00000000000..32376727507 --- /dev/null +++ b/app/controllers/plaid_items_controller.rb @@ -0,0 +1,17 @@ +class PlaidItemsController < ApplicationController + def create + request = Plaid::ItemPublicTokenExchangeRequest.new + request.public_token = plaid_item_params[:public_token] + + response = plaid.item_public_token_exchange(request) + + # Current.family.plaid_items.create!(item_access_token: response.access_token) + + render json: { status: :ok } + end + + private + def plaid_item_params + params.require(:plaid_item).permit(:public_token, metadata: {}) + end +end diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 23e431f7976..4f89167e221 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -2,6 +2,9 @@ class WebhooksController < ApplicationController skip_before_action :verify_authenticity_token, only: [ :stripe ] skip_authentication + def plaid + end + def stripe webhook_body = request.body.read sig_header = request.env["HTTP_STRIPE_SIGNATURE"] diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js new file mode 100644 index 00000000000..4ede33baba3 --- /dev/null +++ b/app/javascript/controllers/plaid_controller.js @@ -0,0 +1,63 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="plaid" +export default class extends Controller { + static values = { + linkToken: String, + }; + + connect() { + console.log("Plaid connect"); + console.log(this.linkTokenValue); + } + + open() { + const handler = Plaid.create({ + token: this.linkTokenValue, + onSuccess: this.handleSuccess, + onLoad: this.handleLoad, + onExit: this.handleExit, + onEvent: this.handleEvent, + }); + + handler.open(); + } + + handleSuccess(public_token, metadata) { + fetch("/plaid_items", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, + }, + body: JSON.stringify({ + plaid_item: { + public_token: public_token, + metadata: metadata, + }, + }), + }) + .then((response) => { + if (!response.ok) throw new Error("Network response was not ok"); + return response.json(); + }) + .then((data) => { + console.log("Success:", data); + }) + .catch((error) => { + console.error("Error:", error); + }); + } + + handleExit(err, metadata) { + // no-op + } + + handleEvent(eventName, metadata) { + // no-op + } + + handleLoad() { + // no-op + } +} diff --git a/app/models/family.rb b/app/models/family.rb index ddfdab3e386..444b93529dc 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -13,6 +13,7 @@ class Family < ApplicationRecord has_many :categories, dependent: :destroy has_many :merchants, dependent: :destroy has_many :issues, through: :accounts + has_many :plaid_items, dependent: :destroy validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS } diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb new file mode 100644 index 00000000000..85a5e8134a3 --- /dev/null +++ b/app/models/plaid_item.rb @@ -0,0 +1,6 @@ +class PlaidItem < ApplicationRecord + encrypts :item_access_token, deterministic: true + validates :item_access_token, presence: true + + belongs_to :family +end diff --git a/app/views/accounts/new.html.erb b/app/views/accounts/new.html.erb index 9e8a562f8a2..afd0042e9fe 100644 --- a/app/views/accounts/new.html.erb +++ b/app/views/accounts/new.html.erb @@ -9,6 +9,13 @@ + + <%= render "entry_method", text: t(".manual_entry"), icon: "keyboard" %> <%= link_to new_import_path(import: { type: "AccountImport" }), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %> @@ -18,7 +25,6 @@ <%= t(".csv_entry") %> <% end %> - <%= render "entry_method", text: t(".connected_entry"), icon: "link-2", disabled: true %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 1cead360b48..1a3db66940c 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -9,6 +9,7 @@ <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %> <%= javascript_importmap_tags %> <%= hotwire_livereload_tags if Rails.env.development? %> <%= turbo_refreshes_with method: :morph, scroll: :preserve %> diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 024d5e68b9a..142afb999d6 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -KRWOjsn+8XsQvywXPDphgdRfbxouM6sML006oZNoiXQE3QMj/B7ee53nsT40Pgt37yyu6Adn84SZTkOs0sbOtHlEKQOr8fagJ96bZsQiIqtWA3JIDrHSVygssRIYVD8LyB1ezS+Oa4rZh4NsGJGQdyxlyUVmdFpqT6s18ptFnraDJuf54pkPyl5zAxtIVufXWO2wbyXryE2XEoYfHNmuPO3rtvXOQS9gEYe5yxh1EqgTG32UKnwxCNXBCksgrzuy9qebVTiBRun1L2S7diRw94dZ1mgkIweDAJCwO5wBtgfqo8DWBPunKbqJ5gwRJTvELDXXCWcnPjCUGPSPPBw4clUXNbjkxMltFlC5EReiTb7fi2rRGM64cRZlgReh8RVy6pyiKM2tHUI3Tmdk4q7nwTBCy6ot--ZUWi92DcBx1HlcZg--SVhHEO5AJCLD9LlhuXA+kA== \ No newline at end of file +HMC62biPQuF61XA8tnd/kvwdV2xr/zpfJxG+IHNgGtpuvPXi9oS+YemBGMLte+1Q7elzAAbmKg73699hVLkRcBCk/FaMQjGRF2lnJ9MpxSR/br8Uma2bSH40lIEjxAfzjr4JPSfsHxlArF30hfd+B9obPDOptLQbpENPBsmiuEHX7S0Y8SmKuzDUVrvdfeLoVuMiAZqOP5izpBAbXfvMjI3YH70iJAaPlfAxQqR89O2nSt+N27siyyfkypE3NHQKZFz+Rmo8uJDlaD3eo/uvQN4xsgRCMUar4X2iY4UOd+MIGAPqLzIUhhJ56G5MRDJ4XpJA6RDuGFc/LNyxdXt0WinUX8Yz7zKiKah1NkEhTkH+b2ylFbsN6cjlqcX0yw8Gw8B4osyHQGnj7Tuf1c8k1z3gBoaQALm8zxKCaJ9k6CopVM2GmbpCLcJqjN1L71wCe6MiWsv9LDF/pwuZNG6hWn0oykdkWeBEQyK8g4Wo1AHqgEi8XtRwbaX6yugO5WQFhjQG/LzXcG02E5Co5/r/G7ZSFpRC9ngoOx3LY6MihPRkTIOumCg3HHtAsWBeHe4L/rDIe4A=--hlLxVbnyuYXf7Rku--A6Cwdr3CAW6bRkl1rcRmRw== \ No newline at end of file diff --git a/config/initializers/plaid.rb b/config/initializers/plaid.rb new file mode 100644 index 00000000000..1d9a3e0370a --- /dev/null +++ b/config/initializers/plaid.rb @@ -0,0 +1,6 @@ +Rails.application.configure do + config.plaid = Plaid::Configuration.new + config.plaid.server_index = Plaid::Configuration::Environment[ENV["PLAID_ENV"] || "sandbox"] + config.plaid.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_CLIENT_ID"] + config.plaid.api_key["PLAID-SECRET"] = ENV["PLAID_SECRET"] +end diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index f7885d6c298..4244e634f9d 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -149,7 +149,7 @@ en: edit: Edit import: Import transactions new: - connected_entry: Securely link account with Plaid (coming soon) + connected_entry: Securely link account with Plaid csv_entry: Import accounts CSV manual_entry: Enter account manually title: Add an account diff --git a/config/routes.rb b/config/routes.rb index 978e349ad43..bc6ce98529a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -130,8 +130,12 @@ end end - # Stripe webhook endpoint - post "webhooks/stripe", to: "webhooks#stripe" + resources :plaid_items, only: :create + + namespace :webhooks do + post "plaid", to: "webhooks#plaid" + post "stripe", to: "webhooks#stripe" + end # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. diff --git a/db/migrate/20241028210231_add_plaid_item.rb b/db/migrate/20241028210231_add_plaid_item.rb new file mode 100644 index 00000000000..482b30fb3a8 --- /dev/null +++ b/db/migrate/20241028210231_add_plaid_item.rb @@ -0,0 +1,18 @@ +class AddPlaidItem < ActiveRecord::Migration[7.2] + def change + create_table :plaid_items, id: :uuid do |t| + t.references :family, null: false, type: :uuid, foreign_key: true + t.string :plaid_access_token_digest + t.string :plaid_id + t.timestamps + end + + create_table :plaid_accounts, id: :uuid do |t| + t.references :plaid_item, null: false, type: :uuid, foreign_key: true + t.references :account, null: false, type: :uuid, foreign_key: true + t.string :plaid_id + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7445d127276..65fef84712b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_10_25_174650) do +ActiveRecord::Schema[7.2].define(version: 2024_10_28_210231) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -465,6 +465,25 @@ t.datetime "updated_at", null: false end + create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "plaid_item_id", null: false + t.uuid "account_id", null: false + t.string "plaid_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_plaid_accounts_on_account_id" + t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" + end + + create_table "plaid_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "plaid_access_token_digest" + t.string "plaid_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_plaid_items_on_family_id" + end + create_table "properties", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -481,7 +500,9 @@ t.string "country_code" t.string "exchange_mic" t.string "exchange_acronym" + t.virtual "search_vector", type: :tsvector, as: "(setweight(to_tsvector('simple'::regconfig, (COALESCE(ticker, ''::character varying))::text), 'B'::\"char\") || to_tsvector('simple'::regconfig, (COALESCE(name, ''::character varying))::text))", stored: true t.index ["country_code"], name: "index_securities_on_country_code" + t.index ["search_vector"], name: "index_securities_on_search_vector", using: :gin t.index ["ticker", "exchange_mic"], name: "index_securities_on_ticker_and_exchange_mic", unique: true end @@ -604,6 +625,9 @@ add_foreign_key "imports", "families" add_foreign_key "institutions", "families" add_foreign_key "merchants", "families" + add_foreign_key "plaid_accounts", "accounts" + add_foreign_key "plaid_accounts", "plaid_items" + add_foreign_key "plaid_items", "families" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" add_foreign_key "sessions", "users" add_foreign_key "taggings", "tags" diff --git a/test/models/plaid_item_test.rb b/test/models/plaid_item_test.rb new file mode 100644 index 00000000000..0f3b85c72c5 --- /dev/null +++ b/test/models/plaid_item_test.rb @@ -0,0 +1,10 @@ +require "test_helper" + +class PlaidItemTest < ActiveSupport::TestCase + test "stores hashed access token" do + # access_token = '' + # plaid_item = PlaidItem.create!(family: families(:one), access_token: "1234567890") + # assert_equal PlaidItem.find(plaid_item.id).access_token, plaid_item.access_token + skip + end +end From 775cd90bfa206c0275fe5f01ce8f54470f88b5fe Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 6 Nov 2024 16:31:47 -0500 Subject: [PATCH 02/23] Remove institutions, add plaid items --- app/controllers/accounts_controller.rb | 3 +- .../concerns/accountable_resource.rb | 25 +++++++- app/controllers/institutions_controller.rb | 40 ------------ app/controllers/plaid_items_controller.rb | 12 ++-- app/controllers/properties_controller.rb | 3 +- app/helpers/institutions_helper.rb | 5 -- app/models/account.rb | 3 +- app/models/demo/generator.rb | 12 ++-- app/models/family.rb | 1 - app/models/plaid_account.rb | 5 ++ app/models/plaid_item.rb | 12 ++++ app/views/accounts/_account_type.html.erb | 2 +- app/views/accounts/_form.html.erb | 6 -- app/views/accounts/index.html.erb | 19 +----- .../_account_groups.erb} | 0 ...nts.html.erb => _manual_accounts.html.erb} | 2 +- ...accounts.html.erb => _plaid_item.html.erb} | 44 ++++--------- .../accounts/new/_method_selector.html.erb | 16 ++--- app/views/credit_cards/new.html.erb | 2 +- app/views/cryptos/new.html.erb | 2 +- app/views/depositories/new.html.erb | 2 +- app/views/institutions/_form.html.erb | 26 -------- app/views/institutions/edit.html.erb | 3 - app/views/institutions/new.html.erb | 3 - app/views/investments/new.html.erb | 2 +- app/views/layouts/application.html.erb | 1 + app/views/loans/new.html.erb | 2 +- config/locales/views/accounts/en.yml | 25 +++----- config/locales/views/institutions/en.yml | 17 ----- config/locales/views/plaid_items/en.yml | 5 ++ config/routes.rb | 3 - ....rb => 20241106193743_add_plaid_domain.rb} | 11 +++- db/schema.rb | 26 +++----- .../institutions_controller_test.rb | 62 ------------------- .../plaid_items_controller_test.rb | 22 +++++++ test/fixtures/accounts.yml | 2 - test/fixtures/institutions.yml | 8 --- .../accountable_resource_interface_test.rb | 6 +- test/system/accounts_test.rb | 4 ++ 39 files changed, 147 insertions(+), 297 deletions(-) delete mode 100644 app/controllers/institutions_controller.rb delete mode 100644 app/helpers/institutions_helper.rb create mode 100644 app/models/plaid_account.rb rename app/views/accounts/{_accountable_group.html.erb => index/_account_groups.erb} (100%) rename app/views/accounts/index/{_institutionless_accounts.html.erb => _manual_accounts.html.erb} (89%) rename app/views/accounts/index/{_institution_accounts.html.erb => _plaid_item.html.erb} (50%) delete mode 100644 app/views/institutions/_form.html.erb delete mode 100644 app/views/institutions/edit.html.erb delete mode 100644 app/views/institutions/new.html.erb delete mode 100644 config/locales/views/institutions/en.yml create mode 100644 config/locales/views/plaid_items/en.yml rename db/migrate/{20241028210231_add_plaid_item.rb => 20241106193743_add_plaid_domain.rb} (62%) delete mode 100644 test/controllers/institutions_controller_test.rb create mode 100644 test/controllers/plaid_items_controller_test.rb delete mode 100644 test/fixtures/institutions.yml diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 5c463b212da..8572226801a 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -4,8 +4,7 @@ class AccountsController < ApplicationController before_action :set_account, only: %i[sync] def index - @institutions = Current.family.institutions - @accounts = Current.family.accounts.ungrouped.alphabetically + @accounts = Current.family.accounts end def summary diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 29ab519df3b..0c1b94a3869 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -14,10 +14,10 @@ def permitted_accountable_attributes(*attrs) end def new + @link_token = plaid.link_token_create(plaid_link_token_request).link_token @account = Current.family.accounts.build( currency: Current.family.currency, - accountable: accountable_type.new, - institution_id: params[:institution_id] + accountable: accountable_type.new ) end @@ -53,8 +53,27 @@ def set_account def account_params params.require(:account).permit( - :name, :is_active, :balance, :subtype, :currency, :institution_id, :accountable_type, :return_to, + :name, :is_active, :balance, :subtype, :currency, :accountable_type, :return_to, accountable_attributes: self.class.permitted_accountable_attributes ) end + + def plaid_link_token_request + { + client_id: Rails.application.config.plaid[:client_id], + secret: Rails.application.config.plaid[:secret], + country_codes: [ "US" ] + } + end + + def plaid_link_token_request + Plaid::LinkTokenCreateRequest.new({ + user: { client_user_id: Current.user.family.id }, + client_name: "Maybe", + products: %w[transactions], + country_codes: [ Current.user.family.country ], + language: "en", + webhook: webhooks_plaid_url + }) + end end diff --git a/app/controllers/institutions_controller.rb b/app/controllers/institutions_controller.rb deleted file mode 100644 index ba5f719d5cd..00000000000 --- a/app/controllers/institutions_controller.rb +++ /dev/null @@ -1,40 +0,0 @@ -class InstitutionsController < ApplicationController - before_action :set_institution, except: %i[new create] - - def new - @institution = Institution.new - end - - def create - Current.family.institutions.create!(institution_params) - redirect_to accounts_path, notice: t(".success") - end - - def edit - end - - def update - @institution.update!(institution_params) - redirect_to accounts_path, notice: t(".success") - end - - def destroy - @institution.destroy! - redirect_to accounts_path, notice: t(".success") - end - - def sync - @institution.sync - redirect_back_or_to accounts_path, notice: t(".success") - end - - private - - def institution_params - params.require(:institution).permit(:name, :logo) - end - - def set_institution - @institution = Current.family.institutions.find(params[:id]) - end -end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index 32376727507..48e8d4feee6 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -1,17 +1,19 @@ class PlaidItemsController < ApplicationController def create - request = Plaid::ItemPublicTokenExchangeRequest.new - request.public_token = plaid_item_params[:public_token] + puts plaid_item_params[:metadata] + request = Plaid::ItemPublicTokenExchangeRequest.new( + public_token: plaid_item_params[:public_token] + ) response = plaid.item_public_token_exchange(request) - # Current.family.plaid_items.create!(item_access_token: response.access_token) + Current.family.plaid_items.create!(item_access_token: response.access_token) - render json: { status: :ok } + redirect_to accounts_path, notice: t(".success") end private def plaid_item_params - params.require(:plaid_item).permit(:public_token, metadata: {}) + params.require(:plaid_item).permit(:public_token, :metadata) end end diff --git a/app/controllers/properties_controller.rb b/app/controllers/properties_controller.rb index d1cd21cf9fb..fb6048e9541 100644 --- a/app/controllers/properties_controller.rb +++ b/app/controllers/properties_controller.rb @@ -11,8 +11,7 @@ def new currency: Current.family.currency, accountable: Property.new( address: Address.new - ), - institution_id: params[:institution_id] + ) ) end diff --git a/app/helpers/institutions_helper.rb b/app/helpers/institutions_helper.rb deleted file mode 100644 index 3fa70e37d78..00000000000 --- a/app/helpers/institutions_helper.rb +++ /dev/null @@ -1,5 +0,0 @@ -module InstitutionsHelper - def institution_logo(institution) - institution.logo.attached? ? institution.logo : institution.logo_url - end -end diff --git a/app/models/account.rb b/app/models/account.rb index bc4f7123331..a716481b851 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -4,8 +4,8 @@ class Account < ApplicationRecord validates :name, :balance, :currency, presence: true belongs_to :family - belongs_to :institution, optional: true belongs_to :import, optional: true + belongs_to :plaid_account, optional: true has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" has_many :entries, dependent: :destroy, class_name: "Account::Entry" @@ -25,7 +25,6 @@ class Account < ApplicationRecord scope :assets, -> { where(classification: "asset") } scope :liabilities, -> { where(classification: "liability") } scope :alphabetically, -> { order(:name) } - scope :ungrouped, -> { where(institution_id: nil) } has_one_attached :logo diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 48a28d2e631..29985a365b6 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -107,8 +107,7 @@ def create_credit_card_account! accountable: CreditCard.new, name: "Chase Credit Card", balance: 2300, - currency: "USD", - institution: family.institutions.find_or_create_by(name: "Chase") + currency: "USD" 50.times do merchant = random_family_record(Merchant) @@ -134,8 +133,7 @@ def create_checking_account! accountable: Depository.new, name: "Chase Checking", balance: 15000, - currency: "USD", - institution: family.institutions.find_or_create_by(name: "Chase") + currency: "USD" 10.times do create_transaction! \ @@ -159,8 +157,7 @@ def create_savings_account! name: "Demo Savings", balance: 40000, currency: "USD", - subtype: "savings", - institution: family.institutions.find_or_create_by(name: "Chase") + subtype: "savings" income_category = categories.find { |c| c.name == "Income" } income_tag = tags.find { |t| t.name == "Emergency Fund" } @@ -208,8 +205,7 @@ def create_investment_account! accountable: Investment.new, name: "Robinhood", balance: 100000, - currency: "USD", - institution: family.institutions.find_or_create_by(name: "Robinhood") + currency: "USD" aapl = Security.find_by(ticker: "AAPL") tm = Security.find_by(ticker: "TM") diff --git a/app/models/family.rb b/app/models/family.rb index 2474b2920fc..0c4ba4d110d 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -7,7 +7,6 @@ class Family < ApplicationRecord has_many :invitations, dependent: :destroy has_many :tags, dependent: :destroy has_many :accounts, dependent: :destroy - has_many :institutions, dependent: :destroy has_many :imports, dependent: :destroy has_many :transactions, through: :accounts has_many :entries, through: :accounts diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb new file mode 100644 index 00000000000..6ba6b8b01ee --- /dev/null +++ b/app/models/plaid_account.rb @@ -0,0 +1,5 @@ +class PlaidAccount < ApplicationRecord + belongs_to :plaid_item + + has_one :account, dependent: :restrict_with_exception +end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 85a5e8134a3..a122e0e39e2 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -3,4 +3,16 @@ class PlaidItem < ApplicationRecord validates :item_access_token, presence: true belongs_to :family + has_one_attached :logo + + has_many :plaid_accounts, dependent: :destroy + has_many :accounts, through: :plaid_accounts + + def has_issues? + false + end + + def syncing? + false + end end diff --git a/app/views/accounts/_account_type.html.erb b/app/views/accounts/_account_type.html.erb index a82915c40ad..ffcc90ae303 100644 --- a/app/views/accounts/_account_type.html.erb +++ b/app/views/accounts/_account_type.html.erb @@ -1,6 +1,6 @@ <%# locals: (accountable:) %> -<%= link_to new_polymorphic_path(accountable, institution_id: params[:institution_id], step: "method_select", return_to: params[:return_to]), +<%= link_to new_polymorphic_path(accountable, step: "method_select", return_to: params[:return_to]), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-alpha-black-25 hover:bg-alpha-black-25 border border-transparent block px-2 rounded-lg p-2" do %> <%= lucide_icon(accountable.icon, style: "color: #{accountable.color}", class: "w-5 h-5") %> diff --git a/app/views/accounts/_form.html.erb b/app/views/accounts/_form.html.erb index 88e30bea737..2910879be5d 100644 --- a/app/views/accounts/_form.html.erb +++ b/app/views/accounts/_form.html.erb @@ -5,12 +5,6 @@ <%= form.hidden_field :accountable_type %> <%= form.hidden_field :return_to, value: params[:return_to] %> - <% if account.new_record? %> - <%= form.hidden_field :institution_id %> - <% else %> - <%= form.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %> - <% end %> - <%= form.text_field :name, placeholder: t(".name_placeholder"), required: "required", label: t(".name_label") %> <%= form.money_field :balance, label: t(".balance"), required: true, default_currency: Current.family.currency %> diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 431fe4cdc8f..37e3e29bec0 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -7,17 +7,6 @@

<%= t(".accounts") %>

- <%= contextual_menu do %> -
- <%= link_to new_institution_path, - class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal", - data: { turbo_frame: "modal" } do %> - <%= lucide_icon "building-2", class: "w-5 h-5 text-gray-500" %> - <%= t(".add_institution") %> - <% end %> -
- <% end %> - <%= render "sync_all_button" %> <%= link_to new_account_path(return_to: accounts_path), @@ -30,16 +19,12 @@
- <% if @accounts.empty? && @institutions.empty? %> + <% if @accounts.empty? %> <%= render "empty" %> <% else %>
- <% @institutions.each do |institution| %> - <%= render "accounts/index/institution_accounts", institution: %> - <% end %> - <% if @accounts.any? %> - <%= render "accounts/index/institutionless_accounts", accounts: @accounts %> + <%= render "accounts/index/manual_accounts", accounts: @accounts %> <% end %>
<% end %> diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/index/_account_groups.erb similarity index 100% rename from app/views/accounts/_accountable_group.html.erb rename to app/views/accounts/index/_account_groups.erb diff --git a/app/views/accounts/index/_institutionless_accounts.html.erb b/app/views/accounts/index/_manual_accounts.html.erb similarity index 89% rename from app/views/accounts/index/_institutionless_accounts.html.erb rename to app/views/accounts/index/_manual_accounts.html.erb index 335e842e834..9fe0779afad 100644 --- a/app/views/accounts/index/_institutionless_accounts.html.erb +++ b/app/views/accounts/index/_manual_accounts.html.erb @@ -12,6 +12,6 @@
- <%= render "accountable_group", accounts: accounts %> + <%= render "accounts/index/account_groups", accounts: accounts %>
diff --git a/app/views/accounts/index/_institution_accounts.html.erb b/app/views/accounts/index/_plaid_item.html.erb similarity index 50% rename from app/views/accounts/index/_institution_accounts.html.erb rename to app/views/accounts/index/_plaid_item.html.erb index a1127077ae0..df177055c96 100644 --- a/app/views/accounts/index/_institution_accounts.html.erb +++ b/app/views/accounts/index/_plaid_item.html.erb @@ -1,4 +1,4 @@ -<%# locals: (institution:) %> +<%# locals: (plaid_item:) %>
@@ -6,57 +6,41 @@ <%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-gray-500 w-5" %>
- <% if institution_logo(institution) %> - <%= image_tag institution_logo(institution), class: "rounded-full h-full w-full" %> + <% if plaid_item.logo.attached? %> + <%= image_tag plaid_item.logo, class: "rounded-full h-full w-full" %> <% else %>
- <%= tag.p institution.name.first.upcase, class: "text-blue-600 text-xs font-medium" %> + <%= tag.p plaid_item.name.first.upcase, class: "text-blue-600 text-xs font-medium" %>
<% end %>
- <%= link_to institution.name, edit_institution_path(institution), data: { turbo_frame: :modal }, class: "font-medium text-gray-900 hover:underline" %> - <% if institution.has_issues? %> + <%= link_to plaid_item.name, edit_plaid_item_path(plaid_item), data: { turbo_frame: :modal }, class: "font-medium text-gray-900 hover:underline" %> + <% if plaid_item.has_issues? %>
<%= lucide_icon "alert-octagon", class: "shrink-0 w-4 h-4" %> <%= tag.span t(".has_issues") %>
- <% elsif institution.syncing? %> + <% elsif plaid_item.syncing? %>
<%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %> <%= tag.span t(".syncing") %>
<% else %> -

<%= institution.last_synced_at ? t(".status", last_synced_at: time_ago_in_words(institution.last_synced_at)) : t(".status_never") %>

+

<%= plaid_item.last_synced_at ? t(".status", last_synced_at: time_ago_in_words(plaid_item.last_synced_at)) : t(".status_never") %>

<% end %>
- <%= button_to sync_institution_path(institution), method: :post, class: "text-gray-900 flex hover:text-gray-800 items-center text-sm font-medium hover:underline" do %> + <%= button_to sync_plaid_item_path(plaid_item), method: :post, class: "text-gray-900 flex hover:text-gray-800 items-center text-sm font-medium hover:underline" do %> <%= lucide_icon "refresh-cw", class: "w-4 h-4" %> <% end %> <%= contextual_menu do %>
- <%= link_to new_account_path(institution_id: institution.id, return_to: accounts_path), - class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg", - data: { turbo_frame: :modal } do %> - <%= lucide_icon "plus", class: "w-5 h-5 text-gray-500" %> - - <%= t(".add_account_to_institution") %> - <% end %> - - <%= link_to edit_institution_path(institution), - class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg", - data: { turbo_frame: :modal } do %> - <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %> - - <%= t(".edit") %> - <% end %> - - <%= button_to institution_path(institution), + <%= button_to plaid_item_path(plaid_item), method: :delete, class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg", data: { @@ -76,15 +60,11 @@
- <% if institution.accounts.any? %> - <%= render "accountable_group", accounts: institution.accounts %> + <% if plaid_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: plaid_item.accounts %> <% else %>

There are no accounts in this financial institution

- <%= link_to new_account_path(institution_id: institution.id, return_to: accounts_path), class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-1.5 pr-2", data: { turbo_frame: "modal" } do %> - <%= lucide_icon("plus", class: "w-4 h-4") %> - <%= t(".new_account") %> - <% end %>
<% end %>
diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb index 2760ff4f457..4c32ede4af3 100644 --- a/app/views/accounts/new/_method_selector.html.erb +++ b/app/views/accounts/new/_method_selector.html.erb @@ -1,4 +1,4 @@ -<%# locals: (path:) %> +<%# locals: (path:, link_token: nil) %> <%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
@@ -9,11 +9,13 @@ <%= t("accounts.new.method_selector.manual_entry") %> <% end %> - - - <%= lucide_icon("link-2", class: "text-gray-500 w-5 h-5") %> - - <%= t("accounts.new.method_selector.connected_entry") %> - + <% if link_token.present? %> + + <% end %>
<% end %> diff --git a/app/views/credit_cards/new.html.erb b/app/views/credit_cards/new.html.erb index 0e13c2a5831..46645cb2fae 100644 --- a/app/views/credit_cards/new.html.erb +++ b/app/views/credit_cards/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_credit_card_path(institution_id: params[:institution_id], return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_credit_card_path(return_to: params[:return_to]) %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "credit_cards/form", account: @account, url: credit_cards_path %> diff --git a/app/views/cryptos/new.html.erb b/app/views/cryptos/new.html.erb index 38cb112bb60..6f6433d2a85 100644 --- a/app/views/cryptos/new.html.erb +++ b/app/views/cryptos/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_crypto_path(institution_id: params[:institution_id], return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_crypto_path(return_to: params[:return_to]) %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "cryptos/form", account: @account, url: cryptos_path %> diff --git a/app/views/depositories/new.html.erb b/app/views/depositories/new.html.erb index db8f4ff5e84..9247ca2c847 100644 --- a/app/views/depositories/new.html.erb +++ b/app/views/depositories/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_depository_path(institution_id: params[:institution_id], return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_depository_path(return_to: params[:return_to]), link_token: @link_token %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "depositories/form", account: @account, url: depositories_path %> diff --git a/app/views/institutions/_form.html.erb b/app/views/institutions/_form.html.erb deleted file mode 100644 index dd72fb34e05..00000000000 --- a/app/views/institutions/_form.html.erb +++ /dev/null @@ -1,26 +0,0 @@ -<%= styled_form_with model: institution, class: "space-y-4", data: { turbo_frame: "_top", controller: "profile-image-preview" } do |f| %> -
- <%= f.label :logo do %> -
- <% persisted_logo = institution_logo(institution) %> - - <% if persisted_logo %> - <%= image_tag persisted_logo, class: "absolute inset-0 rounded-full w-full h-full object-cover" %> - <% end %> - -
- <% unless persisted_logo %> - <%= lucide_icon "image-plus", class: "w-5 h-5 text-gray-500 cursor-pointer", data: { profile_image_preview_target: "template" } %> - <% end %> -
-
- <% end %> -
- - <%= f.file_field :logo, - accept: "image/png, image/jpeg", - class: "hidden", - data: { profile_image_preview_target: "fileField", action: "profile-image-preview#preview" } %> - <%= f.text_field :name, label: t(".name") %> - <%= f.submit %> -<% end %> diff --git a/app/views/institutions/edit.html.erb b/app/views/institutions/edit.html.erb deleted file mode 100644 index 75c815798ec..00000000000 --- a/app/views/institutions/edit.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= modal_form_wrapper title: t(".edit", institution: @institution.name) do %> - <%= render "form", institution: @institution %> -<% end %> diff --git a/app/views/institutions/new.html.erb b/app/views/institutions/new.html.erb deleted file mode 100644 index 94c36193c79..00000000000 --- a/app/views/institutions/new.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= modal_form_wrapper title: t(".new_institution") do %> - <%= render "form", institution: @institution %> -<% end %> diff --git a/app/views/investments/new.html.erb b/app/views/investments/new.html.erb index 5e83350d610..e1db18ef290 100644 --- a/app/views/investments/new.html.erb +++ b/app/views/investments/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_investment_path(institution_id: params[:institution_id], return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_investment_path(return_to: params[:return_to]) %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "investments/form", account: @account, url: investments_path %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 1c4d3b4e7e2..fb0e012ba01 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -9,6 +9,7 @@ <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %> <%= combobox_style_tag %> <%= javascript_importmap_tags %> diff --git a/app/views/loans/new.html.erb b/app/views/loans/new.html.erb index 407e648de14..d9eeaa6ae50 100644 --- a/app/views/loans/new.html.erb +++ b/app/views/loans/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_loan_path(institution_id: params[:institution_id], return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_loan_path(return_to: params[:return_to]) %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "loans/form", account: @account, url: loans_path %> diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index b3dcb1af15a..ea03da7936f 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -12,34 +12,27 @@ en: no_accounts: No accounts yet form: balance: Current balance - institution: Financial institution name_label: Account name name_placeholder: Example account name - ungrouped: "(none)" index: accounts: Accounts - add_institution: Add institution - institution_accounts: - add_account_to_institution: Add new account + manual_accounts: + other_accounts: Other accounts + new_account: New account + plaid_item: confirm_accept: Delete institution - confirm_body: Don't worry, none of the accounts within this institution will - be affected by this deletion. Accounts will be ungrouped and all historical - data will remain intact. - confirm_title: Delete financial institution? - delete: Delete institution - edit: Edit institution + confirm_body: This will permanently delete all the accounts in this group + and all associated data. + confirm_title: Delete institution? + delete: Delete has_issues: Issue detected, see accounts - new_account: Add account status: Last synced %{last_synced_at} ago status_never: Requires data sync syncing: Syncing... - institutionless_accounts: - other_accounts: Other accounts - new_account: New account new: import_accounts: Import accounts method_selector: - connected_entry: Link account (coming soon) + connected_entry: Link account manual_entry: Enter account balance title: How would you like to add it? title: What would you like to add? diff --git a/config/locales/views/institutions/en.yml b/config/locales/views/institutions/en.yml deleted file mode 100644 index d90aec86875..00000000000 --- a/config/locales/views/institutions/en.yml +++ /dev/null @@ -1,17 +0,0 @@ ---- -en: - institutions: - create: - success: Institution created - destroy: - success: Institution deleted - edit: - edit: Edit %{institution} - form: - name: Financial institution name - new: - new_institution: New financial institution - sync: - success: Institution sync started - update: - success: Institution updated diff --git a/config/locales/views/plaid_items/en.yml b/config/locales/views/plaid_items/en.yml new file mode 100644 index 00000000000..2dcc15974ee --- /dev/null +++ b/config/locales/views/plaid_items/en.yml @@ -0,0 +1,5 @@ +--- +en: + plaid_items: + create: + success: Account linked successfully. Please wait for accounts to sync. diff --git a/config/routes.rb b/config/routes.rb index ed8ab72696c..75e55d1871c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -114,9 +114,6 @@ end end - resources :institutions, except: %i[index show] do - post :sync, on: :member - end resources :invite_codes, only: %i[index create] resources :issues, only: :show diff --git a/db/migrate/20241028210231_add_plaid_item.rb b/db/migrate/20241106193743_add_plaid_domain.rb similarity index 62% rename from db/migrate/20241028210231_add_plaid_item.rb rename to db/migrate/20241106193743_add_plaid_domain.rb index 482b30fb3a8..74809a2366e 100644 --- a/db/migrate/20241028210231_add_plaid_item.rb +++ b/db/migrate/20241106193743_add_plaid_domain.rb @@ -1,9 +1,11 @@ -class AddPlaidItem < ActiveRecord::Migration[7.2] +class AddPlaidDomain < ActiveRecord::Migration[7.2] def change create_table :plaid_items, id: :uuid do |t| t.references :family, null: false, type: :uuid, foreign_key: true t.string :plaid_access_token_digest t.string :plaid_id + t.string :name + t.datetime :last_synced_at t.timestamps end @@ -14,5 +16,12 @@ def change t.timestamps end + + add_reference :accounts, :plaid_account, type: :uuid, foreign_key: true + + remove_reference :accounts, :institution + drop_table :institutions do |t| + t.timestamps + end end end diff --git a/db/schema.rb b/db/schema.rb index 0720213b63d..0af0012c361 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_10_30_222235) do +ActiveRecord::Schema[7.2].define(version: 2024_11_06_193743) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -118,16 +118,16 @@ t.string "currency" t.boolean "is_active", default: true, null: false t.date "last_sync_date" - t.uuid "institution_id" t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" + t.uuid "plaid_account_id" t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type" t.index ["family_id", "id"], name: "index_accounts_on_family_id_and_id" t.index ["family_id"], name: "index_accounts_on_family_id" t.index ["import_id"], name: "index_accounts_on_import_id" - t.index ["institution_id"], name: "index_accounts_on_institution_id" + t.index ["plaid_account_id"], name: "index_accounts_on_plaid_account_id" end create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -402,16 +402,6 @@ t.index ["family_id"], name: "index_imports_on_family_id" end - create_table "institutions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "logo_url" - t.uuid "family_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.datetime "last_synced_at" - t.index ["family_id"], name: "index_institutions_on_family_id" - end - create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -494,6 +484,8 @@ t.uuid "family_id", null: false t.string "plaid_access_token_digest" t.string "plaid_id" + t.string "name" + t.datetime "last_synced_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_plaid_items_on_family_id" @@ -515,9 +507,7 @@ t.string "country_code" t.string "exchange_mic" t.string "exchange_acronym" - t.virtual "search_vector", type: :tsvector, as: "(setweight(to_tsvector('simple'::regconfig, (COALESCE(ticker, ''::character varying))::text), 'B'::\"char\") || to_tsvector('simple'::regconfig, (COALESCE(name, ''::character varying))::text))", stored: true t.index ["country_code"], name: "index_securities_on_country_code" - t.index ["search_vector"], name: "index_securities_on_search_vector", using: :gin t.index ["ticker", "exchange_mic"], name: "index_securities_on_ticker_and_exchange_mic", unique: true end @@ -631,7 +621,7 @@ add_foreign_key "account_transactions", "merchants" add_foreign_key "accounts", "families" add_foreign_key "accounts", "imports" - add_foreign_key "accounts", "institutions" + add_foreign_key "accounts", "plaid_accounts" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "categories", "families" @@ -640,10 +630,12 @@ add_foreign_key "impersonation_sessions", "users", column: "impersonator_id" add_foreign_key "import_rows", "imports" add_foreign_key "imports", "families" - add_foreign_key "institutions", "families" add_foreign_key "invitations", "families" add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "merchants", "families" + add_foreign_key "plaid_accounts", "accounts" + add_foreign_key "plaid_accounts", "plaid_items" + add_foreign_key "plaid_items", "families" add_foreign_key "security_prices", "securities" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" add_foreign_key "sessions", "users" diff --git a/test/controllers/institutions_controller_test.rb b/test/controllers/institutions_controller_test.rb deleted file mode 100644 index ce5d9664800..00000000000 --- a/test/controllers/institutions_controller_test.rb +++ /dev/null @@ -1,62 +0,0 @@ -require "test_helper" - -class InstitutionsControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in users(:family_admin) - @institution = institutions(:chase) - end - - test "should get new" do - get new_institution_url - assert_response :success - end - - test "can create institution" do - assert_difference("Institution.count", 1) do - post institutions_url, params: { - institution: { - name: "New institution" - } - } - end - - assert_redirected_to accounts_url - assert_equal "Institution created", flash[:notice] - end - - test "should get edit" do - get edit_institution_url(@institution) - - assert_response :success - end - - test "should update institution" do - patch institution_url(@institution), params: { - institution: { - name: "New Institution Name", - logo: file_fixture_upload("square-placeholder.png", "image/png", :binary) - } - } - - assert_redirected_to accounts_url - assert_equal "Institution updated", flash[:notice] - end - - test "can destroy institution without destroying accounts" do - assert @institution.accounts.count > 0 - - assert_difference -> { Institution.count } => -1, -> { Account.count } => 0 do - delete institution_url(@institution) - end - - assert_redirected_to accounts_url - assert_equal "Institution deleted", flash[:notice] - end - - test "can sync institution" do - post sync_institution_url(@institution) - - assert_redirected_to accounts_url - assert_equal "Institution sync started", flash[:notice] - end -end diff --git a/test/controllers/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb new file mode 100644 index 00000000000..004ae0ae90c --- /dev/null +++ b/test/controllers/plaid_items_controller_test.rb @@ -0,0 +1,22 @@ +require "test_helper" + +class PlaidItemsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + + Plaid::PlaidApi.any_instance.stubs(:item_public_token_exchange).returns( + Plaid::ItemPublicTokenExchangeResponse.new(access_token: "access-sandbox-1234") + ) + end + + test "create" do + assert_difference "PlaidItem.count", 1 do + post plaid_items_url, params: { + plaid_item: { public_token: "public-sandbox-1234" } + } + end + + assert_equal "Account linked successfully. Please wait for accounts to sync.", flash[:notice] + assert_redirected_to accounts_path + end +end diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index 5c33a1efccd..c150e7268ce 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -21,7 +21,6 @@ depository: currency: USD accountable_type: Depository accountable: one - institution: chase credit_card: family: dylan_family @@ -30,7 +29,6 @@ credit_card: currency: USD accountable_type: CreditCard accountable: one - institution: chase investment: family: dylan_family diff --git a/test/fixtures/institutions.yml b/test/fixtures/institutions.yml deleted file mode 100644 index 18625a91aaa..00000000000 --- a/test/fixtures/institutions.yml +++ /dev/null @@ -1,8 +0,0 @@ -chase: - name: Chase - family: dylan_family - -revolut: - name: Revolut - family: dylan_family - logo_url: <%= "file://" + Rails.root.join('test/fixtures/files/square-placeholder.png').to_s %> diff --git a/test/interfaces/accountable_resource_interface_test.rb b/test/interfaces/accountable_resource_interface_test.rb index ce0a6871a7b..3a3f23916ef 100644 --- a/test/interfaces/accountable_resource_interface_test.rb +++ b/test/interfaces/accountable_resource_interface_test.rb @@ -4,6 +4,10 @@ module AccountableResourceInterfaceTest extend ActiveSupport::Testing::Declarative test "shows new form" do + Plaid::PlaidApi.any_instance.stubs(:link_token_create).returns( + Plaid::LinkTokenCreateResponse.new(link_token: "test-link-token") + ) + get new_polymorphic_url(@account.accountable) assert_response :success end @@ -28,7 +32,6 @@ module AccountableResourceInterfaceTest assert_no_difference [ "Account.count", "@account.accountable_class.count" ] do patch account_url(@account), params: { account: { - institution_id: institutions(:chase).id, name: "Updated name", balance: 10000, currency: "USD" @@ -45,7 +48,6 @@ module AccountableResourceInterfaceTest post "/#{@account.accountable_name.pluralize}", params: { account: { accountable_type: @account.accountable_class, - institution_id: institutions(:chase).id, name: "New accountable", balance: 10000, currency: "USD", diff --git a/test/system/accounts_test.rb b/test/system/accounts_test.rb index 87907323560..55e80047596 100644 --- a/test/system/accounts_test.rb +++ b/test/system/accounts_test.rb @@ -4,6 +4,10 @@ class AccountsTest < ApplicationSystemTestCase setup do sign_in @user = users(:family_admin) + Plaid::PlaidApi.any_instance.stubs(:link_token_create).returns( + Plaid::LinkTokenCreateResponse.new(link_token: "test-link-token") + ) + visit root_url open_new_account_modal end From f8b9168abc61ab67e92c858413957f94c2f115c1 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 7 Nov 2024 08:17:27 -0500 Subject: [PATCH 03/23] Improve schema and Plaid provider --- app/controllers/accounts_controller.rb | 1 + app/controllers/application_controller.rb | 8 ---- .../concerns/accountable_resource.rb | 27 ++++-------- app/controllers/plaid_items_controller.rb | 29 +++++++++---- .../controllers/plaid_controller.js | 17 +++----- app/models/account.rb | 1 + app/models/concerns/plaidable.rb | 14 +++++++ app/models/family.rb | 10 +++++ app/models/plaid_account.rb | 2 +- app/models/plaid_item.rb | 24 ++++++++++- app/models/provider/plaid.rb | 42 +++++++++++++++++++ app/views/accounts/index.html.erb | 8 +++- app/views/accounts/index/_plaid_item.html.erb | 10 ++++- config/environments/test.rb | 2 + config/routes.rb | 4 +- db/migrate/20241106193743_add_plaid_domain.rb | 3 +- db/schema.rb | 5 +-- .../plaid_items_controller_test.rb | 26 ++++++++++-- test/fixtures/accounts.yml | 9 ++++ test/fixtures/depositories.yml | 3 +- test/fixtures/plaid_accounts.yml | 3 ++ test/fixtures/plaid_items.yml | 6 +++ test/models/account_test.rb | 2 +- test/system/accounts_test.rb | 4 +- 24 files changed, 191 insertions(+), 69 deletions(-) create mode 100644 app/models/concerns/plaidable.rb create mode 100644 app/models/provider/plaid.rb create mode 100644 test/fixtures/plaid_accounts.yml create mode 100644 test/fixtures/plaid_items.yml diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 8572226801a..55826266070 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -5,6 +5,7 @@ class AccountsController < ApplicationController def index @accounts = Current.family.accounts + @plaid_items = Current.family.plaid_items end def summary diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 43b87dc5e40..e978892045e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -14,14 +14,6 @@ def require_upgrade? true end - def plaid - api_client = Plaid::ApiClient.new( - Rails.application.config.plaid - ) - - Plaid::PlaidApi.new(api_client) - end - def subscription_pending? subscribed_at = Current.session.subscribed_at subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 0c1b94a3869..fdd96b8bf5b 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -4,6 +4,7 @@ module AccountableResource included do layout :with_sidebar before_action :set_account, only: [ :show, :edit, :update, :destroy ] + before_action :set_link_token, only: :new, unless: -> { self_hosted? } end class_methods do @@ -14,7 +15,6 @@ def permitted_accountable_attributes(*attrs) end def new - @link_token = plaid.link_token_create(plaid_link_token_request).link_token @account = Current.family.accounts.build( currency: Current.family.currency, accountable: accountable_type.new @@ -43,6 +43,12 @@ def destroy end private + def set_link_token + @link_token = Current.family.get_link_token( + webhooks_url: webhooks_plaid_url + ) + end + def accountable_type controller_name.classify.constantize end @@ -57,23 +63,4 @@ def account_params accountable_attributes: self.class.permitted_accountable_attributes ) end - - def plaid_link_token_request - { - client_id: Rails.application.config.plaid[:client_id], - secret: Rails.application.config.plaid[:secret], - country_codes: [ "US" ] - } - end - - def plaid_link_token_request - Plaid::LinkTokenCreateRequest.new({ - user: { client_user_id: Current.user.family.id }, - client_name: "Maybe", - products: %w[transactions], - country_codes: [ Current.user.family.country ], - language: "en", - webhook: webhooks_plaid_url - }) - end end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index 48e8d4feee6..28a48d63ed0 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -1,19 +1,34 @@ class PlaidItemsController < ApplicationController + before_action :set_plaid_item, only: %i[destroy sync] + def create - puts plaid_item_params[:metadata] - request = Plaid::ItemPublicTokenExchangeRequest.new( - public_token: plaid_item_params[:public_token] + Current.family.plaid_items.create_from_public_token( + plaid_item_params[:public_token], + item_name ) - response = plaid.item_public_token_exchange(request) + redirect_to accounts_path, notice: t(".success") + end - Current.family.plaid_items.create!(item_access_token: response.access_token) + def destroy + @plaid_item.destroy + redirect_to accounts_path, notice: "Linked account removed" + end - redirect_to accounts_path, notice: t(".success") + def sync + # placeholder no-op end private + def set_plaid_item + @plaid_item = Current.family.plaid_items.find(params[:id]) + end + def plaid_item_params - params.require(:plaid_item).permit(:public_token, :metadata) + params.require(:plaid_item).permit(:public_token, metadata: {}) + end + + def item_name + plaid_item_params.dig(:metadata, :institution, :name) end end diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js index 4ede33baba3..6d60c4085e7 100644 --- a/app/javascript/controllers/plaid_controller.js +++ b/app/javascript/controllers/plaid_controller.js @@ -28,6 +28,7 @@ export default class extends Controller { method: "POST", headers: { "Content-Type": "application/json", + Accept: "text/html", "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, }, body: JSON.stringify({ @@ -36,17 +37,11 @@ export default class extends Controller { metadata: metadata, }, }), - }) - .then((response) => { - if (!response.ok) throw new Error("Network response was not ok"); - return response.json(); - }) - .then((data) => { - console.log("Success:", data); - }) - .catch((error) => { - console.error("Error:", error); - }); + }).then((response) => { + if (response.redirected) { + Turbo.visit(response.url); + } + }); } handleExit(err, metadata) { diff --git a/app/models/account.rb b/app/models/account.rb index a716481b851..0d870cfeef6 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -25,6 +25,7 @@ class Account < ApplicationRecord scope :assets, -> { where(classification: "asset") } scope :liabilities, -> { where(classification: "liability") } scope :alphabetically, -> { order(:name) } + scope :manual, -> { where(plaid_account_id: nil) } has_one_attached :logo diff --git a/app/models/concerns/plaidable.rb b/app/models/concerns/plaidable.rb new file mode 100644 index 00000000000..4accfd54134 --- /dev/null +++ b/app/models/concerns/plaidable.rb @@ -0,0 +1,14 @@ +module Plaidable + extend ActiveSupport::Concern + + class_methods do + def plaid_provider + Provider::Plaid.new unless Rails.application.config.app_mode.self_hosted? + end + end + + private + def plaid_provider + self.class.plaid_provider + end +end diff --git a/app/models/family.rb b/app/models/family.rb index 0c4ba4d110d..86dfb13818a 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,4 +1,6 @@ class Family < ApplicationRecord + include Plaidable + DATE_FORMATS = [ "%m-%d-%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ] include Providable @@ -18,6 +20,14 @@ class Family < ApplicationRecord validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS } + def get_link_token(webhooks_url:) + plaid_provider.get_link_token( + user_id: id, + country: country, + webhooks_url: webhooks_url + ).link_token + end + def snapshot(period = Period.all) query = accounts.active.joins(:balances) .where("account_balances.currency = ?", self.currency) diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 6ba6b8b01ee..1917d263b7b 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -1,5 +1,5 @@ class PlaidAccount < ApplicationRecord belongs_to :plaid_item - has_one :account, dependent: :restrict_with_exception + has_one :account, dependent: :destroy end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index a122e0e39e2..952df5e5f2c 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -1,6 +1,10 @@ class PlaidItem < ApplicationRecord - encrypts :item_access_token, deterministic: true - validates :item_access_token, presence: true + include Plaidable + + encrypts :access_token, deterministic: true + validates :name, :access_token, presence: true + + before_destroy :remove_plaid_item belongs_to :family has_one_attached :logo @@ -8,6 +12,17 @@ class PlaidItem < ApplicationRecord has_many :plaid_accounts, dependent: :destroy has_many :accounts, through: :plaid_accounts + class << self + def create_from_public_token(token, item_name) + response = plaid_provider.exchange_public_token(token) + + create!( + name: item_name, + access_token: response.access_token + ) + end + end + def has_issues? false end @@ -15,4 +30,9 @@ def has_issues? def syncing? false end + + private + def remove_plaid_item + plaid_provider.remove_item(access_token) + end end diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb new file mode 100644 index 00000000000..2246485944f --- /dev/null +++ b/app/models/provider/plaid.rb @@ -0,0 +1,42 @@ +class Provider::Plaid + attr_reader :client + + def initialize + @client = create_client + end + + def get_link_token(user_id:, country:, webhooks_url:) + request = Plaid::LinkTokenCreateRequest.new({ + user: { client_user_id: user_id }, + client_name: "Maybe", + products: %w[transactions], + country_codes: [ country ], + language: "en", + webhook: webhooks_url + }) + + client.link_token_create(request) + end + + def exchange_public_token(token) + request = Plaid::ItemPublicTokenExchangeRequest.new( + public_token: token + ) + + client.item_public_token_exchange(request) + end + + def remove_item(access_token) + request = Plaid::ItemRemoveRequest.new(access_token: access_token) + client.item_remove(request) + end + + private + def create_client + api_client = Plaid::ApiClient.new( + Rails.application.config.plaid + ) + + Plaid::PlaidApi.new(api_client) + end +end diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 37e3e29bec0..dec95165cba 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -23,8 +23,12 @@ <%= render "empty" %> <% else %>
- <% if @accounts.any? %> - <%= render "accounts/index/manual_accounts", accounts: @accounts %> + <% if @plaid_items.any? %> + <%= render partial: "accounts/index/plaid_item", collection: @plaid_items %> + <% end %> + + <% if @accounts.manual.any? %> + <%= render "accounts/index/manual_accounts", accounts: @accounts.manual %> <% end %>
<% end %> diff --git a/app/views/accounts/index/_plaid_item.html.erb b/app/views/accounts/index/_plaid_item.html.erb index df177055c96..baacd21a974 100644 --- a/app/views/accounts/index/_plaid_item.html.erb +++ b/app/views/accounts/index/_plaid_item.html.erb @@ -16,7 +16,7 @@
- <%= link_to plaid_item.name, edit_plaid_item_path(plaid_item), data: { turbo_frame: :modal }, class: "font-medium text-gray-900 hover:underline" %> + <%= tag.p plaid_item.name, class: "font-medium text-gray-900" %> <% if plaid_item.has_issues? %>
<%= lucide_icon "alert-octagon", class: "shrink-0 w-4 h-4" %> @@ -62,9 +62,15 @@
<% if plaid_item.accounts.any? %> <%= render "accounts/index/account_groups", accounts: plaid_item.accounts %> + <% elsif plaid_item.last_synced_at.nil? %> +
+

Syncing data...

+

Hang tight, we're syncing your bank data. This may take a few minutes.

+
<% else %>
-

There are no accounts in this financial institution

+

No accounts found

+

We could not load any accounts from this financial institution.

<% end %>
diff --git a/config/environments/test.rb b/config/environments/test.rb index c9918cbc5d7..5e5b9a36bad 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -63,5 +63,7 @@ # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true + config.active_record.encryption.encrypt_fixtures = true + config.autoload_paths += %w[test/support] end diff --git a/config/routes.rb b/config/routes.rb index 75e55d1871c..63665f6a526 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -147,7 +147,9 @@ end end - resources :plaid_items, only: :create + resources :plaid_items, only: %i[create destroy] do + post :sync, on: :member + end namespace :webhooks do post "plaid", to: "webhooks#plaid" diff --git a/db/migrate/20241106193743_add_plaid_domain.rb b/db/migrate/20241106193743_add_plaid_domain.rb index 74809a2366e..381af9609a8 100644 --- a/db/migrate/20241106193743_add_plaid_domain.rb +++ b/db/migrate/20241106193743_add_plaid_domain.rb @@ -2,7 +2,7 @@ class AddPlaidDomain < ActiveRecord::Migration[7.2] def change create_table :plaid_items, id: :uuid do |t| t.references :family, null: false, type: :uuid, foreign_key: true - t.string :plaid_access_token_digest + t.string :access_token t.string :plaid_id t.string :name t.datetime :last_synced_at @@ -11,7 +11,6 @@ def change create_table :plaid_accounts, id: :uuid do |t| t.references :plaid_item, null: false, type: :uuid, foreign_key: true - t.references :account, null: false, type: :uuid, foreign_key: true t.string :plaid_id t.timestamps diff --git a/db/schema.rb b/db/schema.rb index 0af0012c361..a4e263c472b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -472,17 +472,15 @@ create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "plaid_item_id", null: false - t.uuid "account_id", null: false t.string "plaid_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_plaid_accounts_on_account_id" t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" end create_table "plaid_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false - t.string "plaid_access_token_digest" + t.string "access_token" t.string "plaid_id" t.string "name" t.datetime "last_synced_at" @@ -633,7 +631,6 @@ add_foreign_key "invitations", "families" add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "merchants", "families" - add_foreign_key "plaid_accounts", "accounts" add_foreign_key "plaid_accounts", "plaid_items" add_foreign_key "plaid_items", "families" add_foreign_key "security_prices", "securities" diff --git a/test/controllers/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb index 004ae0ae90c..0e44b4596bd 100644 --- a/test/controllers/plaid_items_controller_test.rb +++ b/test/controllers/plaid_items_controller_test.rb @@ -1,22 +1,40 @@ require "test_helper" +require "ostruct" class PlaidItemsControllerTest < ActionDispatch::IntegrationTest setup do sign_in @user = users(:family_admin) - Plaid::PlaidApi.any_instance.stubs(:item_public_token_exchange).returns( - Plaid::ItemPublicTokenExchangeResponse.new(access_token: "access-sandbox-1234") - ) + @plaid_provider = mock + + PlaidItem.stubs(:plaid_provider).returns(@plaid_provider) end test "create" do + public_token = "public-sandbox-1234" + + @plaid_provider.expects(:exchange_public_token).with(public_token).returns( + OpenStruct.new(access_token: "access-sandbox-1234") + ) + assert_difference "PlaidItem.count", 1 do post plaid_items_url, params: { - plaid_item: { public_token: "public-sandbox-1234" } + plaid_item: { + public_token: public_token, + metadata: { institution: { name: "Plaid Item Name" } } + } } end assert_equal "Account linked successfully. Please wait for accounts to sync.", flash[:notice] assert_redirected_to accounts_path end + + test "destroy" do + @plaid_provider.expects(:remove_item).once + + assert_difference [ "PlaidItem.count", "PlaidAccount.count", "Account.count" ], -1 do + delete plaid_item_url(plaid_items(:one)) + end + end end diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index c150e7268ce..1bb48c6e857 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -22,6 +22,15 @@ depository: accountable_type: Depository accountable: one +connected: + family: dylan_family + name: Connected Account + balance: 5000 + currency: USD + accountable_type: Depository + accountable: two + plaid_account: one + credit_card: family: dylan_family name: Credit Card diff --git a/test/fixtures/depositories.yml b/test/fixtures/depositories.yml index e0553ab036a..acfb5a66f3f 100644 --- a/test/fixtures/depositories.yml +++ b/test/fixtures/depositories.yml @@ -1 +1,2 @@ -one: { } \ No newline at end of file +one: { } +two: {} \ No newline at end of file diff --git a/test/fixtures/plaid_accounts.yml b/test/fixtures/plaid_accounts.yml new file mode 100644 index 00000000000..2a91110430d --- /dev/null +++ b/test/fixtures/plaid_accounts.yml @@ -0,0 +1,3 @@ +one: + plaid_item: one + plaid_id: "1234567890" diff --git a/test/fixtures/plaid_items.yml b/test/fixtures/plaid_items.yml new file mode 100644 index 00000000000..a700c367067 --- /dev/null +++ b/test/fixtures/plaid_items.yml @@ -0,0 +1,6 @@ +one: + family: dylan_family + plaid_id: "1234567890" + access_token: encrypted_token_1 + name: "Test Bank" + last_synced_at: <%= Time.current %> \ No newline at end of file diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 3da0186073b..06d0e641bc7 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -47,7 +47,7 @@ class AccountTest < ActiveSupport::TestCase loans = liabilities.children.find { |group| group.name == "Loan" } other_liabilities = liabilities.children.find { |group| group.name == "OtherLiability" } - assert_equal 1, depositories.children.count + assert_equal 2, depositories.children.count assert_equal 1, properties.children.count assert_equal 1, vehicles.children.count assert_equal 1, investments.children.count diff --git a/test/system/accounts_test.rb b/test/system/accounts_test.rb index 55e80047596..de55e49fafc 100644 --- a/test/system/accounts_test.rb +++ b/test/system/accounts_test.rb @@ -4,9 +4,7 @@ class AccountsTest < ApplicationSystemTestCase setup do sign_in @user = users(:family_admin) - Plaid::PlaidApi.any_instance.stubs(:link_token_create).returns( - Plaid::LinkTokenCreateResponse.new(link_token: "test-link-token") - ) + Family.any_instance.stubs(:get_link_token).returns("test-link-token") visit root_url open_new_account_modal From cc3da86f5d206dc7f962184062d97afe830e813c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 7 Nov 2024 10:17:00 -0500 Subject: [PATCH 04/23] Add webhook verification sketch --- Gemfile | 1 + Gemfile.lock | 3 ++ app/controllers/webhooks_controller.rb | 12 ++++- app/jobs/process_plaid_webhook_job.rb | 9 ++++ app/models/provider/plaid.rb | 49 ++++++++++++++++----- app/models/provider/plaid_sandbox.rb | 28 ++++++++++++ test/jobs/process_plaid_webhook_job_test.rb | 7 +++ 7 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 app/jobs/process_plaid_webhook_job.rb create mode 100644 app/models/provider/plaid_sandbox.rb create mode 100644 test/jobs/process_plaid_webhook_job_test.rb diff --git a/Gemfile b/Gemfile index 50b52b389d7..093377c5608 100644 --- a/Gemfile +++ b/Gemfile @@ -37,6 +37,7 @@ gem "image_processing", ">= 1.2" # Other gem "bcrypt", "~> 3.1" +gem "jwt" gem "faraday" gem "faraday-retry" gem "faraday-multipart" diff --git a/Gemfile.lock b/Gemfile.lock index 62bf8427ec3..be725af4786 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -224,6 +224,8 @@ GEM reline (>= 0.4.2) jmespath (1.6.2) json (2.7.2) + jwt (2.9.3) + base64 language_server-protocol (3.17.0.3) launchy (3.0.1) addressable (~> 2.8) @@ -498,6 +500,7 @@ DEPENDENCIES importmap-rails inline_svg intercom-rails + jwt letter_opener lucide-rails! mocha diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 4f89167e221..e3134625559 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -1,8 +1,18 @@ class WebhooksController < ApplicationController - skip_before_action :verify_authenticity_token, only: [ :stripe ] + skip_before_action :verify_authenticity_token skip_authentication def plaid + webhook_body = request.body.read + plaid_verification_header = request.headers["Plaid-Verification"] + + Provider::Plaid.validate_webhook!(plaid_verification_header) + + ProcessPlaidWebhookJob.perform_later(webhook_body) + + render json: { received: true }, status: :ok + rescue + render json: { error: "Invalid webhook: #{e.message}" }, status: :bad_request end def stripe diff --git a/app/jobs/process_plaid_webhook_job.rb b/app/jobs/process_plaid_webhook_job.rb new file mode 100644 index 00000000000..dc097204729 --- /dev/null +++ b/app/jobs/process_plaid_webhook_job.rb @@ -0,0 +1,9 @@ +class ProcessPlaidWebhookJob < ApplicationJob + queue_as :default + + def perform(webhook_body) + # TODO + puts webhook_body + puts "Processing Plaid webhook..." + end +end diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 2246485944f..d003031d1c0 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -1,8 +1,46 @@ class Provider::Plaid attr_reader :client + class << self + def validate_webhook!(verification_header) + decoded_header = JWT.decode( + verification_header, nil, false, + { algorithm: "ES256", verify_expiration: false } + ).first + + key_id = decoded_header["kid"] + + jwk = client.webhook_verification_key_get( + Plaid::WebhookVerificationKeyGetRequest.new(key_id: key_id) + ).key + + public_key = JWT::JWK.import(jwk).public_key + decoded_token = JWT.decode( + verification_header, public_key, true, + { algorithm: "ES256" } + ) + + payload = decoded_token.first + + issued_at = Time.at(payload["iat"]) + raise JWT::VerificationError, "Webhook is too old" if Time.now - issued_at > 5.minutes + + expected_hash = payload["request_body_sha256"] + actual_hash = Digest::SHA256.hexdigest(webhook_body) + raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash) + end + + def client + api_client = Plaid::ApiClient.new( + Rails.application.config.plaid + ) + + Plaid::PlaidApi.new(api_client) + end + end + def initialize - @client = create_client + @client = self.class.client end def get_link_token(user_id:, country:, webhooks_url:) @@ -30,13 +68,4 @@ def remove_item(access_token) request = Plaid::ItemRemoveRequest.new(access_token: access_token) client.item_remove(request) end - - private - def create_client - api_client = Plaid::ApiClient.new( - Rails.application.config.plaid - ) - - Plaid::PlaidApi.new(api_client) - end end diff --git a/app/models/provider/plaid_sandbox.rb b/app/models/provider/plaid_sandbox.rb new file mode 100644 index 00000000000..132b44224db --- /dev/null +++ b/app/models/provider/plaid_sandbox.rb @@ -0,0 +1,28 @@ +class Provider::PlaidSandbox < Provider::Plaid + attr_reader :client + + def initialize + @client = create_client + end + + def fire_webhook(item, type: "TRANSACTIONS", code: "SYNC_UPDATES_AVAILABLE") + client.sandbox_item_fire_webhook( + Plaid::SandboxItemFireWebhookRequest.new( + access_token: item.access_token, + webhook_type: type, + webhook_code: code, + ) + ) + end + + private + def create_client + raise "Plaid sandbox is not supported in production" if Rails.env.production? + + api_client = Plaid::ApiClient.new( + Rails.application.config.plaid + ) + + Plaid::PlaidApi.new(api_client) + end +end diff --git a/test/jobs/process_plaid_webhook_job_test.rb b/test/jobs/process_plaid_webhook_job_test.rb new file mode 100644 index 00000000000..1dca5c871f6 --- /dev/null +++ b/test/jobs/process_plaid_webhook_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ProcessPlaidWebhookJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end From 4a5a410be6ed021753678606aa61e06f52e21cb2 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 7 Nov 2024 15:20:29 -0500 Subject: [PATCH 05/23] Webhook verification --- .../concerns/accountable_resource.rb | 9 ++++- app/controllers/plaid_items_controller.rb | 2 +- app/controllers/webhooks_controller.rb | 6 +-- app/models/provider/plaid.rb | 37 +++++++++++-------- config/locales/views/plaid_items/en.yml | 2 + config/routes.rb | 4 +- .../plaid_items_controller_test.rb | 3 ++ 7 files changed, 40 insertions(+), 23 deletions(-) diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index fdd96b8bf5b..4c70e393372 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -45,10 +45,17 @@ def destroy private def set_link_token @link_token = Current.family.get_link_token( - webhooks_url: webhooks_plaid_url + webhooks_url: webhooks_url ) end + def webhooks_url + return webhooks_plaid_url if Rails.env.production? + + base_url = ENV.fetch("WEBHOOKS_URL", root_url.chomp("/")) + base_url + "/webhooks/plaid" + end + def accountable_type controller_name.classify.constantize end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index 28a48d63ed0..d49bcc90ee9 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -12,7 +12,7 @@ def create def destroy @plaid_item.destroy - redirect_to accounts_path, notice: "Linked account removed" + redirect_to accounts_path, notice: t(".success") end def sync diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index e3134625559..c75644dfa99 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -6,13 +6,13 @@ def plaid webhook_body = request.body.read plaid_verification_header = request.headers["Plaid-Verification"] - Provider::Plaid.validate_webhook!(plaid_verification_header) + Provider::Plaid.validate_webhook!(plaid_verification_header, webhook_body) ProcessPlaidWebhookJob.perform_later(webhook_body) render json: { received: true }, status: :ok - rescue - render json: { error: "Invalid webhook: #{e.message}" }, status: :bad_request + rescue => error + render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request end def stripe diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index d003031d1c0..d539d724c9f 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -2,31 +2,36 @@ class Provider::Plaid attr_reader :client class << self - def validate_webhook!(verification_header) - decoded_header = JWT.decode( - verification_header, nil, false, - { algorithm: "ES256", verify_expiration: false } - ).first + def validate_webhook!(verification_header, raw_body) + jwks_loader = ->(options) do + key_id = options[:kid] - key_id = decoded_header["kid"] + # TODO: Cache this + # @see https://plaid.com/docs/api/webhooks/webhook-verification/#caching-and-key-rotation + jwk_response = client.webhook_verification_key_get( + Plaid::WebhookVerificationKeyGetRequest.new(key_id: key_id) + ) - jwk = client.webhook_verification_key_get( - Plaid::WebhookVerificationKeyGetRequest.new(key_id: key_id) - ).key + jwks = JWT::JWK::Set.new([ jwk_response.key.to_hash ]) - public_key = JWT::JWK.import(jwk).public_key - decoded_token = JWT.decode( - verification_header, public_key, true, - { algorithm: "ES256" } - ) + jwks.filter! { |key| key[:use] == "sig" } + jwks + end - payload = decoded_token.first + payload, _header = JWT.decode( + verification_header, nil, true, + { + algorithms: [ "ES256" ], + jwks: jwks_loader, + verify_expiration: false + } + ) issued_at = Time.at(payload["iat"]) raise JWT::VerificationError, "Webhook is too old" if Time.now - issued_at > 5.minutes expected_hash = payload["request_body_sha256"] - actual_hash = Digest::SHA256.hexdigest(webhook_body) + actual_hash = Digest::SHA256.hexdigest(raw_body) raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash) end diff --git a/config/locales/views/plaid_items/en.yml b/config/locales/views/plaid_items/en.yml index 2dcc15974ee..bce2b651f25 100644 --- a/config/locales/views/plaid_items/en.yml +++ b/config/locales/views/plaid_items/en.yml @@ -3,3 +3,5 @@ en: plaid_items: create: success: Account linked successfully. Please wait for accounts to sync. + destroy: + success: Accounts removed successfully diff --git a/config/routes.rb b/config/routes.rb index 63665f6a526..1462b71a7eb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -152,8 +152,8 @@ end namespace :webhooks do - post "plaid", to: "webhooks#plaid" - post "stripe", to: "webhooks#stripe" + post "plaid" + post "stripe" end # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. diff --git a/test/controllers/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb index 0e44b4596bd..07186eec12b 100644 --- a/test/controllers/plaid_items_controller_test.rb +++ b/test/controllers/plaid_items_controller_test.rb @@ -36,5 +36,8 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest assert_difference [ "PlaidItem.count", "PlaidAccount.count", "Account.count" ], -1 do delete plaid_item_url(plaid_items(:one)) end + + assert_equal "Accounts removed successfully", flash[:notice] + assert_redirected_to accounts_path end end From babad558b3ef44ea644fa8ba9ba765d774839bba Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 7 Nov 2024 20:06:59 -0500 Subject: [PATCH 06/23] Item accounts and balances sync setup --- app/controllers/plaid_items_controller.rb | 3 +- app/jobs/plaid_item_sync_job.rb | 7 +++ app/jobs/process_plaid_webhook_job.rb | 4 +- app/models/account/sync.rb | 7 --- app/models/family.rb | 2 +- app/models/institution.rb | 25 --------- app/models/plaid_account.rb | 38 +++++++++++++ app/models/plaid_item.rb | 26 +++++++-- app/models/plaid_item_sync.rb | 53 +++++++++++++++++++ app/models/provider/plaid.rb | 5 ++ .../provider/plaid/webhook_processor.rb | 25 +++++++++ app/views/accounts/index.html.erb | 2 +- .../_plaid_item.html.erb | 17 +++--- config/locales/views/accounts/en.yml | 10 ---- config/locales/views/plaid_items/en.yml | 14 +++++ db/migrate/20241106193743_add_plaid_domain.rb | 20 ++++++- db/schema.rb | 21 +++++++- .../plaid_items_controller_test.rb | 12 ++++- test/jobs/plaid_item_sync_job_test.rb | 7 +++ test/models/account/sync_test.rb | 1 - test/models/family_test.rb | 3 +- 21 files changed, 232 insertions(+), 70 deletions(-) create mode 100644 app/jobs/plaid_item_sync_job.rb delete mode 100644 app/models/institution.rb create mode 100644 app/models/plaid_item_sync.rb create mode 100644 app/models/provider/plaid/webhook_processor.rb rename app/views/{accounts/index => plaid_items}/_plaid_item.html.erb (81%) create mode 100644 test/jobs/plaid_item_sync_job_test.rb diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index d49bcc90ee9..66eb9c7f40c 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -16,7 +16,8 @@ def destroy end def sync - # placeholder no-op + @plaid_item.sync_later + redirect_to accounts_path, notice: t(".success") end private diff --git a/app/jobs/plaid_item_sync_job.rb b/app/jobs/plaid_item_sync_job.rb new file mode 100644 index 00000000000..439267932fe --- /dev/null +++ b/app/jobs/plaid_item_sync_job.rb @@ -0,0 +1,7 @@ +class PlaidItemSyncJob < ApplicationJob + queue_as :default + + def perform(plaid_item) + plaid_item.sync + end +end diff --git a/app/jobs/process_plaid_webhook_job.rb b/app/jobs/process_plaid_webhook_job.rb index dc097204729..2e033751b8f 100644 --- a/app/jobs/process_plaid_webhook_job.rb +++ b/app/jobs/process_plaid_webhook_job.rb @@ -2,8 +2,6 @@ class ProcessPlaidWebhookJob < ApplicationJob queue_as :default def perform(webhook_body) - # TODO - puts webhook_body - puts "Processing Plaid webhook..." + Provider::Plaid::WebhookProcessor.new(webhook_body).process end end diff --git a/app/models/account/sync.rb b/app/models/account/sync.rb index 28ae251a9cd..14d3131c875 100644 --- a/app/models/account/sync.rb +++ b/app/models/account/sync.rb @@ -70,13 +70,6 @@ def broadcast_start def broadcast_result(type:, message:) broadcast_remove_to account.family, :notifications, target: id # Remove persistent syncing notification - broadcast_append_to( - [ account.family, :notifications ], - target: "notification-tray", - partial: "shared/notification", - locals: { type: type, message: message } - ) - account.family.broadcast_refresh end end diff --git a/app/models/family.rb b/app/models/family.rb index 86dfb13818a..a0551ba6357 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -129,7 +129,7 @@ def liabilities def sync(start_date: nil) accounts.active.each do |account| if account.needs_sync? - account.sync_later(start_date: start_date || account.last_sync_date) + account.sync_later(start_date: start_date) end end diff --git a/app/models/institution.rb b/app/models/institution.rb deleted file mode 100644 index d34ecd0efdc..00000000000 --- a/app/models/institution.rb +++ /dev/null @@ -1,25 +0,0 @@ -class Institution < ApplicationRecord - belongs_to :family - has_many :accounts, dependent: :nullify - has_one_attached :logo - - scope :alphabetically, -> { order(name: :asc) } - - def sync - accounts.active.each do |account| - if account.needs_sync? - account.sync - end - end - - update! last_synced_at: Time.now - end - - def syncing? - accounts.active.any? { |account| account.syncing? } - end - - def has_issues? - accounts.active.any? { |account| account.has_issues? } - end -end diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 1917d263b7b..b91974cbfec 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -2,4 +2,42 @@ class PlaidAccount < ApplicationRecord belongs_to :plaid_item has_one :account, dependent: :destroy + + accepts_nested_attributes_for :account + + class << self + def create_from_plaid_data!(plaid_data, family) + create!( + plaid_id: plaid_data.account_id, + current_balance: plaid_data.balances.current, + available_balance: plaid_data.balances.available, + currency: plaid_data.balances.iso_currency_code, + plaid_type: plaid_data.type, + plaid_subtype: plaid_data.subtype, + name: plaid_data.name, + mask: plaid_data.mask, + account: family.accounts.new( + name: plaid_data.name, + balance: plaid_data.balances.current, + currency: plaid_data.balances.iso_currency_code, + accountable: plaid_type_to_accountable(plaid_data.type) + ) + ) + end + + def plaid_type_to_accountable(plaid_type) + case plaid_type + when "depository" + Depository.new + when "credit" + CreditCard.new + when "loan" + Loan.new + when "investment" + Investment.new + else + OtherAsset.new + end + end + end end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 952df5e5f2c..88c72cab4a8 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -9,6 +9,7 @@ class PlaidItem < ApplicationRecord belongs_to :family has_one_attached :logo + has_many :syncs, class_name: "PlaidItemSync", dependent: :destroy has_many :plaid_accounts, dependent: :destroy has_many :accounts, through: :plaid_accounts @@ -16,19 +17,34 @@ class << self def create_from_public_token(token, item_name) response = plaid_provider.exchange_public_token(token) - create!( + new_plaid_item = create!( name: item_name, + plaid_id: response.item_id, access_token: response.access_token ) + + new_plaid_item.sync_later end end - def has_issues? - false + def syncing? + syncs.syncing.any? end - def syncing? - false + def last_synced_at + syncs.order(created_at: :desc).first&.last_ran_at + end + + def sync_later + PlaidItemSyncJob.perform_later(self) + end + + def sync + PlaidItemSync.create!(plaid_item: self).run + end + + def fetch_accounts + plaid_provider.get_item_accounts(self) end private diff --git a/app/models/plaid_item_sync.rb b/app/models/plaid_item_sync.rb new file mode 100644 index 00000000000..5fd9af96b46 --- /dev/null +++ b/app/models/plaid_item_sync.rb @@ -0,0 +1,53 @@ +class PlaidItemSync < ApplicationRecord + belongs_to :plaid_item + + enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" } + + def run + start! + + initialize_item unless plaid_item.plaid_accounts.any? + + sync_accounts + + complete! + rescue StandardError => error + fail! error + + raise error if Rails.env.development? + end + + private + def family + plaid_item.family + end + + def initialize_item + accounts_data = plaid_item.fetch_accounts.accounts + + transaction do + accounts_data.each do |account_data| + plaid_item.plaid_accounts + .create_from_plaid_data!(account_data, family) + end + end + end + + def sync_accounts + plaid_item.accounts.each do |account| + account.sync + end + end + + def start! + update! status: "syncing", last_ran_at: Time.now + end + + def complete! + update! status: "completed" + end + + def fail!(error) + update! status: "failed", error: error.message + end +end diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index d539d724c9f..6d8edcfa6b4 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -73,4 +73,9 @@ def remove_item(access_token) request = Plaid::ItemRemoveRequest.new(access_token: access_token) client.item_remove(request) end + + def get_item_accounts(item) + request = Plaid::AccountsGetRequest.new(access_token: item.access_token) + client.accounts_get(request) + end end diff --git a/app/models/provider/plaid/webhook_processor.rb b/app/models/provider/plaid/webhook_processor.rb new file mode 100644 index 00000000000..b9b2bfa225c --- /dev/null +++ b/app/models/provider/plaid/webhook_processor.rb @@ -0,0 +1,25 @@ +class Provider::Plaid::WebhookProcessor + attr_reader :type, :code, :data + + def initialize(webhook_body) + parsed = JSON.parse(webhook_body) + @type = parsed.delete("webhook_type") + @code = parsed.delete("webhook_code") + @data = parsed + end + + def process + case [ type, code ] + when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ] + process_transactions + else + Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}") + end + end + + private + def process_transactions + item = PlaidItem.find_by(plaid_id: data["item_id"]) + item.sync unless item.syncing? + end +end diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index dec95165cba..13ab12e7688 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -24,7 +24,7 @@ <% else %>
<% if @plaid_items.any? %> - <%= render partial: "accounts/index/plaid_item", collection: @plaid_items %> + <%= render @plaid_items %> <% end %> <% if @accounts.manual.any? %> diff --git a/app/views/accounts/index/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb similarity index 81% rename from app/views/accounts/index/_plaid_item.html.erb rename to app/views/plaid_items/_plaid_item.html.erb index baacd21a974..f63d880c048 100644 --- a/app/views/accounts/index/_plaid_item.html.erb +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -17,12 +17,7 @@
<%= tag.p plaid_item.name, class: "font-medium text-gray-900" %> - <% if plaid_item.has_issues? %> -
- <%= lucide_icon "alert-octagon", class: "shrink-0 w-4 h-4" %> - <%= tag.span t(".has_issues") %> -
- <% elsif plaid_item.syncing? %> + <% if plaid_item.syncing? %>
<%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %> <%= tag.span t(".syncing") %> @@ -62,15 +57,15 @@
<% if plaid_item.accounts.any? %> <%= render "accounts/index/account_groups", accounts: plaid_item.accounts %> - <% elsif plaid_item.last_synced_at.nil? %> + <% elsif plaid_item.syncs.empty? %>
-

Syncing data...

-

Hang tight, we're syncing your bank data. This may take a few minutes.

+

<%= t(".syncing_message") %>

+

<%= t(".syncing_description") %>

<% else %>
-

No accounts found

-

We could not load any accounts from this financial institution.

+

<%= t(".no_accounts_title") %>

+

<%= t(".no_accounts_description") %>

<% end %>
diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index ea03da7936f..d7d8873feaa 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -19,16 +19,6 @@ en: manual_accounts: other_accounts: Other accounts new_account: New account - plaid_item: - confirm_accept: Delete institution - confirm_body: This will permanently delete all the accounts in this group - and all associated data. - confirm_title: Delete institution? - delete: Delete - has_issues: Issue detected, see accounts - status: Last synced %{last_synced_at} ago - status_never: Requires data sync - syncing: Syncing... new: import_accounts: Import accounts method_selector: diff --git a/config/locales/views/plaid_items/en.yml b/config/locales/views/plaid_items/en.yml index bce2b651f25..36a6b1ec879 100644 --- a/config/locales/views/plaid_items/en.yml +++ b/config/locales/views/plaid_items/en.yml @@ -1,7 +1,21 @@ --- en: plaid_items: + plaid_item: + confirm_accept: Delete institution + confirm_body: This will permanently delete all the accounts in this group and all associated data. + confirm_title: Delete institution? + delete: Delete + status: Last synced %{last_synced_at} ago + status_never: Requires data sync + syncing: Syncing... + syncing_message: Syncing data... + syncing_description: Hang tight, we're syncing your bank data. This may take a few minutes. + no_accounts_title: No accounts found + no_accounts_description: We could not load any accounts from this financial institution. create: success: Account linked successfully. Please wait for accounts to sync. destroy: success: Accounts removed successfully + sync: + success: Sync started diff --git a/db/migrate/20241106193743_add_plaid_domain.rb b/db/migrate/20241106193743_add_plaid_domain.rb index 381af9609a8..1e8a8e9e44e 100644 --- a/db/migrate/20241106193743_add_plaid_domain.rb +++ b/db/migrate/20241106193743_add_plaid_domain.rb @@ -9,16 +9,34 @@ def change t.timestamps end + create_table :plaid_item_syncs, id: :uuid do |t| + t.references :plaid_item, null: false, type: :uuid, foreign_key: true + t.string :status + t.datetime :last_ran_at + t.string :error + t.jsonb :transactions_data + t.jsonb :accounts_data + t.timestamps + end + create_table :plaid_accounts, id: :uuid do |t| t.references :plaid_item, null: false, type: :uuid, foreign_key: true t.string :plaid_id + t.string :plaid_type + t.string :plaid_subtype + t.decimal :current_balance, precision: 19, scale: 4 + t.decimal :available_balance, precision: 19, scale: 4 + t.string :currency + t.string :name + t.string :mask t.timestamps end + remove_column :accounts, :last_sync_date, :date + remove_reference :accounts, :institution add_reference :accounts, :plaid_account, type: :uuid, foreign_key: true - remove_reference :accounts, :institution drop_table :institutions do |t| t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index a4e263c472b..97a83cbb6eb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -117,7 +117,6 @@ t.decimal "balance", precision: 19, scale: 4 t.string "currency" t.boolean "is_active", default: true, null: false - t.date "last_sync_date" t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" t.uuid "plaid_account_id" @@ -473,11 +472,30 @@ create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "plaid_item_id", null: false t.string "plaid_id" + t.string "plaid_type" + t.string "plaid_subtype" + t.decimal "current_balance", precision: 19, scale: 4 + t.decimal "available_balance", precision: 19, scale: 4 + t.string "currency" + t.string "name" + t.string "mask" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" end + create_table "plaid_item_syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "plaid_item_id", null: false + t.string "status" + t.datetime "last_ran_at" + t.string "error" + t.jsonb "transactions_data" + t.jsonb "accounts_data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["plaid_item_id"], name: "index_plaid_item_syncs_on_plaid_item_id" + end + create_table "plaid_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "access_token" @@ -632,6 +650,7 @@ add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "merchants", "families" add_foreign_key "plaid_accounts", "plaid_items" + add_foreign_key "plaid_item_syncs", "plaid_items" add_foreign_key "plaid_items", "families" add_foreign_key "security_prices", "securities" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" diff --git a/test/controllers/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb index 07186eec12b..7ea30b3c6aa 100644 --- a/test/controllers/plaid_items_controller_test.rb +++ b/test/controllers/plaid_items_controller_test.rb @@ -14,7 +14,7 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest public_token = "public-sandbox-1234" @plaid_provider.expects(:exchange_public_token).with(public_token).returns( - OpenStruct.new(access_token: "access-sandbox-1234") + OpenStruct.new(access_token: "access-sandbox-1234", item_id: "item-sandbox-1234") ) assert_difference "PlaidItem.count", 1 do @@ -40,4 +40,14 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest assert_equal "Accounts removed successfully", flash[:notice] assert_redirected_to accounts_path end + + test "sync" do + plaid_item = plaid_items(:one) + PlaidItem.any_instance.expects(:sync_later).once + + post sync_plaid_item_url(plaid_item) + + assert_equal "Sync started", flash[:notice] + assert_redirected_to accounts_path + end end diff --git a/test/jobs/plaid_item_sync_job_test.rb b/test/jobs/plaid_item_sync_job_test.rb new file mode 100644 index 00000000000..c08c43cdf9e --- /dev/null +++ b/test/jobs/plaid_item_sync_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class PlaidItemSyncJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/account/sync_test.rb b/test/models/account/sync_test.rb index 75b6eaec7bc..dd1424c5bb4 100644 --- a/test/models/account/sync_test.rb +++ b/test/models/account/sync_test.rb @@ -30,7 +30,6 @@ class Account::SyncTest < ActiveSupport::TestCase assert_equal "append", streams.first["action"] assert_equal "remove", streams.second["action"] - assert_equal "append", streams.third["action"] end test "handles sync errors" do diff --git a/test/models/family_test.rb b/test/models/family_test.rb index d56888ecac4..5d01ac222b7 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -66,8 +66,7 @@ def setup account.update! is_active: true Account.any_instance.expects(:needs_sync?).once.returns(true) - Account.any_instance.expects(:last_sync_date).once.returns(2.days.ago.to_date) - Account.any_instance.expects(:sync_later).with(start_date: 2.days.ago.to_date).once + Account.any_instance.expects(:sync_later).with(start_date: nil).once @family.sync end From b9c258929c6d5d295e0b728018fc2da47559c69c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 7 Nov 2024 20:26:32 -0500 Subject: [PATCH 07/23] Provide test encryption keys --- config/application.rb | 2 ++ config/environments/test.rb | 3 +++ 2 files changed, 5 insertions(+) diff --git a/config/application.rb b/config/application.rb index 3615cb7681a..086e28df346 100644 --- a/config/application.rb +++ b/config/application.rb @@ -30,5 +30,7 @@ class Application < Rails::Application config.i18n.fallbacks = true config.app_mode = (ENV["SELF_HOSTED"] == "true" || ENV["SELF_HOSTING_ENABLED"] == "true" ? "self_hosted" : "managed").inquiry + + config.active_record.encryption = Rails.application.credentials.active_record_encryption end end diff --git a/config/environments/test.rb b/config/environments/test.rb index 5e5b9a36bad..1e4a2fcdf80 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -64,6 +64,9 @@ config.action_controller.raise_on_missing_callback_actions = true config.active_record.encryption.encrypt_fixtures = true + config.active_record.encryption.primary_key = "test" + config.active_record.encryption.deterministic_key = "test" + config.active_record.encryption.key_derivation_salt = "test" config.autoload_paths += %w[test/support] end From 28065738f33d92dbab1fe6ff29420790a26a97ac Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 7 Nov 2024 20:30:29 -0500 Subject: [PATCH 08/23] Fix test --- config/environments/test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/environments/test.rb b/config/environments/test.rb index 1e4a2fcdf80..37637b907f5 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -63,10 +63,10 @@ # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true - config.active_record.encryption.encrypt_fixtures = true config.active_record.encryption.primary_key = "test" config.active_record.encryption.deterministic_key = "test" config.active_record.encryption.key_derivation_salt = "test" + config.active_record.encryption.encrypt_fixtures = true config.autoload_paths += %w[test/support] end From e46694e0681e41dc62aec30372fae471913c2fe8 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 7 Nov 2024 20:40:49 -0500 Subject: [PATCH 09/23] Only provide encryption keys in prod --- Gemfile | 1 + Gemfile.lock | 2 ++ config/application.rb | 2 -- config/environments/production.rb | 2 ++ config/environments/test.rb | 3 --- test/models/plaid_item_test.rb | 6 ------ 6 files changed, 5 insertions(+), 11 deletions(-) diff --git a/Gemfile b/Gemfile index 093377c5608..58983ee7bf0 100644 --- a/Gemfile +++ b/Gemfile @@ -38,6 +38,7 @@ gem "image_processing", ">= 1.2" # Other gem "bcrypt", "~> 3.1" gem "jwt" +gem "ostruct" gem "faraday" gem "faraday-retry" gem "faraday-multipart" diff --git a/Gemfile.lock b/Gemfile.lock index be725af4786..9363852ebb4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -280,6 +280,7 @@ GEM octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) + ostruct (0.6.0) pagy (9.1.1) parallel (1.26.3) parser (3.3.5.0) @@ -505,6 +506,7 @@ DEPENDENCIES lucide-rails! mocha octokit + ostruct pagy pg (~> 1.5) plaid diff --git a/config/application.rb b/config/application.rb index 086e28df346..3615cb7681a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -30,7 +30,5 @@ class Application < Rails::Application config.i18n.fallbacks = true config.app_mode = (ENV["SELF_HOSTED"] == "true" || ENV["SELF_HOSTING_ENABLED"] == "true" ? "self_hosted" : "managed").inquiry - - config.active_record.encryption = Rails.application.credentials.active_record_encryption end end diff --git a/config/environments/production.rb b/config/environments/production.rb index ea7cd99da73..c032dfed26a 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -94,4 +94,6 @@ # ] # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } + + config.active_record.encryption = Rails.application.credentials.active_record_encryption end diff --git a/config/environments/test.rb b/config/environments/test.rb index 37637b907f5..5e5b9a36bad 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -63,9 +63,6 @@ # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true - config.active_record.encryption.primary_key = "test" - config.active_record.encryption.deterministic_key = "test" - config.active_record.encryption.key_derivation_salt = "test" config.active_record.encryption.encrypt_fixtures = true config.autoload_paths += %w[test/support] diff --git a/test/models/plaid_item_test.rb b/test/models/plaid_item_test.rb index 0f3b85c72c5..fcc4b6cfd69 100644 --- a/test/models/plaid_item_test.rb +++ b/test/models/plaid_item_test.rb @@ -1,10 +1,4 @@ require "test_helper" class PlaidItemTest < ActiveSupport::TestCase - test "stores hashed access token" do - # access_token = '' - # plaid_item = PlaidItem.create!(family: families(:one), access_token: "1234567890") - # assert_equal PlaidItem.find(plaid_item.id).access_token, plaid_item.access_token - skip - end end From 4067892a89f0ca01e2ad4abc8399b53e87a71bf8 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 7 Nov 2024 20:46:36 -0500 Subject: [PATCH 10/23] Try defining keys in test env --- Gemfile | 1 - Gemfile.lock | 2 -- config/environments/test.rb | 3 +++ 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 58983ee7bf0..093377c5608 100644 --- a/Gemfile +++ b/Gemfile @@ -38,7 +38,6 @@ gem "image_processing", ">= 1.2" # Other gem "bcrypt", "~> 3.1" gem "jwt" -gem "ostruct" gem "faraday" gem "faraday-retry" gem "faraday-multipart" diff --git a/Gemfile.lock b/Gemfile.lock index 9363852ebb4..be725af4786 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -280,7 +280,6 @@ GEM octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) - ostruct (0.6.0) pagy (9.1.1) parallel (1.26.3) parser (3.3.5.0) @@ -506,7 +505,6 @@ DEPENDENCIES lucide-rails! mocha octokit - ostruct pagy pg (~> 1.5) plaid diff --git a/config/environments/test.rb b/config/environments/test.rb index 5e5b9a36bad..37637b907f5 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -63,6 +63,9 @@ # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true + config.active_record.encryption.primary_key = "test" + config.active_record.encryption.deterministic_key = "test" + config.active_record.encryption.key_derivation_salt = "test" config.active_record.encryption.encrypt_fixtures = true config.autoload_paths += %w[test/support] From e11e96d0a3b7a2f219d8fbaa012113ff0bd35e1b Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 8 Nov 2024 13:20:21 -0500 Subject: [PATCH 11/23] Consolidate account sync logic --- app/controllers/accounts_controller.rb | 4 +- app/controllers/concerns/auto_sync.rb | 5 +- app/controllers/webhooks_controller.rb | 3 +- app/jobs/account_sync_job.rb | 7 -- app/jobs/plaid_item_sync_job.rb | 7 -- app/jobs/process_plaid_webhook_job.rb | 7 -- app/jobs/sync_job.rb | 7 ++ app/models/account.rb | 7 +- app/models/account/sync.rb | 75 ------------------- app/models/account/syncable.rb | 29 ------- app/models/concerns/syncable.rb | 36 +++++++++ app/models/family.rb | 26 +++---- app/models/plaid_item.rb | 26 +++---- app/models/plaid_item_sync.rb | 53 ------------- app/models/provider/plaid.rb | 14 ++++ .../provider/plaid/webhook_processor.rb | 25 ------- app/models/sync.rb | 34 +++++++++ app/views/accounts/show/_header.html.erb | 2 +- app/views/plaid_items/_plaid_item.html.erb | 4 +- config/locales/models/account/sync/en.yml | 5 -- config/locales/views/accounts/en.yml | 4 +- config/locales/views/plaid_items/en.yml | 2 +- db/migrate/20241106193743_add_plaid_domain.rb | 28 ++++--- db/schema.rb | 42 ++++------- test/application_system_test_case.rb | 3 - .../account/entries_controller_test.rb | 4 +- .../account/trades_controller_test.rb | 4 +- .../account/transactions_controller_test.rb | 2 +- .../account/transfers_controller_test.rb | 2 +- .../account/valuations_controller_test.rb | 2 +- .../credit_cards_controller_test.rb | 4 +- ..._rate_provider_missings_controller_test.rb | 2 +- test/controllers/loans_controller_test.rb | 4 +- .../controllers/properties_controller_test.rb | 4 +- .../transactions_controller_test.rb | 2 +- test/controllers/vehicles_controller_test.rb | 4 +- test/fixtures/account/syncs.yml | 12 --- test/fixtures/plaid_items.yml | 3 +- test/fixtures/syncs.yml | 14 ++++ .../accountable_resource_interface_test.rb | 4 +- test/interfaces/syncable_interface_test.rb | 30 ++++++++ test/jobs/account_balance_sync_job_test.rb | 7 -- test/jobs/convert_currency_job_test.rb | 7 -- test/jobs/daily_exchange_rate_job_test.rb | 7 -- test/jobs/plaid_item_sync_job_test.rb | 7 -- test/jobs/process_plaid_webhook_job_test.rb | 7 -- test/jobs/sync_job_test.rb | 10 +++ test/models/account/sync_test.rb | 47 ------------ test/models/account_test.rb | 25 +------ test/models/family_test.rb | 44 +++++------ test/models/plaid_item_test.rb | 5 ++ test/models/sync_test.rb | 33 ++++++++ 52 files changed, 299 insertions(+), 452 deletions(-) delete mode 100644 app/jobs/account_sync_job.rb delete mode 100644 app/jobs/plaid_item_sync_job.rb delete mode 100644 app/jobs/process_plaid_webhook_job.rb create mode 100644 app/jobs/sync_job.rb delete mode 100644 app/models/account/sync.rb delete mode 100644 app/models/account/syncable.rb create mode 100644 app/models/concerns/syncable.rb delete mode 100644 app/models/plaid_item_sync.rb delete mode 100644 app/models/provider/plaid/webhook_processor.rb create mode 100644 app/models/sync.rb delete mode 100644 config/locales/models/account/sync/en.yml delete mode 100644 test/fixtures/account/syncs.yml create mode 100644 test/fixtures/syncs.yml create mode 100644 test/interfaces/syncable_interface_test.rb delete mode 100644 test/jobs/account_balance_sync_job_test.rb delete mode 100644 test/jobs/convert_currency_job_test.rb delete mode 100644 test/jobs/daily_exchange_rate_job_test.rb delete mode 100644 test/jobs/plaid_item_sync_job_test.rb delete mode 100644 test/jobs/process_plaid_webhook_job_test.rb create mode 100644 test/jobs/sync_job_test.rb delete mode 100644 test/models/account/sync_test.rb create mode 100644 test/models/sync_test.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 55826266070..d99bc9b9336 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -26,10 +26,12 @@ def sync unless @account.syncing? @account.sync_later end + + redirect_back_or_to accounts_path, notice: t(".success") end def sync_all - Current.family.accounts.active.sync + Current.family.sync_later redirect_back_or_to accounts_path, notice: t(".success") end diff --git a/app/controllers/concerns/auto_sync.rb b/app/controllers/concerns/auto_sync.rb index 122710a6f0f..c7be63ae059 100644 --- a/app/controllers/concerns/auto_sync.rb +++ b/app/controllers/concerns/auto_sync.rb @@ -2,12 +2,11 @@ module AutoSync extend ActiveSupport::Concern included do - before_action :sync_family, if: -> { Current.family.present? && Current.family.needs_sync? } + before_action :sync_family, if: -> { Current.family&.needs_sync? } end private - def sync_family - Current.family.sync + Current.family.sync_later end end diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index c75644dfa99..56ce7c98f89 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -7,8 +7,7 @@ def plaid plaid_verification_header = request.headers["Plaid-Verification"] Provider::Plaid.validate_webhook!(plaid_verification_header, webhook_body) - - ProcessPlaidWebhookJob.perform_later(webhook_body) + Provider::Plaid.process_webhook(webhook_body) render json: { received: true }, status: :ok rescue => error diff --git a/app/jobs/account_sync_job.rb b/app/jobs/account_sync_job.rb deleted file mode 100644 index cdcefffc98c..00000000000 --- a/app/jobs/account_sync_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -class AccountSyncJob < ApplicationJob - queue_as :default - - def perform(account, start_date: nil) - account.sync(start_date: start_date) - end -end diff --git a/app/jobs/plaid_item_sync_job.rb b/app/jobs/plaid_item_sync_job.rb deleted file mode 100644 index 439267932fe..00000000000 --- a/app/jobs/plaid_item_sync_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -class PlaidItemSyncJob < ApplicationJob - queue_as :default - - def perform(plaid_item) - plaid_item.sync - end -end diff --git a/app/jobs/process_plaid_webhook_job.rb b/app/jobs/process_plaid_webhook_job.rb deleted file mode 100644 index 2e033751b8f..00000000000 --- a/app/jobs/process_plaid_webhook_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -class ProcessPlaidWebhookJob < ApplicationJob - queue_as :default - - def perform(webhook_body) - Provider::Plaid::WebhookProcessor.new(webhook_body).process - end -end diff --git a/app/jobs/sync_job.rb b/app/jobs/sync_job.rb new file mode 100644 index 00000000000..51ca4796aa6 --- /dev/null +++ b/app/jobs/sync_job.rb @@ -0,0 +1,7 @@ +class SyncJob < ApplicationJob + queue_as :default + + def perform(syncable, start_date: nil) + syncable.sync(start_date: start_date) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 0d870cfeef6..ae68b710cb1 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -14,7 +14,6 @@ class Account < ApplicationRecord has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade" has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy - has_many :syncs, dependent: :destroy has_many :issues, as: :issuable, dependent: :destroy monetize :balance @@ -87,6 +86,12 @@ def create_and_sync(attributes) end end + def sync_data(sync_record) + resolve_stale_issues + Balance::Syncer.new(self, start_date: sync_record.start_date).run + Holding::Syncer.new(self, start_date: sync_record.start_date).run + end + def original_balance balance_amount = balances.chronological.first&.balance || balance Money.new(balance_amount, currency) diff --git a/app/models/account/sync.rb b/app/models/account/sync.rb deleted file mode 100644 index 14d3131c875..00000000000 --- a/app/models/account/sync.rb +++ /dev/null @@ -1,75 +0,0 @@ -class Account::Sync < ApplicationRecord - belongs_to :account - - enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" } - - class << self - def for(account, start_date: nil) - create! account: account, start_date: start_date - end - - def latest - order(created_at: :desc).first - end - end - - def run - start! - - account.resolve_stale_issues - - sync_balances - sync_holdings - - complete! - rescue StandardError => error - account.observe_unknown_issue(error) - fail! error - - raise error if Rails.env.development? - end - - private - - def sync_balances - Account::Balance::Syncer.new(account, start_date: start_date).run - end - - def sync_holdings - Account::Holding::Syncer.new(account, start_date: start_date).run - end - - def start! - update! status: "syncing", last_ran_at: Time.now - broadcast_start - end - - def complete! - update! status: "completed" - - if account.has_issues? - broadcast_result type: "alert", message: account.highest_priority_issue.title - else - broadcast_result type: "notice", message: "Sync complete" - end - end - - def fail!(error) - update! status: "failed", error: error.message - broadcast_result type: "alert", message: I18n.t("account.sync.failed") - end - - def broadcast_start - broadcast_append_to( - [ account.family, :notifications ], - target: "notification-tray", - partial: "shared/notification", - locals: { id: id, type: "processing", message: "Syncing account balances" } - ) - end - - def broadcast_result(type:, message:) - broadcast_remove_to account.family, :notifications, target: id # Remove persistent syncing notification - account.family.broadcast_refresh - end -end diff --git a/app/models/account/syncable.rb b/app/models/account/syncable.rb deleted file mode 100644 index 382d401fa3c..00000000000 --- a/app/models/account/syncable.rb +++ /dev/null @@ -1,29 +0,0 @@ -module Account::Syncable - extend ActiveSupport::Concern - - class_methods do - def sync(start_date: nil) - all.each { |a| a.sync_later(start_date: start_date) } - end - end - - def syncing? - syncs.syncing.any? - end - - def latest_sync_date - syncs.where.not(last_ran_at: nil).pluck(:last_ran_at).max&.to_date - end - - def needs_sync? - latest_sync_date.nil? || latest_sync_date < Date.current - end - - def sync_later(start_date: nil) - AccountSyncJob.perform_later(self, start_date: start_date) - end - - def sync(start_date: nil) - Account::Sync.for(self, start_date: start_date).run - end -end diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb new file mode 100644 index 00000000000..26e98d83006 --- /dev/null +++ b/app/models/concerns/syncable.rb @@ -0,0 +1,36 @@ +module Syncable + extend ActiveSupport::Concern + + included do + has_many :syncs, as: :syncable, dependent: :destroy + end + + def syncing? + syncs.syncing.any? + end + + def last_synced_at + syncs.ordered.first&.last_ran_at + end + + def needs_sync? + latest_sync&.last_ran_at.nil? || latest_sync.last_ran_at.to_date < Date.current + end + + def sync_later(start_date: nil) + SyncJob.perform_later(self, start_date: start_date) + end + + def sync(start_date: nil, parent_sync: nil) + syncs.create!(start_date: start_date, parent_sync: parent_sync).perform + end + + def sync_data(sync_record) + raise NotImplementedError, "Subclasses must implement the `sync_data` method" + end + + private + def latest_sync + syncs.order(created_at: :desc).first + end +end diff --git a/app/models/family.rb b/app/models/family.rb index a0551ba6357..b13f71265fa 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,5 +1,5 @@ class Family < ApplicationRecord - include Plaidable + include Plaidable, Syncable DATE_FORMATS = [ "%m-%d-%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y", "%e/%m/%Y", "%Y.%m.%d" ] @@ -20,6 +20,16 @@ class Family < ApplicationRecord validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS } + def sync_data(sync_record) + accounts.manual.each do |account| + account.sync(start_date: sync_record.start_date, parent_sync: sync_record) + end + + plaid_items.each do |plaid_item| + plaid_item.sync(start_date: sync_record.start_date, parent_sync: sync_record) + end + end + def get_link_token(webhooks_url:) plaid_provider.get_link_token( user_id: id, @@ -126,20 +136,6 @@ def liabilities Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency) end - def sync(start_date: nil) - accounts.active.each do |account| - if account.needs_sync? - account.sync_later(start_date: start_date) - end - end - - update! last_synced_at: Time.now - end - - def needs_sync? - last_synced_at.nil? || last_synced_at.to_date < Date.current - end - def synth_usage self.class.synth_provider&.usage end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 88c72cab4a8..491f347616d 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -1,5 +1,5 @@ class PlaidItem < ApplicationRecord - include Plaidable + include Plaidable, Syncable encrypts :access_token, deterministic: true validates :name, :access_token, presence: true @@ -9,7 +9,6 @@ class PlaidItem < ApplicationRecord belongs_to :family has_one_attached :logo - has_many :syncs, class_name: "PlaidItemSync", dependent: :destroy has_many :plaid_accounts, dependent: :destroy has_many :accounts, through: :plaid_accounts @@ -27,20 +26,12 @@ def create_from_public_token(token, item_name) end end - def syncing? - syncs.syncing.any? - end - - def last_synced_at - syncs.order(created_at: :desc).first&.last_ran_at - end - - def sync_later - PlaidItemSyncJob.perform_later(self) - end + def sync_data(sync_record) + fetch_and_load_plaid_data - def sync - PlaidItemSync.create!(plaid_item: self).run + accounts.each do |account| + account.sync(start_date: sync_record.start_date, parent_sync: sync_record) + end end def fetch_accounts @@ -48,6 +39,11 @@ def fetch_accounts end private + def fetch_and_load_plaid_data + # TODO + puts "fetching and loading plaid data" + end + def remove_plaid_item plaid_provider.remove_item(access_token) end diff --git a/app/models/plaid_item_sync.rb b/app/models/plaid_item_sync.rb deleted file mode 100644 index 5fd9af96b46..00000000000 --- a/app/models/plaid_item_sync.rb +++ /dev/null @@ -1,53 +0,0 @@ -class PlaidItemSync < ApplicationRecord - belongs_to :plaid_item - - enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" } - - def run - start! - - initialize_item unless plaid_item.plaid_accounts.any? - - sync_accounts - - complete! - rescue StandardError => error - fail! error - - raise error if Rails.env.development? - end - - private - def family - plaid_item.family - end - - def initialize_item - accounts_data = plaid_item.fetch_accounts.accounts - - transaction do - accounts_data.each do |account_data| - plaid_item.plaid_accounts - .create_from_plaid_data!(account_data, family) - end - end - end - - def sync_accounts - plaid_item.accounts.each do |account| - account.sync - end - end - - def start! - update! status: "syncing", last_ran_at: Time.now - end - - def complete! - update! status: "completed" - end - - def fail!(error) - update! status: "failed", error: error.message - end -end diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 6d8edcfa6b4..ec9c01ab0c2 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -2,6 +2,20 @@ class Provider::Plaid attr_reader :client class << self + def process_webhook(webhook_body) + parsed = JSON.parse(webhook_body) + type = parsed["webhook_type"] + code = parsed["webhook_code"] + + case [ type, code ] + when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ] + plaid_item = PlaidItem.find_by(plaid_id: parsed["item_id"]) + plaid_item.sync_later + else + Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}") + end + end + def validate_webhook!(verification_header, raw_body) jwks_loader = ->(options) do key_id = options[:kid] diff --git a/app/models/provider/plaid/webhook_processor.rb b/app/models/provider/plaid/webhook_processor.rb deleted file mode 100644 index b9b2bfa225c..00000000000 --- a/app/models/provider/plaid/webhook_processor.rb +++ /dev/null @@ -1,25 +0,0 @@ -class Provider::Plaid::WebhookProcessor - attr_reader :type, :code, :data - - def initialize(webhook_body) - parsed = JSON.parse(webhook_body) - @type = parsed.delete("webhook_type") - @code = parsed.delete("webhook_code") - @data = parsed - end - - def process - case [ type, code ] - when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ] - process_transactions - else - Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}") - end - end - - private - def process_transactions - item = PlaidItem.find_by(plaid_id: data["item_id"]) - item.sync unless item.syncing? - end -end diff --git a/app/models/sync.rb b/app/models/sync.rb new file mode 100644 index 00000000000..9c9d12ab922 --- /dev/null +++ b/app/models/sync.rb @@ -0,0 +1,34 @@ +class Sync < ApplicationRecord + belongs_to :syncable, polymorphic: true + belongs_to :parent_sync, class_name: "Sync", optional: true + + has_many :child_syncs, class_name: "Sync", foreign_key: :parent_sync_id + + enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" } + + scope :ordered, -> { order(created_at: :desc) } + + def perform + start! + + syncable.sync_data(self) + + complete! + rescue StandardError => error + fail! error + raise error if Rails.env.development? + end + + private + def start! + update! status: :syncing + end + + def complete! + update! status: :completed, last_ran_at: Time.current + end + + def fail!(error) + update! status: :failed, error: error.message, last_ran_at: Time.current + end +end diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index c41f2d482c4..1f87db18ec9 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -20,7 +20,7 @@ <% end %>
- <%= button_to sync_account_path(account), method: :post, class: "flex items-center gap-2", title: "Sync Account" do %> + <%= button_to sync_account_path(account), method: :post, class: "flex items-center gap-2", title: "Sync Account", data: { turbo: false } do %> <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %> <% end %> diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb index f63d880c048..7c47fe0b401 100644 --- a/app/views/plaid_items/_plaid_item.html.erb +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -23,7 +23,9 @@ <%= tag.span t(".syncing") %>
<% else %> -

<%= plaid_item.last_synced_at ? t(".status", last_synced_at: time_ago_in_words(plaid_item.last_synced_at)) : t(".status_never") %>

+

+ <%= plaid_item.last_synced_at ? t(".status", timestamp: time_ago_in_words(plaid_item.last_synced_at)) : t(".status_never") %> +

<% end %>
diff --git a/config/locales/models/account/sync/en.yml b/config/locales/models/account/sync/en.yml deleted file mode 100644 index 324ea42ee9d..00000000000 --- a/config/locales/models/account/sync/en.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -en: - account: - sync: - failed: Sync failed diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index d7d8873feaa..8f9a3ef5c89 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -53,7 +53,9 @@ en: no_liabilities: No liabilities found no_liabilities_description: Add a liability either via connection, importing or entering manually. + sync: + success: Account sync started. This may take a few minutes. sync_all: - success: Successfully queued accounts for syncing. + success: Account sync started. This may take a few minutes. sync_all_button: sync: Sync all diff --git a/config/locales/views/plaid_items/en.yml b/config/locales/views/plaid_items/en.yml index 36a6b1ec879..3a8622dac74 100644 --- a/config/locales/views/plaid_items/en.yml +++ b/config/locales/views/plaid_items/en.yml @@ -6,7 +6,7 @@ en: confirm_body: This will permanently delete all the accounts in this group and all associated data. confirm_title: Delete institution? delete: Delete - status: Last synced %{last_synced_at} ago + status: Last synced %{timestamp} ago status_never: Requires data sync syncing: Syncing... syncing_message: Syncing data... diff --git a/db/migrate/20241106193743_add_plaid_domain.rb b/db/migrate/20241106193743_add_plaid_domain.rb index 1e8a8e9e44e..91221693b56 100644 --- a/db/migrate/20241106193743_add_plaid_domain.rb +++ b/db/migrate/20241106193743_add_plaid_domain.rb @@ -5,17 +5,6 @@ def change t.string :access_token t.string :plaid_id t.string :name - t.datetime :last_synced_at - t.timestamps - end - - create_table :plaid_item_syncs, id: :uuid do |t| - t.references :plaid_item, null: false, type: :uuid, foreign_key: true - t.string :status - t.datetime :last_ran_at - t.string :error - t.jsonb :transactions_data - t.jsonb :accounts_data t.timestamps end @@ -33,10 +22,27 @@ def change t.timestamps end + create_table :syncs, id: :uuid do |t| + t.references :syncable, polymorphic: true, null: false, type: :uuid + t.references :parent_sync, type: :uuid + t.datetime :last_ran_at + t.date :start_date + t.string :status, default: "pending" + t.string :error + t.jsonb :data + + t.timestamps + end + + remove_column :families, :last_synced_at, :datetime remove_column :accounts, :last_sync_date, :date remove_reference :accounts, :institution add_reference :accounts, :plaid_account, type: :uuid, foreign_key: true + drop_table :account_syncs do |t| + t.timestamps + end + drop_table :institutions do |t| t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index 97a83cbb6eb..31c3d17237c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -66,17 +66,6 @@ t.index ["security_id"], name: "index_account_holdings_on_security_id" end - create_table "account_syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id", null: false - t.string "status", default: "pending", null: false - t.date "start_date" - t.datetime "last_ran_at" - t.string "error" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_account_syncs_on_account_id" - end - create_table "account_trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "security_id", null: false t.decimal "qty", precision: 19, scale: 4 @@ -219,7 +208,6 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "currency", default: "USD" - t.datetime "last_synced_at" t.string "locale", default: "en" t.string "stripe_plan_id" t.string "stripe_customer_id" @@ -484,24 +472,11 @@ t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" end - create_table "plaid_item_syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "plaid_item_id", null: false - t.string "status" - t.datetime "last_ran_at" - t.string "error" - t.jsonb "transactions_data" - t.jsonb "accounts_data" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["plaid_item_id"], name: "index_plaid_item_syncs_on_plaid_item_id" - end - create_table "plaid_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "access_token" t.string "plaid_id" t.string "name" - t.datetime "last_synced_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_plaid_items_on_family_id" @@ -579,6 +554,21 @@ t.index ["currency_code"], name: "index_stock_exchanges_on_currency_code" end + create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "syncable_type", null: false + t.uuid "syncable_id", null: false + t.uuid "parent_sync_id" + t.datetime "last_ran_at" + t.date "start_date" + t.string "status", default: "pending" + t.string "error" + t.jsonb "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["parent_sync_id"], name: "index_syncs_on_parent_sync_id" + t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" + end + create_table "taggings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "tag_id", null: false t.string "taggable_type" @@ -631,7 +621,6 @@ add_foreign_key "account_entries", "imports" add_foreign_key "account_holdings", "accounts" add_foreign_key "account_holdings", "securities" - add_foreign_key "account_syncs", "accounts" add_foreign_key "account_trades", "securities" add_foreign_key "account_transactions", "categories", on_delete: :nullify add_foreign_key "account_transactions", "merchants" @@ -650,7 +639,6 @@ add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "merchants", "families" add_foreign_key "plaid_accounts", "plaid_items" - add_foreign_key "plaid_item_syncs", "plaid_items" add_foreign_key "plaid_items", "families" add_foreign_key "security_prices", "securities" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index 80b2711bade..83b45e4f7d3 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -3,9 +3,6 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase setup do Capybara.default_max_wait_time = 5 - - # Prevent "auto sync" from running when tests execute enqueued jobs - families(:dylan_family).update! last_synced_at: Time.now end driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : :chrome, screen_size: [ 1400, 1400 ] diff --git a/test/controllers/account/entries_controller_test.rb b/test/controllers/account/entries_controller_test.rb index b8b38357fb0..d735eb01d60 100644 --- a/test/controllers/account/entries_controller_test.rb +++ b/test/controllers/account/entries_controller_test.rb @@ -19,7 +19,7 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest end assert_redirected_to account_url(entry.account) - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end @@ -51,7 +51,7 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest end assert_redirected_to account_entry_url(entry.account, entry) - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end diff --git a/test/controllers/account/trades_controller_test.rb b/test/controllers/account/trades_controller_test.rb index f45e1b4b673..43f207202dc 100644 --- a/test/controllers/account/trades_controller_test.rb +++ b/test/controllers/account/trades_controller_test.rb @@ -109,7 +109,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert created_entry.amount.positive? assert created_entry.account_trade.qty.positive? assert_equal "Transaction created successfully.", flash[:notice] - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob assert_redirected_to @entry.account end @@ -132,7 +132,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert created_entry.amount.negative? assert created_entry.account_trade.qty.negative? assert_equal "Transaction created successfully.", flash[:notice] - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob assert_redirected_to @entry.account end end diff --git a/test/controllers/account/transactions_controller_test.rb b/test/controllers/account/transactions_controller_test.rb index f52902290f7..ddda4677acc 100644 --- a/test/controllers/account/transactions_controller_test.rb +++ b/test/controllers/account/transactions_controller_test.rb @@ -35,6 +35,6 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest assert_equal "Transaction updated successfully.", flash[:notice] assert_redirected_to account_entry_url(@entry.account, @entry) - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end diff --git a/test/controllers/account/transfers_controller_test.rb b/test/controllers/account/transfers_controller_test.rb index 671147e0a13..72e143451ee 100644 --- a/test/controllers/account/transfers_controller_test.rb +++ b/test/controllers/account/transfers_controller_test.rb @@ -21,7 +21,7 @@ class Account::TransfersControllerTest < ActionDispatch::IntegrationTest name: "Test Transfer" } } - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob end end diff --git a/test/controllers/account/valuations_controller_test.rb b/test/controllers/account/valuations_controller_test.rb index eed2a33fe71..1d3daeb7f8e 100644 --- a/test/controllers/account/valuations_controller_test.rb +++ b/test/controllers/account/valuations_controller_test.rb @@ -29,7 +29,7 @@ class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest end assert_equal "Valuation created successfully.", flash[:notice] - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob assert_redirected_to account_valuations_path(@entry.account) end diff --git a/test/controllers/credit_cards_controller_test.rb b/test/controllers/credit_cards_controller_test.rb index dfd1b5b6787..8c119e8b034 100644 --- a/test/controllers/credit_cards_controller_test.rb +++ b/test/controllers/credit_cards_controller_test.rb @@ -43,7 +43,7 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to created_account assert_equal "Credit card account created", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end test "updates with credit card details" do @@ -78,6 +78,6 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to @account assert_equal "Credit card account updated", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end diff --git a/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb b/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb index 01d48ce3fd3..6b8840142c9 100644 --- a/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb +++ b/test/controllers/issue/exchange_rate_provider_missings_controller_test.rb @@ -13,7 +13,7 @@ class Issue::ExchangeRateProviderMissingsControllerTest < ActionDispatch::Integr } } - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob assert_redirected_to @issue.issuable end end diff --git a/test/controllers/loans_controller_test.rb b/test/controllers/loans_controller_test.rb index 7df26b81dc7..627d82ac407 100644 --- a/test/controllers/loans_controller_test.rb +++ b/test/controllers/loans_controller_test.rb @@ -39,7 +39,7 @@ class LoansControllerTest < ActionDispatch::IntegrationTest assert_redirected_to created_account assert_equal "Loan account created", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end test "updates with loan details" do @@ -70,6 +70,6 @@ class LoansControllerTest < ActionDispatch::IntegrationTest assert_redirected_to @account assert_equal "Loan account updated", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end diff --git a/test/controllers/properties_controller_test.rb b/test/controllers/properties_controller_test.rb index 4641206088a..9eaf63e823c 100644 --- a/test/controllers/properties_controller_test.rb +++ b/test/controllers/properties_controller_test.rb @@ -43,7 +43,7 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to created_account assert_equal "Property account created", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end test "updates with property details" do @@ -74,6 +74,6 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to @account assert_equal "Property account updated", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb index 09b3191016e..5b73c11468f 100644 --- a/test/controllers/transactions_controller_test.rb +++ b/test/controllers/transactions_controller_test.rb @@ -37,7 +37,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest assert_equal entry_params[:amount].to_d, Account::Transaction.order(created_at: :desc).first.entry.amount assert_equal "New transaction created successfully", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) assert_redirected_to account_url(account) end diff --git a/test/controllers/vehicles_controller_test.rb b/test/controllers/vehicles_controller_test.rb index 00df8f36e09..3ebd23e3413 100644 --- a/test/controllers/vehicles_controller_test.rb +++ b/test/controllers/vehicles_controller_test.rb @@ -40,7 +40,7 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to created_account assert_equal "Vehicle account created", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end test "updates with vehicle details" do @@ -66,6 +66,6 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to @account assert_equal "Vehicle account updated", flash[:notice] - assert_enqueued_with(job: AccountSyncJob) + assert_enqueued_with(job: SyncJob) end end diff --git a/test/fixtures/account/syncs.yml b/test/fixtures/account/syncs.yml deleted file mode 100644 index f042d3b17fa..00000000000 --- a/test/fixtures/account/syncs.yml +++ /dev/null @@ -1,12 +0,0 @@ -one: - account: depository - status: failed - start_date: 2024-07-07 - last_ran_at: 2024-07-07 09:03:31 - error: test sync error - -two: - account: investment - status: completed - start_date: 2024-07-07 - last_ran_at: 2024-07-07 09:03:32 diff --git a/test/fixtures/plaid_items.yml b/test/fixtures/plaid_items.yml index a700c367067..21a0b460f53 100644 --- a/test/fixtures/plaid_items.yml +++ b/test/fixtures/plaid_items.yml @@ -2,5 +2,4 @@ one: family: dylan_family plaid_id: "1234567890" access_token: encrypted_token_1 - name: "Test Bank" - last_synced_at: <%= Time.current %> \ No newline at end of file + name: "Test Bank" \ No newline at end of file diff --git a/test/fixtures/syncs.yml b/test/fixtures/syncs.yml new file mode 100644 index 00000000000..db3249fc513 --- /dev/null +++ b/test/fixtures/syncs.yml @@ -0,0 +1,14 @@ +account: + syncable_type: Account + syncable: depository + last_ran_at: <%= Time.now %> + +plaid_item: + syncable_type: PlaidItem + syncable: one + last_ran_at: <%= Time.now %> + +family: + syncable_type: Family + syncable: dylan_family + last_ran_at: <%= Time.now %> diff --git a/test/interfaces/accountable_resource_interface_test.rb b/test/interfaces/accountable_resource_interface_test.rb index 3a3f23916ef..4b9cc76fa34 100644 --- a/test/interfaces/accountable_resource_interface_test.rb +++ b/test/interfaces/accountable_resource_interface_test.rb @@ -70,7 +70,7 @@ module AccountableResourceInterfaceTest end assert_redirected_to @account - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob assert_equal "#{@account.accountable_name.humanize} account updated", flash[:notice] end @@ -86,7 +86,7 @@ module AccountableResourceInterfaceTest end assert_redirected_to @account - assert_enqueued_with job: AccountSyncJob + assert_enqueued_with job: SyncJob assert_equal "#{@account.accountable_name.humanize} account updated", flash[:notice] end end diff --git a/test/interfaces/syncable_interface_test.rb b/test/interfaces/syncable_interface_test.rb new file mode 100644 index 00000000000..0bc53da74e3 --- /dev/null +++ b/test/interfaces/syncable_interface_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +module SyncableInterfaceTest + extend ActiveSupport::Testing::Declarative + include ActiveJob::TestHelper + + test "can sync later" do + assert_enqueued_with job: SyncJob, args: [ @syncable, start_date: nil ] do + @syncable.sync_later + end + end + + test "can sync" do + assert_difference "@syncable.syncs.count", 1 do + @syncable.sync(start_date: 2.days.ago.to_date) + end + end + + test "needs sync if last sync is yesterday or older" do + assert_not @syncable.needs_sync? + + @syncable.syncs.first.update! last_ran_at: 2.days.ago + + assert @syncable.needs_sync? + end + + test "implements sync_data" do + assert_respond_to @syncable, :sync_data + end +end diff --git a/test/jobs/account_balance_sync_job_test.rb b/test/jobs/account_balance_sync_job_test.rb deleted file mode 100644 index af5d627e5de..00000000000 --- a/test/jobs/account_balance_sync_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class Account::BalanceSyncJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/jobs/convert_currency_job_test.rb b/test/jobs/convert_currency_job_test.rb deleted file mode 100644 index a208d53e239..00000000000 --- a/test/jobs/convert_currency_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class ConvertCurrencyJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/jobs/daily_exchange_rate_job_test.rb b/test/jobs/daily_exchange_rate_job_test.rb deleted file mode 100644 index 7308046a65e..00000000000 --- a/test/jobs/daily_exchange_rate_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class DailyExchangeRateJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/jobs/plaid_item_sync_job_test.rb b/test/jobs/plaid_item_sync_job_test.rb deleted file mode 100644 index c08c43cdf9e..00000000000 --- a/test/jobs/plaid_item_sync_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class PlaidItemSyncJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/jobs/process_plaid_webhook_job_test.rb b/test/jobs/process_plaid_webhook_job_test.rb deleted file mode 100644 index 1dca5c871f6..00000000000 --- a/test/jobs/process_plaid_webhook_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class ProcessPlaidWebhookJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/jobs/sync_job_test.rb b/test/jobs/sync_job_test.rb new file mode 100644 index 00000000000..eac669f1881 --- /dev/null +++ b/test/jobs/sync_job_test.rb @@ -0,0 +1,10 @@ +require "test_helper" + +class SyncJobTest < ActiveJob::TestCase + test "sync is performed" do + syncable = accounts(:depository) + syncable.expects(:sync).once + + SyncJob.perform_now(syncable) + end +end diff --git a/test/models/account/sync_test.rb b/test/models/account/sync_test.rb deleted file mode 100644 index dd1424c5bb4..00000000000 --- a/test/models/account/sync_test.rb +++ /dev/null @@ -1,47 +0,0 @@ -require "test_helper" - -class Account::SyncTest < ActiveSupport::TestCase - setup do - @account = accounts(:depository) - - @sync = Account::Sync.for(@account) - - @balance_syncer = mock("Account::Balance::Syncer") - @holding_syncer = mock("Account::Holding::Syncer") - end - - test "runs sync" do - Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once - Account::Holding::Syncer.expects(:new).with(@account, start_date: nil).returns(@holding_syncer).once - - @account.expects(:resolve_stale_issues).once - @balance_syncer.expects(:run).once - @holding_syncer.expects(:run).once - - assert_equal "pending", @sync.status - assert_nil @sync.last_ran_at - - @sync.run - - streams = capture_turbo_stream_broadcasts [ @account.family, :notifications ] - - assert_equal "completed", @sync.status - assert @sync.last_ran_at - - assert_equal "append", streams.first["action"] - assert_equal "remove", streams.second["action"] - end - - test "handles sync errors" do - Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once - Account::Holding::Syncer.expects(:new).with(@account, start_date: nil).returns(@holding_syncer).never # error from balance sync halts entire sync - - @balance_syncer.expects(:run).raises(StandardError.new("test sync error")) - - @sync.run - - assert @sync.last_ran_at - assert_equal "failed", @sync.status - assert_equal "test sync error", @sync.error - end -end diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 06d0e641bc7..15dc923fe04 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -1,34 +1,13 @@ require "test_helper" class AccountTest < ActiveSupport::TestCase - include ActiveJob::TestHelper + include SyncableInterfaceTest setup do - @account = accounts(:depository) + @account = @syncable = accounts(:depository) @family = families(:dylan_family) end - test "can sync later" do - assert_enqueued_with(job: AccountSyncJob, args: [ @account, start_date: Date.current ]) do - @account.sync_later start_date: Date.current - end - end - - test "can sync" do - start_date = 10.days.ago.to_date - - mock_sync = mock("Account::Sync") - mock_sync.expects(:run).once - - Account::Sync.expects(:for).with(@account, start_date: start_date).returns(mock_sync).once - - @account.sync start_date: start_date - end - - test "needs sync if account has not synced today" do - assert @account.needs_sync? - end - test "groups accounts by type" do result = @family.accounts.by_group(period: Period.all) assets = result[:assets] diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 5d01ac222b7..f2a096f2bfd 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -3,9 +3,28 @@ class FamilyTest < ActiveSupport::TestCase include Account::EntriesTestHelper + include SyncableInterfaceTest def setup - @family = families :empty + @family = families(:empty) + @syncable = families(:dylan_family) + end + + test "syncs plaid items and manual accounts" do + family_sync = syncs(:family) + + manual_accounts_count = @syncable.accounts.manual.count + items_count = @syncable.plaid_items.count + + Account.any_instance.expects(:sync) + .with(start_date: nil, parent_sync: family_sync) + .times(manual_accounts_count) + + PlaidItem.any_instance.expects(:sync) + .with(start_date: nil, parent_sync: family_sync) + .times(items_count) + + @syncable.sync_data(family_sync) end test "calculates assets" do @@ -48,29 +67,6 @@ def setup assert_equal Money.new(50000, @family.currency), @family.net_worth end - test "needs sync if last family sync was before today" do - assert @family.needs_sync? - - @family.update! last_synced_at: Time.now - - assert_not @family.needs_sync? - end - - test "syncs active accounts" do - account = create_account(balance: 1000, accountable: CreditCard.new, is_active: false) - - Account.any_instance.expects(:sync_later).never - - @family.sync - - account.update! is_active: true - - Account.any_instance.expects(:needs_sync?).once.returns(true) - Account.any_instance.expects(:sync_later).with(start_date: nil).once - - @family.sync - end - test "calculates snapshot" do asset = create_account(balance: 500, accountable: Depository.new) liability = create_account(balance: 100, accountable: CreditCard.new) diff --git a/test/models/plaid_item_test.rb b/test/models/plaid_item_test.rb index fcc4b6cfd69..050b8a8974e 100644 --- a/test/models/plaid_item_test.rb +++ b/test/models/plaid_item_test.rb @@ -1,4 +1,9 @@ require "test_helper" class PlaidItemTest < ActiveSupport::TestCase + include SyncableInterfaceTest + + setup do + @plaid_item = @syncable = plaid_items(:one) + end end diff --git a/test/models/sync_test.rb b/test/models/sync_test.rb new file mode 100644 index 00000000000..dcce6158855 --- /dev/null +++ b/test/models/sync_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class SyncTest < ActiveSupport::TestCase + setup do + @sync = syncs(:account) + end + + test "runs successful sync" do + @sync.syncable.expects(:sync_data).with(@sync).once + + assert_equal "pending", @sync.status + + previously_ran_at = @sync.last_ran_at + + @sync.perform + + assert @sync.last_ran_at > previously_ran_at + assert_equal "completed", @sync.status + end + + test "handles sync errors" do + @sync.syncable.expects(:sync_data).with(@sync).raises(StandardError.new("test sync error")) + + assert_equal "pending", @sync.status + previously_ran_at = @sync.last_ran_at + + @sync.perform + + assert @sync.last_ran_at > previously_ran_at + assert_equal "failed", @sync.status + assert_equal "test sync error", @sync.error + end +end From e1acd23dd5bf3db7c50b9196a38b9a7df70b58a5 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 8 Nov 2024 13:31:57 -0500 Subject: [PATCH 12/23] Add back plaid account initialization --- app/models/plaid_item.rb | 21 ++++++++++++-------- config/locales/views/accounts/en.yml | 4 ++-- test/controllers/accounts_controller_test.rb | 5 +++-- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 491f347616d..69ab2ad9551 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -27,21 +27,26 @@ def create_from_public_token(token, item_name) end def sync_data(sync_record) - fetch_and_load_plaid_data + fetch_and_load_plaid_data(start_date: sync_record.start_date) accounts.each do |account| account.sync(start_date: sync_record.start_date, parent_sync: sync_record) end end - def fetch_accounts - plaid_provider.get_item_accounts(self) - end - private - def fetch_and_load_plaid_data - # TODO - puts "fetching and loading plaid data" + def fetch_and_load_plaid_data(start_date: nil) + accounts_data = fetch_accounts.accounts + + transaction do + accounts_data.each do |account_data| + plaid_accounts.create_from_plaid_data!(account_data, family) + end + end + end + + def fetch_accounts + plaid_provider.get_item_accounts(self) end def remove_plaid_item diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 8f9a3ef5c89..32b26f5632c 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -54,8 +54,8 @@ en: no_liabilities_description: Add a liability either via connection, importing or entering manually. sync: - success: Account sync started. This may take a few minutes. + success: Account sync started. This may take a few minutes. sync_all: - success: Account sync started. This may take a few minutes. + success: Account sync started. This may take a few minutes. sync_all_button: sync: Sync all diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index 18ff5c7d8c5..d60c63c17f8 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -22,12 +22,13 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest test "can sync an account" do post sync_account_path(@account) - assert_response :no_content + assert_redirected_to accounts_url + assert_equal "Account sync started. This may take a few minutes.", flash[:notice] end test "can sync all accounts" do post sync_all_accounts_path assert_redirected_to accounts_url - assert_equal "Successfully queued accounts for syncing.", flash[:notice] + assert_equal "Account sync started. This may take a few minutes.", flash[:notice] end end From 04fb03e03f744fb2591b2bdcad042d6758dc7264 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 12 Nov 2024 19:03:33 -0500 Subject: [PATCH 13/23] Plaid transaction sync --- app/controllers/accounts_controller.rb | 9 +- app/controllers/plaid_items_controller.rb | 3 +- app/models/concerns/syncable.rb | 2 +- app/models/plaid_account.rb | 106 +++++++++++++----- app/models/plaid_item.rb | 13 ++- app/models/provider/plaid.rb | 28 +++++ app/models/sync.rb | 27 +++++ app/views/accounts/_sync_all_button.html.erb | 4 - app/views/accounts/index.html.erb | 5 +- app/views/accounts/show/_header.html.erb | 2 +- app/views/layouts/application.html.erb | 4 +- config/locales/views/accounts/en.yml | 7 +- config/locales/views/plaid_items/en.yml | 2 - db/migrate/20241106193743_add_plaid_domain.rb | 3 + db/schema.rb | 2 + test/controllers/accounts_controller_test.rb | 6 +- .../plaid_items_controller_test.rb | 3 +- 17 files changed, 168 insertions(+), 58 deletions(-) delete mode 100644 app/views/accounts/_sync_all_button.html.erb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 634e50d1035..cb12779418b 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -28,12 +28,15 @@ def sync @account.sync_later end - redirect_back_or_to accounts_path, notice: t(".success") + head :ok end def sync_all - Current.family.sync_later - redirect_back_or_to accounts_path, notice: t(".success") + unless Current.family.syncing? + Current.family.sync_later + end + + head :ok end private diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index 66eb9c7f40c..61823fac203 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -17,7 +17,8 @@ def destroy def sync @plaid_item.sync_later - redirect_to accounts_path, notice: t(".success") + + head :ok end private diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb index 26e98d83006..6e9a318676c 100644 --- a/app/models/concerns/syncable.rb +++ b/app/models/concerns/syncable.rb @@ -6,7 +6,7 @@ module Syncable end def syncing? - syncs.syncing.any? + syncs.where(status: [ :syncing, :pending ]).any? end def last_synced_at diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index b91974cbfec..5b7087265d5 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -1,4 +1,14 @@ class PlaidAccount < ApplicationRecord + include Plaidable + + TYPE_MAPPING = { + "depository" => Depository, + "credit" => CreditCard, + "loan" => Loan, + "investment" => Investment, + "other" => OtherAsset + } + belongs_to :plaid_item has_one :account, dependent: :destroy @@ -6,38 +16,84 @@ class PlaidAccount < ApplicationRecord accepts_nested_attributes_for :account class << self - def create_from_plaid_data!(plaid_data, family) - create!( - plaid_id: plaid_data.account_id, - current_balance: plaid_data.balances.current, - available_balance: plaid_data.balances.available, - currency: plaid_data.balances.iso_currency_code, - plaid_type: plaid_data.type, - plaid_subtype: plaid_data.subtype, - name: plaid_data.name, - mask: plaid_data.mask, - account: family.accounts.new( + def find_or_create_from_plaid_data!(plaid_data, family) + find_or_create_by!(plaid_id: plaid_data.account_id) do |a| + a.account = family.accounts.new( name: plaid_data.name, balance: plaid_data.balances.current, currency: plaid_data.balances.iso_currency_code, - accountable: plaid_type_to_accountable(plaid_data.type) + accountable: TYPE_MAPPING[plaid_data.type].new ) - ) + end end + end + + def sync_account_data!(plaid_account_data) + update!( + current_balance: plaid_account_data.balances.current, + available_balance: plaid_account_data.balances.available, + currency: plaid_account_data.balances.iso_currency_code, + plaid_type: plaid_account_data.type, + plaid_subtype: plaid_account_data.subtype, + account_attributes: { + id: account.id, + balance: plaid_account_data.balances.current + } + ) + end - def plaid_type_to_accountable(plaid_type) - case plaid_type - when "depository" - Depository.new - when "credit" - CreditCard.new - when "loan" - Loan.new - when "investment" - Investment.new - else - OtherAsset.new + def sync_transactions!(plaid_transactions_data) + plaid_transactions_data.added.each do |plaid_txn| + account.entries.account_transactions.find_or_create_by!(plaid_id: plaid_txn.transaction_id) do |t| + t.name = plaid_txn.name + t.amount = plaid_txn.amount + t.currency = plaid_txn.iso_currency_code + t.date = plaid_txn.date + t.marked_as_transfer = transfer?(plaid_txn) + t.entryable = Account::Transaction.new( + category: get_category(plaid_txn.personal_finance_category.primary), + merchant: get_merchant(plaid_txn.merchant_name) + ) end end + + plaid_transactions_data.modified.each do |plaid_txn| + existing_txn = account.entries.account_transactions.find_by(plaid_id: plaid_txn.transaction_id) + + existing_txn.update!( + amount: plaid_txn.amount, + date: plaid_txn.date + ) + end + + plaid_transactions_data.removed.each do |plaid_txn| + account.entries.account_transactions.find_by(plaid_id: plaid_txn.transaction_id)&.destroy + end end + + private + def family + plaid_item.family + end + + def transfer?(plaid_txn) + transfer_categories = [ "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS" ] + + transfer_categories.include?(plaid_txn.personal_finance_category.primary) + end + + # See https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv + def get_category(plaid_category) + ignored_categories = [ "BANK_FEES", "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS", "OTHER" ] + + return nil if ignored_categories.include?(plaid_category) + + family.categories.find_or_create_by!(name: plaid_category.titleize) + end + + def get_merchant(plaid_merchant_name) + return nil if plaid_merchant_name.blank? + + family.merchants.find_or_create_by!(name: plaid_merchant_name) + end end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 69ab2ad9551..68d08652592 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -36,17 +36,18 @@ def sync_data(sync_record) private def fetch_and_load_plaid_data(start_date: nil) - accounts_data = fetch_accounts.accounts + accounts_data = plaid_provider.get_item_accounts(self).accounts + transactions_data = plaid_provider.get_item_transactions(self) transaction do accounts_data.each do |account_data| - plaid_accounts.create_from_plaid_data!(account_data, family) + plaid_account = plaid_accounts.find_or_create_from_plaid_data!(account_data, family) + plaid_account.sync_account_data!(account_data) + plaid_account.sync_transactions!(transactions_data) end - end - end - def fetch_accounts - plaid_provider.get_item_accounts(self) + update!(prev_cursor: transactions_data.cursor) + end end def remove_plaid_item diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index ec9c01ab0c2..2a07a115cd8 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -92,4 +92,32 @@ def get_item_accounts(item) request = Plaid::AccountsGetRequest.new(access_token: item.access_token) client.accounts_get(request) end + + def get_item_transactions(item) + cursor = item.prev_cursor + added = [] + modified = [] + removed = [] + has_more = true + + while has_more + request = Plaid::TransactionsSyncRequest.new( + access_token: item.access_token, + cursor: cursor + ) + + response = client.transactions_sync(request) + + added += response.added + modified += response.modified + removed += response.removed + has_more = response.has_more + cursor = response.next_cursor + end + + TransactionSyncResponse.new(added:, modified:, removed:, cursor:) + end + + private + TransactionSyncResponse = Struct.new :added, :modified, :removed, :cursor, keyword_init: true end diff --git a/app/models/sync.rb b/app/models/sync.rb index 9c9d12ab922..e32067e4657 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -20,15 +20,42 @@ def perform end private + def family + syncable.is_a?(Family) ? syncable : syncable.family + end + def start! update! status: :syncing + + broadcast_append_to( + [ family, :notifications ], + target: "notification-tray", + partial: "shared/notification", + locals: { id: id, type: "processing", message: "Syncing account balances" } + ) unless parent_sync.present? end def complete! update! status: :completed, last_ran_at: Time.current + broadcast_result unless parent_sync.present? end def fail!(error) update! status: :failed, error: error.message, last_ran_at: Time.current + + broadcast_result(refresh: false) unless parent_sync.present? + + broadcast_append_to( + [ family, :notifications ], + target: "notification-tray", + partial: "shared/notification", + locals: { id: id, type: "alert", message: "Something went wrong while syncing your data." } + ) unless parent_sync.present? + end + + def broadcast_result(refresh: true) + sleep 2 # Artificial delay for user experience + broadcast_remove_to family, :notifications, target: id + family.broadcast_refresh if refresh end end diff --git a/app/views/accounts/_sync_all_button.html.erb b/app/views/accounts/_sync_all_button.html.erb deleted file mode 100644 index 8300df194ad..00000000000 --- a/app/views/accounts/_sync_all_button.html.erb +++ /dev/null @@ -1,4 +0,0 @@ -<%= button_to sync_all_accounts_path, class: "btn btn--outline flex items-center gap-2", title: t(".sync") do %> - <%= lucide_icon "refresh-cw", class: "w-5 h-5" %> - <%= t(".sync") %> -<% end %> diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 13ab12e7688..83325ffb4e2 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -7,7 +7,10 @@

<%= t(".accounts") %>

- <%= render "sync_all_button" %> + <%= button_to sync_all_accounts_path, class: "btn btn--outline flex items-center gap-2", title: t(".sync") do %> + <%= lucide_icon "refresh-cw", class: "w-5 h-5" %> + <%= t(".sync") %> + <% end %> <%= link_to new_account_path(return_to: accounts_path), data: { turbo_frame: "modal" }, diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index 955cc154692..de5955b0f1c 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -20,7 +20,7 @@ <% end %>
- <%= button_to sync_account_path(account), method: :post, class: "flex items-center gap-2", title: "Sync Account", data: { turbo: false } do %> + <%= button_to sync_account_path(account), method: :post, class: "flex items-center gap-2", title: "Sync Account" do %> <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %> <% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index fb0e012ba01..05f7405cab0 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -31,8 +31,8 @@ <%= render "impersonation_sessions/super_admin_bar" if Current.true_user&.super_admin? %> <%= render "impersonation_sessions/approval_bar" if Current.true_user&.impersonated_support_sessions&.initiated&.any? %> -
-
+
+
<%= render_flash_notifications %>
diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 32b26f5632c..aa29c266801 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -19,6 +19,7 @@ en: manual_accounts: other_accounts: Other accounts new_account: New account + sync: Sync all new: import_accounts: Import accounts method_selector: @@ -53,9 +54,3 @@ en: no_liabilities: No liabilities found no_liabilities_description: Add a liability either via connection, importing or entering manually. - sync: - success: Account sync started. This may take a few minutes. - sync_all: - success: Account sync started. This may take a few minutes. - sync_all_button: - sync: Sync all diff --git a/config/locales/views/plaid_items/en.yml b/config/locales/views/plaid_items/en.yml index 3a8622dac74..453059014f4 100644 --- a/config/locales/views/plaid_items/en.yml +++ b/config/locales/views/plaid_items/en.yml @@ -17,5 +17,3 @@ en: success: Account linked successfully. Please wait for accounts to sync. destroy: success: Accounts removed successfully - sync: - success: Sync started diff --git a/db/migrate/20241106193743_add_plaid_domain.rb b/db/migrate/20241106193743_add_plaid_domain.rb index 91221693b56..cdaf1367854 100644 --- a/db/migrate/20241106193743_add_plaid_domain.rb +++ b/db/migrate/20241106193743_add_plaid_domain.rb @@ -5,6 +5,7 @@ def change t.string :access_token t.string :plaid_id t.string :name + t.string :prev_cursor t.timestamps end @@ -39,6 +40,8 @@ def change remove_reference :accounts, :institution add_reference :accounts, :plaid_account, type: :uuid, foreign_key: true + add_column :account_entries, :plaid_id, :string + drop_table :account_syncs do |t| t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index 8402b52226c..e5df3423825 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -46,6 +46,7 @@ t.uuid "import_id" t.text "notes" t.boolean "excluded", default: false + t.string "plaid_id" t.index ["account_id"], name: "index_account_entries_on_account_id" t.index ["import_id"], name: "index_account_entries_on_import_id" t.index ["transfer_id"], name: "index_account_entries_on_transfer_id" @@ -478,6 +479,7 @@ t.string "access_token" t.string "plaid_id" t.string "name" + t.string "prev_cursor" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_plaid_items_on_family_id" diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index d60c63c17f8..e6ddcdc1005 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -22,13 +22,11 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest test "can sync an account" do post sync_account_path(@account) - assert_redirected_to accounts_url - assert_equal "Account sync started. This may take a few minutes.", flash[:notice] + assert_response :ok end test "can sync all accounts" do post sync_all_accounts_path - assert_redirected_to accounts_url - assert_equal "Account sync started. This may take a few minutes.", flash[:notice] + assert_response :ok end end diff --git a/test/controllers/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb index 7ea30b3c6aa..011f10c83bc 100644 --- a/test/controllers/plaid_items_controller_test.rb +++ b/test/controllers/plaid_items_controller_test.rb @@ -47,7 +47,6 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest post sync_plaid_item_url(plaid_item) - assert_equal "Sync started", flash[:notice] - assert_redirected_to accounts_path + assert_response :ok end end From d3b163bf453bae9630031f5f30edeee265872a22 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 13 Nov 2024 15:25:03 -0500 Subject: [PATCH 14/23] Sync UI overhaul for Plaid --- .../stylesheets/application.tailwind.css | 4 +- app/controllers/accounts_controller.rb | 8 +- .../concerns/accountable_resource.rb | 12 +- app/controllers/concerns/auto_sync.rb | 11 +- app/controllers/plaid_items_controller.rb | 8 +- .../controllers/plaid_controller.js | 3 +- app/jobs/destroy_job.rb | 7 ++ app/jobs/sync_job.rb | 4 +- app/models/account.rb | 13 ++- app/models/concerns/syncable.rb | 13 +-- app/models/family.rb | 17 ++- app/models/plaid_item.rb | 29 +++-- app/models/provider/plaid.rb | 45 +++++-- app/models/sync.rb | 32 +---- app/views/account/holdings/index.html.erb | 5 - app/views/accounts/index.html.erb | 11 +- app/views/accounts/show/_header.html.erb | 2 +- app/views/credit_cards/new.html.erb | 2 +- app/views/cryptos/new.html.erb | 2 +- app/views/investments/new.html.erb | 2 +- app/views/layouts/application.html.erb | 4 + app/views/loans/new.html.erb | 2 +- app/views/plaid_items/_plaid_item.html.erb | 110 +++++++++--------- app/views/shared/_notification.html.erb | 2 +- config/i18n-tasks.yml | 2 - config/locales/views/account/holdings/en.yml | 2 - config/locales/views/accounts/en.yml | 6 + config/locales/views/credit_cards/en.yml | 6 - config/locales/views/cryptos/en.yml | 6 - config/locales/views/depositories/en.yml | 6 - config/locales/views/investments/en.yml | 6 - config/locales/views/layout/en.yml | 2 + config/locales/views/loans/en.yml | 6 - config/locales/views/other_assets/en.yml | 6 - config/locales/views/other_liabilities/en.yml | 6 - config/locales/views/plaid_items/en.yml | 20 ++-- config/locales/views/properties/en.yml | 6 - config/locales/views/vehicles/en.yml | 6 - db/migrate/20241106193743_add_plaid_domain.rb | 7 +- db/schema.rb | 8 +- test/controllers/accounts_controller_test.rb | 10 +- .../plaid_items_controller_test.rb | 11 +- test/fixtures/families.yml | 2 + test/fixtures/syncs.yml | 3 + .../accountable_resource_interface_test.rb | 5 +- test/interfaces/syncable_interface_test.rb | 14 +-- test/jobs/sync_job_test.rb | 7 +- test/models/family_test.rb | 10 +- test/models/plaid_item_test.rb | 12 ++ test/models/sync_test.rb | 5 +- test/system/accounts_test.rb | 2 +- 51 files changed, 276 insertions(+), 254 deletions(-) create mode 100644 app/jobs/destroy_job.rb diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 31a3d01f7e5..6d989d2716b 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -101,7 +101,7 @@ } .btn { - @apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer focus:outline-gray-500; + @apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500; } .btn--primary { @@ -113,7 +113,7 @@ } .btn--outline { - @apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50; + @apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400; } .btn--ghost { diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index cb12779418b..4870b3a2866 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -4,8 +4,8 @@ class AccountsController < ApplicationController before_action :set_account, only: %i[sync] def index - @accounts = Current.family.accounts - @plaid_items = Current.family.plaid_items + @manual_accounts = Current.family.accounts.manual.active + @plaid_items = Current.family.plaid_items.active end def summary @@ -28,7 +28,7 @@ def sync @account.sync_later end - head :ok + redirect_to account_path(@account) end def sync_all @@ -36,7 +36,7 @@ def sync_all Current.family.sync_later end - head :ok + redirect_to accounts_path end private diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 4c70e393372..5da2a5de788 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -29,23 +29,25 @@ def edit def create @account = Current.family.accounts.create_and_sync(account_params.except(:return_to)) - redirect_to account_params[:return_to].presence || @account, notice: t(".success") + redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize) end def update @account.update_with_sync!(account_params.except(:return_to)) - redirect_back_or_to @account, notice: t(".success") + redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize) end def destroy - @account.destroy! - redirect_to accounts_path, notice: t(".success") + @account.destroy_later + redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize) end private def set_link_token @link_token = Current.family.get_link_token( - webhooks_url: webhooks_url + webhooks_url: webhooks_url, + redirect_url: accounts_url, + accountable_type: accountable_type.name ) end diff --git a/app/controllers/concerns/auto_sync.rb b/app/controllers/concerns/auto_sync.rb index c7be63ae059..7452a807c1f 100644 --- a/app/controllers/concerns/auto_sync.rb +++ b/app/controllers/concerns/auto_sync.rb @@ -2,11 +2,20 @@ module AutoSync extend ActiveSupport::Concern included do - before_action :sync_family, if: -> { Current.family&.needs_sync? } + before_action :sync_family, if: :family_needs_auto_sync? end private def sync_family + Current.family.update!(last_auto_synced_at: Time.current) Current.family.sync_later end + + def family_needs_auto_sync? + return false unless Current.family.present? + return false unless Current.family.accounts.any? + + Current.family.last_auto_synced_at.blank? || + Current.family.last_auto_synced_at.to_date < Date.current + end end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index 61823fac203..196f6bcd036 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -11,14 +11,16 @@ def create end def destroy - @plaid_item.destroy + @plaid_item.destroy_later redirect_to accounts_path, notice: t(".success") end def sync - @plaid_item.sync_later + unless @plaid_item.syncing? + @plaid_item.sync_later + end - head :ok + redirect_to accounts_path end private diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js index 6d60c4085e7..b58e7c62ac3 100644 --- a/app/javascript/controllers/plaid_controller.js +++ b/app/javascript/controllers/plaid_controller.js @@ -28,7 +28,6 @@ export default class extends Controller { method: "POST", headers: { "Content-Type": "application/json", - Accept: "text/html", "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, }, body: JSON.stringify({ @@ -39,7 +38,7 @@ export default class extends Controller { }), }).then((response) => { if (response.redirected) { - Turbo.visit(response.url); + window.location.href = response.url; } }); } diff --git a/app/jobs/destroy_job.rb b/app/jobs/destroy_job.rb new file mode 100644 index 00000000000..2296c45f85c --- /dev/null +++ b/app/jobs/destroy_job.rb @@ -0,0 +1,7 @@ +class DestroyJob < ApplicationJob + queue_as :default + + def perform(model) + model.destroy + end +end diff --git a/app/jobs/sync_job.rb b/app/jobs/sync_job.rb index 51ca4796aa6..c6f062536a7 100644 --- a/app/jobs/sync_job.rb +++ b/app/jobs/sync_job.rb @@ -1,7 +1,7 @@ class SyncJob < ApplicationJob queue_as :default - def perform(syncable, start_date: nil) - syncable.sync(start_date: start_date) + def perform(sync) + sync.perform end end diff --git a/app/models/account.rb b/app/models/account.rb index ae68b710cb1..f81cf28f5cb 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -20,7 +20,7 @@ class Account < ApplicationRecord enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true } - scope :active, -> { where(is_active: true) } + scope :active, -> { where(is_active: true, scheduled_for_deletion: false) } scope :assets, -> { where(classification: "asset") } scope :liabilities, -> { where(classification: "liability") } scope :alphabetically, -> { order(:name) } @@ -86,10 +86,15 @@ def create_and_sync(attributes) end end - def sync_data(sync_record) + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def sync_data(start_date: nil) resolve_stale_issues - Balance::Syncer.new(self, start_date: sync_record.start_date).run - Holding::Syncer.new(self, start_date: sync_record.start_date).run + Balance::Syncer.new(self, start_date: start_date).run + Holding::Syncer.new(self, start_date: start_date).run end def original_balance diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb index 6e9a318676c..ca0c3577f68 100644 --- a/app/models/concerns/syncable.rb +++ b/app/models/concerns/syncable.rb @@ -13,19 +13,16 @@ def last_synced_at syncs.ordered.first&.last_ran_at end - def needs_sync? - latest_sync&.last_ran_at.nil? || latest_sync.last_ran_at.to_date < Date.current - end - def sync_later(start_date: nil) - SyncJob.perform_later(self, start_date: start_date) + new_sync = syncs.create!(start_date: start_date) + SyncJob.perform_later(new_sync) end - def sync(start_date: nil, parent_sync: nil) - syncs.create!(start_date: start_date, parent_sync: parent_sync).perform + def sync(start_date: nil) + syncs.create!(start_date: start_date).perform end - def sync_data(sync_record) + def sync_data(start_date: nil) raise NotImplementedError, "Subclasses must implement the `sync_data` method" end diff --git a/app/models/family.rb b/app/models/family.rb index b13f71265fa..a04084392d8 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -20,21 +20,28 @@ class Family < ApplicationRecord validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS } - def sync_data(sync_record) + def sync_data(start_date: nil) accounts.manual.each do |account| - account.sync(start_date: sync_record.start_date, parent_sync: sync_record) + account.sync_data(start_date: start_date) end plaid_items.each do |plaid_item| - plaid_item.sync(start_date: sync_record.start_date, parent_sync: sync_record) + plaid_item.sync_data(start_date: start_date) end end - def get_link_token(webhooks_url:) + def syncing? + super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&:syncing?) + end + + def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil) plaid_provider.get_link_token( user_id: id, country: country, - webhooks_url: webhooks_url + language: locale, + webhooks_url: webhooks_url, + redirect_url: redirect_url, + accountable_type: accountable_type ).link_token end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 68d08652592..3c850da0245 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -12,6 +12,8 @@ class PlaidItem < ApplicationRecord has_many :plaid_accounts, dependent: :destroy has_many :accounts, through: :plaid_accounts + scope :active, -> { where(scheduled_for_deletion: false) } + class << self def create_from_public_token(token, item_name) response = plaid_provider.exchange_public_token(token) @@ -26,28 +28,31 @@ def create_from_public_token(token, item_name) end end - def sync_data(sync_record) - fetch_and_load_plaid_data(start_date: sync_record.start_date) + def sync_data(start_date: nil) + fetch_and_load_plaid_data accounts.each do |account| - account.sync(start_date: sync_record.start_date, parent_sync: sync_record) + account.sync_data(start_date: start_date) end end + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + private - def fetch_and_load_plaid_data(start_date: nil) + def fetch_and_load_plaid_data accounts_data = plaid_provider.get_item_accounts(self).accounts transactions_data = plaid_provider.get_item_transactions(self) - transaction do - accounts_data.each do |account_data| - plaid_account = plaid_accounts.find_or_create_from_plaid_data!(account_data, family) - plaid_account.sync_account_data!(account_data) - plaid_account.sync_transactions!(transactions_data) - end - - update!(prev_cursor: transactions_data.cursor) + accounts_data.each do |account_data| + plaid_account = plaid_accounts.find_or_create_from_plaid_data!(account_data, family) + plaid_account.sync_account_data!(account_data) + plaid_account.sync_transactions!(transactions_data) end + + update!(next_cursor: transactions_data.cursor) end def remove_plaid_item diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 2a07a115cd8..d144c732c34 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -1,6 +1,10 @@ class Provider::Plaid attr_reader :client + PLAID_COUNTRY_CODES = %w[US GB ES NL FR IE CA DE IT PL DK NO SE EE LT LV PT BE].freeze + PLAID_LANGUAGES = %w[da nl en et fr de hi it lv lt no pl pt ro es sv vi].freeze + PLAID_PRODUCTS = %w[transactions investments liabilities].freeze + class << self def process_webhook(webhook_body) parsed = JSON.parse(webhook_body) @@ -10,6 +14,11 @@ def process_webhook(webhook_body) case [ type, code ] when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ] plaid_item = PlaidItem.find_by(plaid_id: parsed["item_id"]) + + if parsed["historical_update_complete"] + plaid_item.update!(historical_update_complete: true) + end + plaid_item.sync_later else Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}") @@ -62,14 +71,16 @@ def initialize @client = self.class.client end - def get_link_token(user_id:, country:, webhooks_url:) + def get_link_token(user_id:, country:, language: "en", webhooks_url:, redirect_url:, accountable_type: nil) request = Plaid::LinkTokenCreateRequest.new({ user: { client_user_id: user_id }, - client_name: "Maybe", - products: %w[transactions], - country_codes: [ country ], - language: "en", - webhook: webhooks_url + client_name: "Maybe Finance", + products: get_products(accountable_type), + country_codes: [ get_plaid_country_code(country) ], + language: get_plaid_language(language), + webhook: webhooks_url, + redirect_uri: redirect_url, + transactions: { days_requested: 730 } # max allowed by Plaid }) client.link_token_create(request) @@ -94,7 +105,7 @@ def get_item_accounts(item) end def get_item_transactions(item) - cursor = item.prev_cursor + cursor = item.next_cursor added = [] modified = [] removed = [] @@ -120,4 +131,24 @@ def get_item_transactions(item) private TransactionSyncResponse = Struct.new :added, :modified, :removed, :cursor, keyword_init: true + + def get_products(accountable_type) + case accountable_type + when "Investment" + %w[investments] + when "CreditCard", "Loan" + %w[liabilities] + else + %w[transactions] + end + end + + def get_plaid_country_code(country_code) + PLAID_COUNTRY_CODES.include?(country_code) ? country_code : "US" + end + + def get_plaid_language(locale = "en") + language = locale.split("-").first + PLAID_LANGUAGES.include?(language) ? language : "en" + end end diff --git a/app/models/sync.rb b/app/models/sync.rb index e32067e4657..2769fb28e49 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -1,8 +1,5 @@ class Sync < ApplicationRecord belongs_to :syncable, polymorphic: true - belongs_to :parent_sync, class_name: "Sync", optional: true - - has_many :child_syncs, class_name: "Sync", foreign_key: :parent_sync_id enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" } @@ -11,7 +8,9 @@ class Sync < ApplicationRecord def perform start! - syncable.sync_data(self) + transaction do + syncable.sync_data(start_date: start_date) + end complete! rescue StandardError => error @@ -26,36 +25,17 @@ def family def start! update! status: :syncing - - broadcast_append_to( - [ family, :notifications ], - target: "notification-tray", - partial: "shared/notification", - locals: { id: id, type: "processing", message: "Syncing account balances" } - ) unless parent_sync.present? end def complete! update! status: :completed, last_ran_at: Time.current - broadcast_result unless parent_sync.present? + + family.broadcast_refresh end def fail!(error) update! status: :failed, error: error.message, last_ran_at: Time.current - broadcast_result(refresh: false) unless parent_sync.present? - - broadcast_append_to( - [ family, :notifications ], - target: "notification-tray", - partial: "shared/notification", - locals: { id: id, type: "alert", message: "Something went wrong while syncing your data." } - ) unless parent_sync.present? - end - - def broadcast_result(refresh: true) - sleep 2 # Artificial delay for user experience - broadcast_remove_to family, :notifications, target: id - family.broadcast_refresh if refresh + family.broadcast_refresh end end diff --git a/app/views/account/holdings/index.html.erb b/app/views/account/holdings/index.html.erb index 993e6b15014..af87088b767 100644 --- a/app/views/account/holdings/index.html.erb +++ b/app/views/account/holdings/index.html.erb @@ -23,11 +23,6 @@
<% if @holdings.any? %> <%= render partial: "account/holdings/holding", collection: @holdings, spacer_template: "ruler" %> - <% elsif @account.needs_sync? || true %> -
-

<%= t(".needs_sync") %>

- <%= button_to "Sync holding prices", sync_account_path(@account), class: "bg-gray-900 text-white text-sm rounded-lg px-3 py-2" %> -
<% else %>

<%= t(".no_holdings") %>

<% end %> diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 83325ffb4e2..7147faf3232 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -7,7 +7,10 @@

<%= t(".accounts") %>

- <%= button_to sync_all_accounts_path, class: "btn btn--outline flex items-center gap-2", title: t(".sync") do %> + <%= button_to sync_all_accounts_path, + disabled: Current.family.syncing?, + class: "btn btn--outline flex items-center gap-2", + title: t(".sync") do %> <%= lucide_icon "refresh-cw", class: "w-5 h-5" %> <%= t(".sync") %> <% end %> @@ -22,7 +25,7 @@
- <% if @accounts.empty? %> + <% if @manual_accounts.empty? && @plaid_items.empty? %> <%= render "empty" %> <% else %>
@@ -30,8 +33,8 @@ <%= render @plaid_items %> <% end %> - <% if @accounts.manual.any? %> - <%= render "accounts/index/manual_accounts", accounts: @accounts.manual %> + <% if @manual_accounts.any? %> + <%= render "accounts/index/manual_accounts", accounts: @manual_accounts %> <% end %>
<% end %> diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index de5955b0f1c..4d58aa9a55a 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -20,7 +20,7 @@ <% end %>
- <%= button_to sync_account_path(account), method: :post, class: "flex items-center gap-2", title: "Sync Account" do %> + <%= button_to sync_account_path(account), disabled: account.syncing?, class: "flex items-center gap-2", title: "Sync Account" do %> <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %> <% end %> diff --git a/app/views/credit_cards/new.html.erb b/app/views/credit_cards/new.html.erb index 46645cb2fae..9297f2ae8aa 100644 --- a/app/views/credit_cards/new.html.erb +++ b/app/views/credit_cards/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_credit_card_path(return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_credit_card_path(return_to: params[:return_to]), link_token: @link_token %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "credit_cards/form", account: @account, url: credit_cards_path %> diff --git a/app/views/cryptos/new.html.erb b/app/views/cryptos/new.html.erb index 6f6433d2a85..ff9e1d07380 100644 --- a/app/views/cryptos/new.html.erb +++ b/app/views/cryptos/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_crypto_path(return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_crypto_path(return_to: params[:return_to]), link_token: @link_token %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "cryptos/form", account: @account, url: cryptos_path %> diff --git a/app/views/investments/new.html.erb b/app/views/investments/new.html.erb index e1db18ef290..532a168cdd7 100644 --- a/app/views/investments/new.html.erb +++ b/app/views/investments/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_investment_path(return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_investment_path(return_to: params[:return_to]), link_token: @link_token %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "investments/form", account: @account, url: investments_path %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 05f7405cab0..305987c3ecb 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -34,6 +34,10 @@
<%= render_flash_notifications %> + + <% if Current.family&.syncing? %> + <%= render "shared/notification", id: "syncing-notification", type: :processing, message: t(".syncing") %> + <% end %>
diff --git a/app/views/loans/new.html.erb b/app/views/loans/new.html.erb index d9eeaa6ae50..110a68bd9d8 100644 --- a/app/views/loans/new.html.erb +++ b/app/views/loans/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_loan_path(return_to: params[:return_to]) %> + <%= render "accounts/new/method_selector", path: new_loan_path(return_to: params[:return_to]), link_token: @link_token %> <% else %> <%= modal_form_wrapper title: t(".title") do %> <%= render "loans/form", account: @account, url: loans_path %> diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb index 7c47fe0b401..730115a28d7 100644 --- a/app/views/plaid_items/_plaid_item.html.erb +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -1,43 +1,44 @@ <%# locals: (plaid_item:) %> -
- -
- <%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-gray-500 w-5" %> +<%= tag.div id: dom_id(plaid_item) do %> +
+ +
+ <%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-gray-500 w-5" %> -
- <% if plaid_item.logo.attached? %> - <%= image_tag plaid_item.logo, class: "rounded-full h-full w-full" %> - <% else %> -
- <%= tag.p plaid_item.name.first.upcase, class: "text-blue-600 text-xs font-medium" %> -
- <% end %> -
+
+ <% if plaid_item.logo.attached? %> + <%= image_tag plaid_item.logo, class: "rounded-full h-full w-full" %> + <% else %> +
+ <%= tag.p plaid_item.name.first.upcase, class: "text-blue-600 text-xs font-medium" %> +
+ <% end %> +
-
- <%= tag.p plaid_item.name, class: "font-medium text-gray-900" %> - <% if plaid_item.syncing? %> -
- <%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %> - <%= tag.span t(".syncing") %> -
- <% else %> -

- <%= plaid_item.last_synced_at ? t(".status", timestamp: time_ago_in_words(plaid_item.last_synced_at)) : t(".status_never") %> -

- <% end %> +
+ <%= tag.p plaid_item.name, class: "font-medium text-gray-900" %> + <% if plaid_item.syncing? || plaid_item.historical_update_complete == false %> +
+ <%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %> + <%= tag.span t(".syncing") %> +
+ <% else %> +

+ <%= plaid_item.last_synced_at ? t(".status", timestamp: time_ago_in_words(plaid_item.last_synced_at)) : t(".status_never") %> +

+ <% end %> +
-
-
- <%= button_to sync_plaid_item_path(plaid_item), method: :post, class: "text-gray-900 flex hover:text-gray-800 items-center text-sm font-medium hover:underline" do %> - <%= lucide_icon "refresh-cw", class: "w-4 h-4" %> - <% end %> +
+ <%= button_to sync_plaid_item_path(plaid_item), disabled: plaid_item.syncing?, class: "disabled:text-gray-400 text-gray-900 flex hover:text-gray-800 items-center text-sm font-medium hover:underline" do %> + <%= lucide_icon "refresh-cw", class: "w-4 h-4" %> + <% end %> - <%= contextual_menu do %> -
- <%= button_to plaid_item_path(plaid_item), + <%= contextual_menu do %> +
+ <%= button_to plaid_item_path(plaid_item), method: :delete, class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg", data: { @@ -47,28 +48,29 @@ accept: t(".confirm_accept") } } do %> - <%= lucide_icon "trash-2", class: "w-5 h-5" %> + <%= lucide_icon "trash-2", class: "w-5 h-5" %> - <%= t(".delete") %> - <% end %> + <%= t(".delete") %> + <% end %> +
+ <% end %> +
+
+ +
+ <% if plaid_item.historical_update_complete == false %> +
+

<%= t(".historical_update_message") %>

+

<%= t(".historical_update_description") %>

+
+ <% elsif plaid_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: plaid_item.accounts %> + <% else %> +
+

<%= t(".no_accounts_title") %>

+

<%= t(".no_accounts_description") %>

<% end %>
-
- -
- <% if plaid_item.accounts.any? %> - <%= render "accounts/index/account_groups", accounts: plaid_item.accounts %> - <% elsif plaid_item.syncs.empty? %> -
-

<%= t(".syncing_message") %>

-

<%= t(".syncing_description") %>

-
- <% else %> -
-

<%= t(".no_accounts_title") %>

-

<%= t(".no_accounts_description") %>

-
- <% end %> -
-
+ +<% end %> diff --git a/app/views/shared/_notification.html.erb b/app/views/shared/_notification.html.erb index 7f4cef0cc47..6f6ed438cbc 100644 --- a/app/views/shared/_notification.html.erb +++ b/app/views/shared/_notification.html.erb @@ -4,7 +4,7 @@ <% action = "animationend->element-removal#remove" if type == :notice %> <%= tag.div class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25", - id: id, + id: type == :processing ? "syncing-notification" : id, data: { controller: "element-removal", action: action diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 458a8c08cbc..bf7d512bba8 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -26,8 +26,6 @@ search: - app/assets/fonts - app/assets/videos - app/assets/builds -ignore_missing: - - 'accountable_resource.{create,update,destroy}.success' ignore_unused: - 'activerecord.attributes.*' # i18n-tasks does not detect these on forms, forms validations (https://github.com/glebm/i18n-tasks/blob/0b4b483c82664f26c5696fb0f6aa1297356e4683/templates/config/i18n-tasks.yml#L146) - 'activerecord.models.*' # i18n-tasks does not detect use in dynamic model names (e.g. object.model_name.human) diff --git a/config/locales/views/account/holdings/en.yml b/config/locales/views/account/holdings/en.yml index ad5eafacc45..99fb3a420f7 100644 --- a/config/locales/views/account/holdings/en.yml +++ b/config/locales/views/account/holdings/en.yml @@ -9,8 +9,6 @@ en: cost: cost holdings: Holdings name: name - needs_sync: Your account needs to sync the latest prices to calculate this - portfolio new_holding: New transaction no_holdings: No holdings to show. return: total return diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index aa29c266801..6320e0394bf 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -6,6 +6,10 @@ en: troubleshoot: Troubleshoot account_list: new_account: New %{type} + create: + success: "%{type} account created" + destroy: + success: "%{type} account scheduled for deletion" empty: empty_message: Add an account either via connection, importing or entering manually. new_account: New account @@ -54,3 +58,5 @@ en: no_liabilities: No liabilities found no_liabilities_description: Add a liability either via connection, importing or entering manually. + update: + success: "%{type} account updated" diff --git a/config/locales/views/credit_cards/en.yml b/config/locales/views/credit_cards/en.yml index 53ff3163a17..f0131d21809 100644 --- a/config/locales/views/credit_cards/en.yml +++ b/config/locales/views/credit_cards/en.yml @@ -1,10 +1,6 @@ --- en: credit_cards: - create: - success: Credit card account created - destroy: - success: Credit card account deleted edit: edit: Edit %{account} form: @@ -27,5 +23,3 @@ en: expiration_date: Expiration Date minimum_payment: Minimum Payment unknown: Unknown - update: - success: Credit card account updated diff --git a/config/locales/views/cryptos/en.yml b/config/locales/views/cryptos/en.yml index 3e8619af61c..298cc9cc7bc 100644 --- a/config/locales/views/cryptos/en.yml +++ b/config/locales/views/cryptos/en.yml @@ -1,13 +1,7 @@ --- en: cryptos: - create: - success: Crypto account created - destroy: - success: Crypto account deleted edit: edit: Edit %{account} new: title: Enter account balance - update: - success: Crypto account updated diff --git a/config/locales/views/depositories/en.yml b/config/locales/views/depositories/en.yml index 38a91c29c0a..081d14679da 100644 --- a/config/locales/views/depositories/en.yml +++ b/config/locales/views/depositories/en.yml @@ -1,10 +1,6 @@ --- en: depositories: - create: - success: Depository account created - destroy: - success: Depository account deleted edit: edit: Edit %{account} form: @@ -12,5 +8,3 @@ en: subtype_prompt: Select account type new: title: Enter account balance - update: - success: Depository account updated diff --git a/config/locales/views/investments/en.yml b/config/locales/views/investments/en.yml index ed83fb05aa8..471d88de7f3 100644 --- a/config/locales/views/investments/en.yml +++ b/config/locales/views/investments/en.yml @@ -1,10 +1,6 @@ --- en: investments: - create: - success: Investment account created - destroy: - success: Investment account deleted edit: edit: Edit %{account} form: @@ -14,8 +10,6 @@ en: title: Enter account balance show: chart_title: Total value - update: - success: Investment account updated value_tooltip: cash: Cash holdings: Holdings diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml index 8adc4792e49..c0598db9b4c 100644 --- a/config/locales/views/layout/en.yml +++ b/config/locales/views/layout/en.yml @@ -1,6 +1,8 @@ --- en: layouts: + application: + syncing: Syncing account data... auth: existing_account: Already have an account? no_account: New to Maybe? diff --git a/config/locales/views/loans/en.yml b/config/locales/views/loans/en.yml index ae143a7d68b..930af8dfc43 100644 --- a/config/locales/views/loans/en.yml +++ b/config/locales/views/loans/en.yml @@ -1,10 +1,6 @@ --- en: loans: - create: - success: Loan account created - destroy: - success: Loan account deleted edit: edit: Edit %{account} form: @@ -24,5 +20,3 @@ en: term: Term type: Type unknown: Unknown - update: - success: Loan account updated diff --git a/config/locales/views/other_assets/en.yml b/config/locales/views/other_assets/en.yml index ce079a1d4c9..be3f0f43147 100644 --- a/config/locales/views/other_assets/en.yml +++ b/config/locales/views/other_assets/en.yml @@ -1,13 +1,7 @@ --- en: other_assets: - create: - success: Other asset account created - destroy: - success: Other asset account deleted edit: edit: Edit %{account} new: title: Enter asset details - update: - success: Other asset account updated diff --git a/config/locales/views/other_liabilities/en.yml b/config/locales/views/other_liabilities/en.yml index 332c3846286..9008481ecd9 100644 --- a/config/locales/views/other_liabilities/en.yml +++ b/config/locales/views/other_liabilities/en.yml @@ -1,13 +1,7 @@ --- en: other_liabilities: - create: - success: Other liability account created - destroy: - success: Other liability account deleted edit: edit: Edit %{account} new: title: Enter liability details - update: - success: Other liability account updated diff --git a/config/locales/views/plaid_items/en.yml b/config/locales/views/plaid_items/en.yml index 453059014f4..fc09d65cdf3 100644 --- a/config/locales/views/plaid_items/en.yml +++ b/config/locales/views/plaid_items/en.yml @@ -1,19 +1,21 @@ --- en: plaid_items: + create: + success: Account linked successfully. Please wait for accounts to sync. + destroy: + success: Accounts scheduled for deletion. plaid_item: confirm_accept: Delete institution - confirm_body: This will permanently delete all the accounts in this group and all associated data. + confirm_body: This will permanently delete all the accounts in this group and + all associated data. confirm_title: Delete institution? delete: Delete + no_accounts_description: We could not load any accounts from this financial + institution. + no_accounts_title: No accounts found status: Last synced %{timestamp} ago status_never: Requires data sync syncing: Syncing... - syncing_message: Syncing data... - syncing_description: Hang tight, we're syncing your bank data. This may take a few minutes. - no_accounts_title: No accounts found - no_accounts_description: We could not load any accounts from this financial institution. - create: - success: Account linked successfully. Please wait for accounts to sync. - destroy: - success: Accounts removed successfully + historical_update_message: Setting up linked account... + historical_update_description: We're loading your historical transaction data from Plaid. This may take several minutes. diff --git a/config/locales/views/properties/en.yml b/config/locales/views/properties/en.yml index 0558b5e6758..289335bb826 100644 --- a/config/locales/views/properties/en.yml +++ b/config/locales/views/properties/en.yml @@ -1,10 +1,6 @@ --- en: properties: - create: - success: Property account created - destroy: - success: Property account deleted edit: edit: Edit %{account} form: @@ -34,5 +30,3 @@ en: trend: Trend unknown: Unknown year_built: Year Built - update: - success: Property account updated diff --git a/config/locales/views/vehicles/en.yml b/config/locales/views/vehicles/en.yml index ef5a13c4403..03ed5225296 100644 --- a/config/locales/views/vehicles/en.yml +++ b/config/locales/views/vehicles/en.yml @@ -1,10 +1,6 @@ --- en: vehicles: - create: - success: Vehicle account created - destroy: - success: Vehicle account deleted edit: edit: Edit %{account} form: @@ -27,5 +23,3 @@ en: trend: Trend unknown: Unknown year: Year - update: - success: Vehicle account updated diff --git a/db/migrate/20241106193743_add_plaid_domain.rb b/db/migrate/20241106193743_add_plaid_domain.rb index cdaf1367854..ffb6f92813b 100644 --- a/db/migrate/20241106193743_add_plaid_domain.rb +++ b/db/migrate/20241106193743_add_plaid_domain.rb @@ -5,7 +5,9 @@ def change t.string :access_token t.string :plaid_id t.string :name - t.string :prev_cursor + t.string :next_cursor + t.boolean :scheduled_for_deletion, default: false + t.boolean :historical_update_complete, default: false t.timestamps end @@ -25,7 +27,6 @@ def change create_table :syncs, id: :uuid do |t| t.references :syncable, polymorphic: true, null: false, type: :uuid - t.references :parent_sync, type: :uuid t.datetime :last_ran_at t.date :start_date t.string :status, default: "pending" @@ -36,11 +37,13 @@ def change end remove_column :families, :last_synced_at, :datetime + add_column :families, :last_auto_synced_at, :datetime remove_column :accounts, :last_sync_date, :date remove_reference :accounts, :institution add_reference :accounts, :plaid_account, type: :uuid, foreign_key: true add_column :account_entries, :plaid_id, :string + add_column :accounts, :scheduled_for_deletion, :boolean, default: false drop_table :account_syncs do |t| t.timestamps diff --git a/db/schema.rb b/db/schema.rb index e5df3423825..3ff71c6de46 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -110,6 +110,7 @@ t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" t.uuid "plaid_account_id" + t.boolean "scheduled_for_deletion", default: false t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type" @@ -215,6 +216,7 @@ t.string "stripe_subscription_status", default: "incomplete" t.string "date_format", default: "%m-%d-%Y" t.string "country", default: "US" + t.datetime "last_auto_synced_at" end create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -479,7 +481,9 @@ t.string "access_token" t.string "plaid_id" t.string "name" - t.string "prev_cursor" + t.string "next_cursor" + t.boolean "scheduled_for_deletion", default: false + t.boolean "historical_update_complete", default: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_plaid_items_on_family_id" @@ -560,7 +564,6 @@ create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "syncable_type", null: false t.uuid "syncable_id", null: false - t.uuid "parent_sync_id" t.datetime "last_ran_at" t.date "start_date" t.string "status", default: "pending" @@ -568,7 +571,6 @@ t.jsonb "data" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["parent_sync_id"], name: "index_syncs_on_parent_sync_id" t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" end diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index e6ddcdc1005..a2312018cc2 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -10,9 +10,13 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest get accounts_url assert_response :success - @user.family.accounts.each do |account| + @user.family.accounts.manual.each do |account| assert_dom "#" + dom_id(account), count: 1 end + + @user.family.plaid_items.each do |item| + assert_dom "#" + dom_id(item), count: 1 + end end test "new" do @@ -22,11 +26,11 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest test "can sync an account" do post sync_account_path(@account) - assert_response :ok + assert_redirected_to account_path(@account) end test "can sync all accounts" do post sync_all_accounts_path - assert_response :ok + assert_redirected_to accounts_path end end diff --git a/test/controllers/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb index 011f10c83bc..7f9a3afe681 100644 --- a/test/controllers/plaid_items_controller_test.rb +++ b/test/controllers/plaid_items_controller_test.rb @@ -31,13 +31,10 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest end test "destroy" do - @plaid_provider.expects(:remove_item).once + delete plaid_item_url(plaid_items(:one)) - assert_difference [ "PlaidItem.count", "PlaidAccount.count", "Account.count" ], -1 do - delete plaid_item_url(plaid_items(:one)) - end - - assert_equal "Accounts removed successfully", flash[:notice] + assert_equal "Accounts scheduled for deletion.", flash[:notice] + assert_enqueued_with job: DestroyJob assert_redirected_to accounts_path end @@ -47,6 +44,6 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest post sync_plaid_item_url(plaid_item) - assert_response :ok + assert_redirected_to accounts_path end end diff --git a/test/fixtures/families.yml b/test/fixtures/families.yml index 57697046c67..26b56ae543b 100644 --- a/test/fixtures/families.yml +++ b/test/fixtures/families.yml @@ -1,8 +1,10 @@ empty: name: Family stripe_subscription_status: active + last_auto_synced_at: <%= Time.now %> dylan_family: name: The Dylan Family stripe_subscription_status: active + last_auto_synced_at: <%= Time.now %> diff --git a/test/fixtures/syncs.yml b/test/fixtures/syncs.yml index db3249fc513..1b01056894d 100644 --- a/test/fixtures/syncs.yml +++ b/test/fixtures/syncs.yml @@ -2,13 +2,16 @@ account: syncable_type: Account syncable: depository last_ran_at: <%= Time.now %> + status: completed plaid_item: syncable_type: PlaidItem syncable: one last_ran_at: <%= Time.now %> + status: completed family: syncable_type: Family syncable: dylan_family last_ran_at: <%= Time.now %> + status: completed diff --git a/test/interfaces/accountable_resource_interface_test.rb b/test/interfaces/accountable_resource_interface_test.rb index 4b9cc76fa34..c1fe92089b5 100644 --- a/test/interfaces/accountable_resource_interface_test.rb +++ b/test/interfaces/accountable_resource_interface_test.rb @@ -25,7 +25,8 @@ module AccountableResourceInterfaceTest test "destroys account" do delete account_url(@account) assert_redirected_to accounts_path - assert_equal "#{@account.accountable_name.humanize} account deleted", flash[:notice] + assert_enqueued_with job: DestroyJob + assert_equal "#{@account.accountable_name.underscore.humanize} account scheduled for deletion", flash[:notice] end test "updates basic account balances" do @@ -40,7 +41,7 @@ module AccountableResourceInterfaceTest end assert_redirected_to @account - assert_equal "#{@account.accountable_name.humanize} account updated", flash[:notice] + assert_equal "#{@account.accountable_name.underscore.humanize} account updated", flash[:notice] end test "creates with basic attributes" do diff --git a/test/interfaces/syncable_interface_test.rb b/test/interfaces/syncable_interface_test.rb index 0bc53da74e3..6613dbcc26a 100644 --- a/test/interfaces/syncable_interface_test.rb +++ b/test/interfaces/syncable_interface_test.rb @@ -5,8 +5,10 @@ module SyncableInterfaceTest include ActiveJob::TestHelper test "can sync later" do - assert_enqueued_with job: SyncJob, args: [ @syncable, start_date: nil ] do - @syncable.sync_later + assert_difference "@syncable.syncs.count", 1 do + assert_enqueued_with job: SyncJob do + @syncable.sync_later + end end end @@ -16,14 +18,6 @@ module SyncableInterfaceTest end end - test "needs sync if last sync is yesterday or older" do - assert_not @syncable.needs_sync? - - @syncable.syncs.first.update! last_ran_at: 2.days.ago - - assert @syncable.needs_sync? - end - test "implements sync_data" do assert_respond_to @syncable, :sync_data end diff --git a/test/jobs/sync_job_test.rb b/test/jobs/sync_job_test.rb index eac669f1881..b8d34400619 100644 --- a/test/jobs/sync_job_test.rb +++ b/test/jobs/sync_job_test.rb @@ -3,8 +3,11 @@ class SyncJobTest < ActiveJob::TestCase test "sync is performed" do syncable = accounts(:depository) - syncable.expects(:sync).once - SyncJob.perform_now(syncable) + sync = syncable.syncs.create!(start_date: 2.days.ago.to_date) + + sync.expects(:perform).once + + SyncJob.perform_now(sync) end end diff --git a/test/models/family_test.rb b/test/models/family_test.rb index f2a096f2bfd..74376a7e1c8 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -16,15 +16,15 @@ def setup manual_accounts_count = @syncable.accounts.manual.count items_count = @syncable.plaid_items.count - Account.any_instance.expects(:sync) - .with(start_date: nil, parent_sync: family_sync) + Account.any_instance.expects(:sync_data) + .with(start_date: nil) .times(manual_accounts_count) - PlaidItem.any_instance.expects(:sync) - .with(start_date: nil, parent_sync: family_sync) + PlaidItem.any_instance.expects(:sync_data) + .with(start_date: nil) .times(items_count) - @syncable.sync_data(family_sync) + @syncable.sync_data(start_date: family_sync.start_date) end test "calculates assets" do diff --git a/test/models/plaid_item_test.rb b/test/models/plaid_item_test.rb index 050b8a8974e..d689e855047 100644 --- a/test/models/plaid_item_test.rb +++ b/test/models/plaid_item_test.rb @@ -6,4 +6,16 @@ class PlaidItemTest < ActiveSupport::TestCase setup do @plaid_item = @syncable = plaid_items(:one) end + + test "removes plaid item when destroyed" do + @plaid_provider = mock + + PlaidItem.stubs(:plaid_provider).returns(@plaid_provider) + + @plaid_provider.expects(:remove_item).with(@plaid_item.access_token).once + + assert_difference "PlaidItem.count", -1 do + @plaid_item.destroy + end + end end diff --git a/test/models/sync_test.rb b/test/models/sync_test.rb index dcce6158855..5fdf58983ed 100644 --- a/test/models/sync_test.rb +++ b/test/models/sync_test.rb @@ -3,10 +3,11 @@ class SyncTest < ActiveSupport::TestCase setup do @sync = syncs(:account) + @sync.update(status: "pending") end test "runs successful sync" do - @sync.syncable.expects(:sync_data).with(@sync).once + @sync.syncable.expects(:sync_data).with(start_date: @sync.start_date).once assert_equal "pending", @sync.status @@ -19,7 +20,7 @@ class SyncTest < ActiveSupport::TestCase end test "handles sync errors" do - @sync.syncable.expects(:sync_data).with(@sync).raises(StandardError.new("test sync error")) + @sync.syncable.expects(:sync_data).with(start_date: @sync.start_date).raises(StandardError.new("test sync error")) assert_equal "pending", @sync.status previously_ran_at = @sync.last_ran_at diff --git a/test/system/accounts_test.rb b/test/system/accounts_test.rb index de55e49fafc..9ce77814c6e 100644 --- a/test/system/accounts_test.rb +++ b/test/system/accounts_test.rb @@ -69,7 +69,7 @@ class AccountsTest < ApplicationSystemTestCase assert_account_created("OtherLiability") end - test "can sync all acounts on accounts page" do + test "can sync all accounts on accounts page" do visit accounts_url assert_button "Sync all" end From 5407c53329a642c862953fb35fa447cbd4570486 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 14 Nov 2024 19:12:48 -0500 Subject: [PATCH 15/23] Add liability and investment syncing --- app/controllers/accounts_controller.rb | 4 +- app/controllers/concerns/auto_sync.rb | 6 +- app/controllers/plaid_items_controller.rb | 2 +- app/models/account.rb | 2 + app/models/account/balance/loader.rb | 7 +- app/models/account/entry.rb | 2 +- app/models/account/valuation.rb | 2 +- app/models/concerns/syncable.rb | 8 +- app/models/family.rb | 2 + app/models/plaid_account.rb | 102 ++++++++++++++++-- app/models/plaid_item.rb | 84 +++++++++++++-- app/models/provider/plaid.rb | 74 ++++++++++++- app/models/sync.rb | 4 +- app/views/accounts/index.html.erb | 2 +- app/views/accounts/index/_account_groups.erb | 2 +- app/views/plaid_items/_plaid_item.html.erb | 14 +-- config/locales/views/plaid_items/en.yml | 3 +- db/migrate/20241106193743_add_plaid_domain.rb | 2 +- ...241114164118_add_products_to_plaid_item.rb | 10 ++ db/schema.rb | 9 +- test/fixtures/families.yml | 4 +- 21 files changed, 291 insertions(+), 54 deletions(-) create mode 100644 db/migrate/20241114164118_add_products_to_plaid_item.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 4870b3a2866..93a473880c6 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -4,8 +4,8 @@ class AccountsController < ApplicationController before_action :set_account, only: %i[sync] def index - @manual_accounts = Current.family.accounts.manual.active - @plaid_items = Current.family.plaid_items.active + @manual_accounts = Current.family.accounts.manual.active.alphabetically + @plaid_items = Current.family.plaid_items.active.ordered end def summary diff --git a/app/controllers/concerns/auto_sync.rb b/app/controllers/concerns/auto_sync.rb index 7452a807c1f..fa2790345d0 100644 --- a/app/controllers/concerns/auto_sync.rb +++ b/app/controllers/concerns/auto_sync.rb @@ -7,7 +7,7 @@ module AutoSync private def sync_family - Current.family.update!(last_auto_synced_at: Time.current) + Current.family.update!(last_synced_at: Time.current) Current.family.sync_later end @@ -15,7 +15,7 @@ def family_needs_auto_sync? return false unless Current.family.present? return false unless Current.family.accounts.any? - Current.family.last_auto_synced_at.blank? || - Current.family.last_auto_synced_at.to_date < Date.current + Current.family.last_synced_at.blank? || + Current.family.last_synced_at.to_date < Date.current end end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index 196f6bcd036..c0ac89ad744 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -4,7 +4,7 @@ class PlaidItemsController < ApplicationController def create Current.family.plaid_items.create_from_public_token( plaid_item_params[:public_token], - item_name + item_name: item_name, ) redirect_to accounts_path, notice: t(".success") diff --git a/app/models/account.rb b/app/models/account.rb index f81cf28f5cb..fcc9dc25e44 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -92,6 +92,8 @@ def destroy_later end def sync_data(start_date: nil) + update!(last_synced_at: Time.current) + resolve_stale_issues Balance::Syncer.new(self, start_date: start_date).run Holding::Syncer.new(self, start_date: start_date).run diff --git a/app/models/account/balance/loader.rb b/app/models/account/balance/loader.rb index cb6ba0bdc03..56c02011fd7 100644 --- a/app/models/account/balance/loader.rb +++ b/app/models/account/balance/loader.rb @@ -19,7 +19,12 @@ def load(balances, start_date) def update_account_balance!(balances) last_balance = balances.select { |db| db.currency == account.currency }.last&.balance - account.update! balance: last_balance if last_balance.present? + + if account.plaid_account.present? + account.update! balance: account.plaid_account.current_balance || last_balance + else + account.update! balance: last_balance if last_balance.present? + end end def upsert_balances!(balances) diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index b66927885d3..09d19618a6e 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -87,7 +87,7 @@ def entries_on_entry_date class << self # arbitrary cutoff date to avoid expensive sync operations def min_supported_date - 20.years.ago.to_date + 30.years.ago.to_date end def daily_totals(entries, currency, period: Period.last_30_days) diff --git a/app/models/account/valuation.rb b/app/models/account/valuation.rb index f5f5aa50cb9..653c11e2fe3 100644 --- a/app/models/account/valuation.rb +++ b/app/models/account/valuation.rb @@ -12,7 +12,7 @@ def requires_search?(_params) end def name - oldest? ? "Initial balance" : entry.name || "Balance update" + entry.name || (oldest? ? "Initial balance" : "Balance update") end def trend diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb index ca0c3577f68..ec6abb2e49c 100644 --- a/app/models/concerns/syncable.rb +++ b/app/models/concerns/syncable.rb @@ -9,10 +9,6 @@ def syncing? syncs.where(status: [ :syncing, :pending ]).any? end - def last_synced_at - syncs.ordered.first&.last_ran_at - end - def sync_later(start_date: nil) new_sync = syncs.create!(start_date: start_date) SyncJob.perform_later(new_sync) @@ -26,6 +22,10 @@ def sync_data(start_date: nil) raise NotImplementedError, "Subclasses must implement the `sync_data` method" end + def sync_error + latest_sync.error + end + private def latest_sync syncs.order(created_at: :desc).first diff --git a/app/models/family.rb b/app/models/family.rb index a04084392d8..732c96c9693 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -21,6 +21,8 @@ class Family < ApplicationRecord validates :date_format, inclusion: { in: DATE_FORMATS } def sync_data(start_date: nil) + update!(last_synced_at: Time.current) + accounts.manual.each do |account| account.sync_data(start_date: start_date) end diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 5b7087265d5..602f016f304 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -42,9 +42,85 @@ def sync_account_data!(plaid_account_data) ) end - def sync_transactions!(plaid_transactions_data) - plaid_transactions_data.added.each do |plaid_txn| - account.entries.account_transactions.find_or_create_by!(plaid_id: plaid_txn.transaction_id) do |t| + def sync_investments!(transactions:, holdings:, securities:) + transactions.each do |transaction| + if transaction.type == "cash" + new_transaction = account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| + t.name = transaction.name + t.amount = transaction.amount + t.currency = transaction.iso_currency_code + t.date = transaction.date + t.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal]) + t.entryable = Account::Transaction.new + end + else + plaid_security = nil + + if transaction.security.ticker_symbol.present? + plaid_security = transaction.security + else + plaid_security = securities.find { |s| s.security_id == transaction.security.proxy_security_id } + end + + security = Security.find_or_create_by!( + ticker: plaid_security.ticker_symbol, + exchange_mic: plaid_security.market_identifier_code || "XNAS", + country_code: "US" + ) + + new_transaction = account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| + t.name = transaction.name + t.amount = transaction.quantity * transaction.price, + t.currency = transaction.iso_currency_code + t.date = transaction.date + t.entryable = Account::Trade.new( + security: security, + qty: transaction.quantity, + price: transaction.price, + currency: transaction.iso_currency_code + ) + end + end + end + end + + def sync_credit_data!(plaid_credit_data) + account.update!( + accountable_attributes: { + id: account.accountable_id, + minimum_payment: plaid_credit_data.minimum_payment_amount, + apr: plaid_credit_data.aprs.first&.apr_percentage + } + ) + end + + def sync_mortgage_data!(plaid_mortgage_data) + create_initial_loan_balance(plaid_mortgage_data) + + account.update!( + accountable_attributes: { + id: account.accountable_id, + rate_type: plaid_mortgage_data.interest_rate&.type, + interest_rate: plaid_mortgage_data.interest_rate&.percentage + } + ) + end + + def sync_student_loan_data!(plaid_student_loan_data) + create_initial_loan_balance(plaid_student_loan_data) + + account.update!( + accountable_attributes: { + id: account.accountable_id, + rate_type: "fixed", + interest_rate: plaid_student_loan_data.interest_rate_percentage + } + ) + end + + def sync_transactions!(added:, modified:, removed:) + added.each do |plaid_txn| + account.entries.find_or_create_by!(plaid_id: plaid_txn.transaction_id) do |t| t.name = plaid_txn.name t.amount = plaid_txn.amount t.currency = plaid_txn.iso_currency_code @@ -57,8 +133,8 @@ def sync_transactions!(plaid_transactions_data) end end - plaid_transactions_data.modified.each do |plaid_txn| - existing_txn = account.entries.account_transactions.find_by(plaid_id: plaid_txn.transaction_id) + modified.each do |plaid_txn| + existing_txn = account.entries.find_by(plaid_id: plaid_txn.transaction_id) existing_txn.update!( amount: plaid_txn.amount, @@ -66,8 +142,8 @@ def sync_transactions!(plaid_transactions_data) ) end - plaid_transactions_data.removed.each do |plaid_txn| - account.entries.account_transactions.find_by(plaid_id: plaid_txn.transaction_id)&.destroy + removed.each do |plaid_txn| + account.entries.find_by(plaid_id: plaid_txn.transaction_id)&.destroy end end @@ -82,6 +158,18 @@ def transfer?(plaid_txn) transfer_categories.include?(plaid_txn.personal_finance_category.primary) end + def create_initial_loan_balance(loan_data) + if loan_data.origination_principal_amount.present? && loan_data.origination_date.present? + account.entries.find_or_create_by!(plaid_id: loan_data.account_id) do |e| + e.name = "Initial Principal" + e.amount = loan_data.origination_principal_amount + e.currency = account.currency + e.date = loan_data.origination_date + e.entryable = Account::Valuation.new + end + end + end + # See https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv def get_category(plaid_category) ignored_categories = [ "BANK_FEES", "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS", "OTHER" ] diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 3c850da0245..c2ca4cbc61a 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -13,15 +13,16 @@ class PlaidItem < ApplicationRecord has_many :accounts, through: :plaid_accounts scope :active, -> { where(scheduled_for_deletion: false) } + scope :ordered, -> { order(created_at: :desc) } class << self - def create_from_public_token(token, item_name) + def create_from_public_token(token, item_name:) response = plaid_provider.exchange_public_token(token) new_plaid_item = create!( name: item_name, plaid_id: response.item_id, - access_token: response.access_token + access_token: response.access_token, ) new_plaid_item.sync_later @@ -29,6 +30,8 @@ def create_from_public_token(token, item_name) end def sync_data(start_date: nil) + update!(last_synced_at: Time.current) + fetch_and_load_plaid_data accounts.each do |account| @@ -41,18 +44,81 @@ def destroy_later DestroyJob.perform_later(self) end + def has_investment_accounts? + available_products.include?("investments") || billed_products.include?("investments") + end + + def has_liability_accounts? + available_products.include?("liabilities") || billed_products.include?("liabilities") + end + private def fetch_and_load_plaid_data - accounts_data = plaid_provider.get_item_accounts(self).accounts - transactions_data = plaid_provider.get_item_transactions(self) + item = plaid_provider.get_item(access_token).item + update!(available_products: item.available_products, billed_products: item.billed_products) + + fetched_accounts = plaid_provider.get_item_accounts(self).accounts + + internal_plaid_accounts = fetched_accounts.map do |account| + internal_plaid_account = plaid_accounts.find_or_create_from_plaid_data!(account, family) + internal_plaid_account.sync_account_data!(account) + internal_plaid_account + end + + fetched_transactions = safe_fetch_plaid_data(:get_item_transactions) unless has_investment_accounts? + + if fetched_transactions + transaction do + internal_plaid_accounts.each do |internal_plaid_account| + added = fetched_transactions.added.select { |t| t.account_id == internal_plaid_account.plaid_id } + modified = fetched_transactions.modified.select { |t| t.account_id == internal_plaid_account.plaid_id } + removed = fetched_transactions.removed.select { |t| t.account_id == internal_plaid_account.plaid_id } + + internal_plaid_account.sync_transactions!(added:, modified:, removed:) + end - accounts_data.each do |account_data| - plaid_account = plaid_accounts.find_or_create_from_plaid_data!(account_data, family) - plaid_account.sync_account_data!(account_data) - plaid_account.sync_transactions!(transactions_data) + update!(next_cursor: fetched_transactions.cursor) + end end - update!(next_cursor: transactions_data.cursor) + fetched_investments = safe_fetch_plaid_data(:get_item_investments) if has_investment_accounts? + + if fetched_investments + transaction do + internal_plaid_accounts.each do |internal_plaid_account| + transactions = fetched_investments.transactions.select { |t| t.account_id == internal_plaid_account.plaid_id } + holdings = fetched_investments.holdings.select { |h| h.account_id == internal_plaid_account.plaid_id } + securities = fetched_investments.securities + + internal_plaid_account.sync_investments!(transactions:, holdings:, securities:) + end + end + end + + fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities) if has_liability_accounts? + + if fetched_liabilities + transaction do + internal_plaid_accounts.each do |internal_plaid_account| + credit = fetched_liabilities.credit.find { |l| l.account_id == internal_plaid_account.plaid_id } + mortgage = fetched_liabilities.mortgage.find { |l| l.account_id == internal_plaid_account.plaid_id } + student = fetched_liabilities.student.find { |l| l.account_id == internal_plaid_account.plaid_id } + + internal_plaid_account.sync_credit_data!(credit) if credit + internal_plaid_account.sync_mortgage_data!(mortgage) if mortgage + internal_plaid_account.sync_student_loan_data!(student) if student + end + end + end + end + + def safe_fetch_plaid_data(method) + begin + plaid_provider.send(method, self) + rescue Plaid::ApiError => e + Rails.logger.warn("Error fetching #{method} for item #{id}: #{e.message}") + nil + end end def remove_plaid_item diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index d144c732c34..e1227c53bca 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -4,6 +4,7 @@ class Provider::Plaid PLAID_COUNTRY_CODES = %w[US GB ES NL FR IE CA DE IT PL DK NO SE EE LT LV PT BE].freeze PLAID_LANGUAGES = %w[da nl en et fr de hi it lv lt no pl pt ro es sv vi].freeze PLAID_PRODUCTS = %w[transactions investments liabilities].freeze + MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730 class << self def process_webhook(webhook_body) @@ -15,10 +16,6 @@ def process_webhook(webhook_body) when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ] plaid_item = PlaidItem.find_by(plaid_id: parsed["item_id"]) - if parsed["historical_update_complete"] - plaid_item.update!(historical_update_complete: true) - end - plaid_item.sync_later else Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}") @@ -80,7 +77,7 @@ def get_link_token(user_id:, country:, language: "en", webhooks_url:, redirect_u language: get_plaid_language(language), webhook: webhooks_url, redirect_uri: redirect_url, - transactions: { days_requested: 730 } # max allowed by Plaid + transactions: { days_requested: MAX_HISTORY_DAYS } }) client.link_token_create(request) @@ -94,6 +91,11 @@ def exchange_public_token(token) client.item_public_token_exchange(request) end + def get_item(access_token) + request = Plaid::ItemGetRequest.new(access_token: access_token) + client.item_get(request) + end + def remove_item(access_token) request = Plaid::ItemRemoveRequest.new(access_token: access_token) client.item_remove(request) @@ -129,8 +131,70 @@ def get_item_transactions(item) TransactionSyncResponse.new(added:, modified:, removed:, cursor:) end + def get_item_investments(item, start_date: nil, end_date: Date.current) + start_date = start_date || MAX_HISTORY_DAYS.days.ago.to_date + holdings = get_item_holdings(item) + transactions, securities = get_item_investment_transactions(item, start_date:, end_date:) + + InvestmentsResponse.new(holdings:, transactions:, securities:) + end + + def get_item_liabilities(item) + request = Plaid::LiabilitiesGetRequest.new({ access_token: item.access_token }) + response = client.liabilities_get(request) + response.liabilities + end + private TransactionSyncResponse = Struct.new :added, :modified, :removed, :cursor, keyword_init: true + InvestmentsResponse = Struct.new :holdings, :transactions, :securities, keyword_init: true + + def get_item_holdings(item) + request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: item.access_token }) + response = client.investments_holdings_get(request) + + securities_by_id = response.securities.index_by(&:security_id) + accounts_by_id = response.accounts.index_by(&:account_id) + + response.holdings.each do |holding| + holding.define_singleton_method(:security) { securities_by_id[holding.security_id] } + holding.define_singleton_method(:account) { accounts_by_id[holding.account_id] } + end + + response.holdings + end + + def get_item_investment_transactions(item, start_date:, end_date:) + transactions = [] + securities = [] + offset = 0 + + loop do + request = Plaid::InvestmentsTransactionsGetRequest.new( + access_token: item.access_token, + start_date: start_date.to_s, + end_date: end_date.to_s, + options: { offset: offset } + ) + + response = client.investments_transactions_get(request) + securities_by_id = response.securities.index_by(&:security_id) + accounts_by_id = response.accounts.index_by(&:account_id) + + response.investment_transactions.each do |t| + t.define_singleton_method(:security) { securities_by_id[t.security_id] } + t.define_singleton_method(:account) { accounts_by_id[t.account_id] } + transactions << t + end + + securities += response.securities + + break if transactions.length >= response.total_investment_transactions + offset = transactions.length + end + + [ transactions, securities ] + end def get_products(accountable_type) case accountable_type diff --git a/app/models/sync.rb b/app/models/sync.rb index 2769fb28e49..c0a8b53c89d 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -8,9 +8,7 @@ class Sync < ApplicationRecord def perform start! - transaction do - syncable.sync_data(start_date: start_date) - end + syncable.sync_data(start_date: start_date) complete! rescue StandardError => error diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 7147faf3232..a350e607ca4 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -30,7 +30,7 @@ <% else %>
<% if @plaid_items.any? %> - <%= render @plaid_items %> + <%= render @plaid_items.sort_by(&:created_at) %> <% end %> <% if @manual_accounts.any? %> diff --git a/app/views/accounts/index/_account_groups.erb b/app/views/accounts/index/_account_groups.erb index bef3069e00c..0b26b61875b 100644 --- a/app/views/accounts/index/_account_groups.erb +++ b/app/views/accounts/index/_account_groups.erb @@ -1,6 +1,6 @@ <%# locals: (accounts:) %> -<% accounts.group_by(&:accountable_type).each do |group, accounts| %> +<% accounts.group_by(&:accountable_type).sort_by { |group, _| group }.each do |group, accounts| %>

<%= to_accountable_title(Accountable.from_type(group)) %>

diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb index 730115a28d7..01330318ea6 100644 --- a/app/views/plaid_items/_plaid_item.html.erb +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -18,11 +18,16 @@
<%= tag.p plaid_item.name, class: "font-medium text-gray-900" %> - <% if plaid_item.syncing? || plaid_item.historical_update_complete == false %> + <% if plaid_item.syncing? %>
<%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %> <%= tag.span t(".syncing") %>
+ <% elsif plaid_item.sync_error.present? %> +
+ <%= lucide_icon "alert-circle", class: "w-4 h-4 text-red-500" %> + <%= tag.span t(".error"), class: "text-red-500" %> +
<% else %>

<%= plaid_item.last_synced_at ? t(".status", timestamp: time_ago_in_words(plaid_item.last_synced_at)) : t(".status_never") %> @@ -58,12 +63,7 @@

- <% if plaid_item.historical_update_complete == false %> -
-

<%= t(".historical_update_message") %>

-

<%= t(".historical_update_description") %>

-
- <% elsif plaid_item.accounts.any? %> + <% if plaid_item.accounts.any? %> <%= render "accounts/index/account_groups", accounts: plaid_item.accounts %> <% else %>
diff --git a/config/locales/views/plaid_items/en.yml b/config/locales/views/plaid_items/en.yml index fc09d65cdf3..db7cbdedad4 100644 --- a/config/locales/views/plaid_items/en.yml +++ b/config/locales/views/plaid_items/en.yml @@ -17,5 +17,4 @@ en: status: Last synced %{timestamp} ago status_never: Requires data sync syncing: Syncing... - historical_update_message: Setting up linked account... - historical_update_description: We're loading your historical transaction data from Plaid. This may take several minutes. + error: Error occurred while syncing data diff --git a/db/migrate/20241106193743_add_plaid_domain.rb b/db/migrate/20241106193743_add_plaid_domain.rb index ffb6f92813b..582b8e9d9ff 100644 --- a/db/migrate/20241106193743_add_plaid_domain.rb +++ b/db/migrate/20241106193743_add_plaid_domain.rb @@ -7,7 +7,7 @@ def change t.string :name t.string :next_cursor t.boolean :scheduled_for_deletion, default: false - t.boolean :historical_update_complete, default: false + t.timestamps end diff --git a/db/migrate/20241114164118_add_products_to_plaid_item.rb b/db/migrate/20241114164118_add_products_to_plaid_item.rb new file mode 100644 index 00000000000..d19f53c325a --- /dev/null +++ b/db/migrate/20241114164118_add_products_to_plaid_item.rb @@ -0,0 +1,10 @@ +class AddProductsToPlaidItem < ActiveRecord::Migration[7.2] + def change + add_column :plaid_items, :available_products, :string, array: true, default: [] + add_column :plaid_items, :billed_products, :string, array: true, default: [] + + rename_column :families, :last_auto_synced_at, :last_synced_at + add_column :plaid_items, :last_synced_at, :datetime + add_column :accounts, :last_synced_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 3ff71c6de46..fa9dea5a05f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do +ActiveRecord::Schema[7.2].define(version: 2024_11_14_164118) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -111,6 +111,7 @@ t.uuid "import_id" t.uuid "plaid_account_id" t.boolean "scheduled_for_deletion", default: false + t.datetime "last_synced_at" t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type" @@ -216,7 +217,7 @@ t.string "stripe_subscription_status", default: "incomplete" t.string "date_format", default: "%m-%d-%Y" t.string "country", default: "US" - t.datetime "last_auto_synced_at" + t.datetime "last_synced_at" end create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -483,9 +484,11 @@ t.string "name" t.string "next_cursor" t.boolean "scheduled_for_deletion", default: false - t.boolean "historical_update_complete", default: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "available_products", default: [], array: true + t.string "billed_products", default: [], array: true + t.datetime "last_synced_at" t.index ["family_id"], name: "index_plaid_items_on_family_id" end diff --git a/test/fixtures/families.yml b/test/fixtures/families.yml index 26b56ae543b..375bb175a19 100644 --- a/test/fixtures/families.yml +++ b/test/fixtures/families.yml @@ -1,10 +1,10 @@ empty: name: Family stripe_subscription_status: active - last_auto_synced_at: <%= Time.now %> + last_synced_at: <%= Time.now %> dylan_family: name: The Dylan Family stripe_subscription_status: active - last_auto_synced_at: <%= Time.now %> + last_synced_at: <%= Time.now %> From a96e3d1d8eab06bcfa23c54bcf273ad6189527f5 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 15 Nov 2024 08:49:52 -0500 Subject: [PATCH 16/23] Handle investment webhooks and process current day holdings --- app/models/account/holding/syncer.rb | 3 +- app/models/plaid_account.rb | 49 ++++++++++++++++++++-------- app/models/provider/plaid.rb | 8 +++-- 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/app/models/account/holding/syncer.rb b/app/models/account/holding/syncer.rb index f3c7c1fe179..de5462fa856 100644 --- a/app/models/account/holding/syncer.rb +++ b/app/models/account/holding/syncer.rb @@ -1,7 +1,8 @@ class Account::Holding::Syncer def initialize(account, start_date: nil) @account = account - @sync_date_range = calculate_sync_start_date(start_date)..Date.current + end_date = account.plaid_account.present? ? 1.day.ago.to_date : Date.current + @sync_date_range = calculate_sync_start_date(start_date)..end_date @portfolio = {} load_prior_portfolio if start_date diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 602f016f304..59a543dae3a 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -54,20 +54,8 @@ def sync_investments!(transactions:, holdings:, securities:) t.entryable = Account::Transaction.new end else - plaid_security = nil - - if transaction.security.ticker_symbol.present? - plaid_security = transaction.security - else - plaid_security = securities.find { |s| s.security_id == transaction.security.proxy_security_id } - end - - security = Security.find_or_create_by!( - ticker: plaid_security.ticker_symbol, - exchange_mic: plaid_security.market_identifier_code || "XNAS", - country_code: "US" - ) - + security = get_security(transaction.security, securities) + next if security.nil? new_transaction = account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| t.name = transaction.name t.amount = transaction.quantity * transaction.price, @@ -82,6 +70,23 @@ def sync_investments!(transactions:, holdings:, securities:) end end end + + # Update only the current day holdings. The account sync will populate historical values based on trades. + holdings.each do |holding| + internal_security = get_security(holding.security, securities) + next if internal_security.nil? + + existing_holding = account.holdings.find_or_initialize_by( + security: internal_security, + date: Date.current, + currency: holding.iso_currency_code + ) + + existing_holding.qty = holding.quantity + existing_holding.price = holding.institution_price + existing_holding.amount = holding.quantity * holding.institution_price + existing_holding.save! + end end def sync_credit_data!(plaid_credit_data) @@ -152,6 +157,22 @@ def family plaid_item.family end + def get_security(plaid_security, securities) + security = nil + + if plaid_security.ticker_symbol.present? + security = plaid_security + else + security = securities.find { |s| s.security_id == plaid_security.proxy_security_id } + end + + Security.find_or_create_by!( + ticker: security.ticker_symbol, + exchange_mic: security.market_identifier_code || "XNAS", + country_code: "US" + ) if security.present? + end + def transfer?(plaid_txn) transfer_categories = [ "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS" ] diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index e1227c53bca..81b6d975a36 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -12,11 +12,13 @@ def process_webhook(webhook_body) type = parsed["webhook_type"] code = parsed["webhook_code"] + item = PlaidItem.find_by(plaid_id: parsed["item_id"]) + case [ type, code ] when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ] - plaid_item = PlaidItem.find_by(plaid_id: parsed["item_id"]) - - plaid_item.sync_later + item.sync_later + when [ "INVESTMENTS_TRANSACTIONS", "DEFAULT_UPDATE" ] + item.sync_later else Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}") end From 676e11ac08636e2820c55d09dffd0a4ad32e521d Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 15 Nov 2024 09:19:49 -0500 Subject: [PATCH 17/23] Remove logs --- app/javascript/controllers/plaid_controller.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js index b58e7c62ac3..a36f40af341 100644 --- a/app/javascript/controllers/plaid_controller.js +++ b/app/javascript/controllers/plaid_controller.js @@ -6,11 +6,6 @@ export default class extends Controller { linkToken: String, }; - connect() { - console.log("Plaid connect"); - console.log(this.linkTokenValue); - } - open() { const handler = Plaid.create({ token: this.linkTokenValue, From fcd6ca4fc15a2c5081a47f8debf6ad5690e0c51d Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 15 Nov 2024 09:21:17 -0500 Subject: [PATCH 18/23] Remove "all" period select for performance --- app/helpers/forms_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 49f303cb974..2099770c2b2 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -18,7 +18,7 @@ def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: end def period_select(form:, selected:, classes: "border border-alpha-black-100 shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-gray-900 focus:outline-none focus:ring-0") - periods_for_select = [ [ "7D", "last_7_days" ], [ "1M", "last_30_days" ], [ "1Y", "last_365_days" ], [ "All", "all" ] ] + periods_for_select = [ [ "7D", "last_7_days" ], [ "1M", "last_30_days" ], [ "1Y", "last_365_days" ] ] form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" }) end From 64d8fe48d28098c34c703e4efb93d54489cbb18a Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 15 Nov 2024 09:33:14 -0500 Subject: [PATCH 19/23] fix amount calc --- app/models/plaid_account.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 59a543dae3a..2c1d0a305f8 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -58,7 +58,7 @@ def sync_investments!(transactions:, holdings:, securities:) next if security.nil? new_transaction = account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| t.name = transaction.name - t.amount = transaction.quantity * transaction.price, + t.amount = transaction.quantity * transaction.price t.currency = transaction.iso_currency_code t.date = transaction.date t.entryable = Account::Trade.new( From 38717f269674fea7d72691ca36e25e7b79b49198 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 15 Nov 2024 09:56:31 -0500 Subject: [PATCH 20/23] Remove todo comment --- app/models/provider/plaid.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 81b6d975a36..ae46b7194bf 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -28,8 +28,6 @@ def validate_webhook!(verification_header, raw_body) jwks_loader = ->(options) do key_id = options[:kid] - # TODO: Cache this - # @see https://plaid.com/docs/api/webhooks/webhook-verification/#caching-and-key-rotation jwk_response = client.webhook_verification_key_get( Plaid::WebhookVerificationKeyGetRequest.new(key_id: key_id) ) From 119049040f34e27b561b37b99925cdb2f33d85d6 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 15 Nov 2024 12:25:21 -0500 Subject: [PATCH 21/23] Coming soon for investment historical data --- app/assets/images/placeholder-graph.svg | 10 ++++++ app/models/import/account_mapping.rb | 2 +- app/views/account/entries/index.html.erb | 32 ++++++++++--------- app/views/account/trades/_trade.html.erb | 20 +++--------- .../transactions/_transaction.html.erb | 6 +++- app/views/account/transfers/_form.html.erb | 4 +-- app/views/accounts/show/_header.html.erb | 6 ++-- app/views/accounts/show/_menu.html.erb | 30 +++++++++++------ app/views/investments/_chart.html.erb | 24 ++++++++++++++ app/views/investments/show.html.erb | 6 +++- app/views/transactions/_form.html.erb | 2 +- config/locales/views/account/entries/en.yml | 2 +- config/locales/views/account/trades/en.yml | 7 ---- config/locales/views/accounts/en.yml | 1 + config/locales/views/investments/en.yml | 2 ++ config/locales/views/plaid_items/en.yml | 2 +- config/locales/views/registrations/en.yml | 2 +- 17 files changed, 100 insertions(+), 58 deletions(-) create mode 100644 app/assets/images/placeholder-graph.svg diff --git a/app/assets/images/placeholder-graph.svg b/app/assets/images/placeholder-graph.svg new file mode 100644 index 00000000000..e868dbfff11 --- /dev/null +++ b/app/assets/images/placeholder-graph.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/models/import/account_mapping.rb b/app/models/import/account_mapping.rb index 67b11ba534d..c4c0041468e 100644 --- a/app/models/import/account_mapping.rb +++ b/app/models/import/account_mapping.rb @@ -8,7 +8,7 @@ def mapping_values(import) end def selectable_values - family_accounts = import.family.accounts.alphabetically.map { |account| [ account.name, account.id ] } + family_accounts = import.family.accounts.manual.alphabetically.map { |account| [ account.name, account.id ] } unless key.blank? family_accounts.unshift [ "Add as new account", CREATE_NEW_KEY ] diff --git a/app/views/account/entries/index.html.erb b/app/views/account/entries/index.html.erb index 19df9200352..e501a0cf468 100644 --- a/app/views/account/entries/index.html.erb +++ b/app/views/account/entries/index.html.erb @@ -2,23 +2,25 @@
<%= tag.h2 t(".title"), class: "font-medium text-lg" %> -
- - + <% end %>
diff --git a/app/views/account/trades/_trade.html.erb b/app/views/account/trades/_trade.html.erb index f7974079c3c..29cd40f461c 100644 --- a/app/views/account/trades/_trade.html.erb +++ b/app/views/account/trades/_trade.html.erb @@ -3,7 +3,7 @@ <% trade, account = entry.account_trade, entry.account %>
-
+
<% if selectable %> <%= check_box_tag dom_id(entry, "selection"), class: "maybe-checkbox maybe-checkbox--light", @@ -30,21 +30,11 @@
-
- <% if entry.account_transaction? && entry.marked_as_transfer? %> - <%= tag.p entry.inflow? ? t(".deposit") : t(".withdrawal") %> - <% elsif entry.account_transaction? %> - <%= tag.p entry.inflow? ? t(".inflow") : t(".outflow") %> - <% else %> - <%= tag.p trade.buy? ? t(".buy") : t(".sell") %> - <% end %> +
+ <%= tag.span format_money(entry.amount_money) %>
-
- <% if entry.account_transaction? %> - <%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": entry.inflow? } %> - <% else %> - <%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %> - <% end %> +
+ <%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index ae288d56dca..ebdda70d9c2 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -68,7 +68,11 @@ <% if show_balance %>
- <%= tag.p format_money(entry.trend.current), class: "font-medium text-sm text-gray-900" %> + <% if entry.account.investment? %> + <%= tag.p "--", class: "font-medium text-sm text-gray-400" %> + <% else %> + <%= tag.p format_money(entry.trend.current), class: "font-medium text-sm text-gray-900" %> + <% end %>
<% end %>
diff --git a/app/views/account/transfers/_form.html.erb b/app/views/account/transfers/_form.html.erb index 2d35cef07e3..0ce7566a535 100644 --- a/app/views/account/transfers/_form.html.erb +++ b/app/views/account/transfers/_form.html.erb @@ -26,8 +26,8 @@
- <%= f.collection_select :from_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %> - <%= f.collection_select :to_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %> + <%= f.collection_select :from_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %> + <%= f.collection_select :to_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %> <%= f.money_field :amount, label: t(".amount"), required: true, hide_currency: true %> <%= f.date_field :date, value: transfer.date, label: t(".date"), required: true, max: Date.current %>
diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index 4d58aa9a55a..1a2b39a5db3 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -20,8 +20,10 @@ <% end %>
- <%= button_to sync_account_path(account), disabled: account.syncing?, class: "flex items-center gap-2", title: "Sync Account" do %> - <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %> + <% unless account.plaid_account_id.present? %> + <%= button_to sync_account_path(account), disabled: account.syncing?, class: "flex items-center gap-2", title: "Sync Account" do %> + <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-500 hover:text-gray-400" %> + <% end %> <% end %> <%= render "accounts/show/menu", account: account %> diff --git a/app/views/accounts/show/_menu.html.erb b/app/views/accounts/show/_menu.html.erb index 4ff93e025af..1c601032b6a 100644 --- a/app/views/accounts/show/_menu.html.erb +++ b/app/views/accounts/show/_menu.html.erb @@ -2,23 +2,32 @@ <%= contextual_menu do %>
- <%= link_to edit_account_path(account), + <% if account.plaid_account_id.present? %> + <%= link_to accounts_path, + data: { turbo_frame: :_top }, + class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %> + <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %> + + <%= t(".manage") %> + <% end %> + <% else %> + <%= link_to edit_account_path(account), data: { turbo_frame: :modal }, class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %> - <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %> + <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %> - <%= t(".edit") %> - <% end %> + <%= t(".edit") %> + <% end %> - <%= link_to new_import_path, + <%= link_to new_import_path, data: { turbo_frame: :modal }, class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %> - <%= lucide_icon "download", class: "w-5 h-5 text-gray-500" %> + <%= lucide_icon "download", class: "w-5 h-5 text-gray-500" %> - <%= t(".import") %> - <% end %> + <%= t(".import") %> + <% end %> - <%= button_to account_path(account), + <%= button_to account_path(account), method: :delete, class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg", data: { @@ -29,7 +38,8 @@ accept: t(".confirm_accept", name: account.name) } } do %> - <%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account + <%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account + <% end %> <% end %>
<% end %> diff --git a/app/views/investments/_chart.html.erb b/app/views/investments/_chart.html.erb index bb96b3bc3e9..9f88954e7a1 100644 --- a/app/views/investments/_chart.html.erb +++ b/app/views/investments/_chart.html.erb @@ -1 +1,25 @@ <%# locals: (account:) %> + +<% period = Period.from_param(params[:period]) %> +<% series = account.series(period: period) %> +<% trend = series.trend %> + +
+
+
+
+ <%= tag.p t(".value"), class: "text-sm font-medium text-gray-500" %> +
+ + <%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %> +
+
+ +
+ <%= image_tag "placeholder-graph.svg", class: "w-full h-full object-cover rounded-bl-lg rounded-br-lg opacity-50" %> +
+

Historical investment data coming soon.

+

We're working to bring you the full picture.

+
+
+
diff --git a/app/views/investments/show.html.erb b/app/views/investments/show.html.erb index 4833badd520..36f1b934f65 100644 --- a/app/views/investments/show.html.erb +++ b/app/views/investments/show.html.erb @@ -4,7 +4,10 @@ <%= tag.div class: "space-y-4" do %> <%= render "accounts/show/header", account: @account %> - <%= render "accounts/show/chart", + <% if @account.plaid_account_id.present? %> + <%= render "investments/chart", account: @account %> + <% else %> + <%= render "accounts/show/chart", account: @account, title: t(".chart_title"), tooltip: render( @@ -12,6 +15,7 @@ value: @account.value, cash: @account.balance_money ) %> + <% end %>
<%= render "accounts/show/tabs", account: @account, tabs: [ diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb index e8cd3ea1a89..dad6157b259 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/transactions/_form.html.erb @@ -12,7 +12,7 @@
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %> - <%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %> + <%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %> <%= f.money_field :amount, label: t(".amount"), required: true %> <%= f.hidden_field :entryable_type, value: "Account::Transaction" %> <%= f.fields_for :entryable do |ef| %> diff --git a/config/locales/views/account/entries/en.yml b/config/locales/views/account/entries/en.yml index e117e90d601..b742a731a8e 100644 --- a/config/locales/views/account/entries/en.yml +++ b/config/locales/views/account/entries/en.yml @@ -16,7 +16,7 @@ en: new: New new_balance: New balance new_transaction: New transaction - no_entries: No entries found + no_entries: No entries found title: Activity loading: loading: Loading entries... diff --git a/config/locales/views/account/trades/en.yml b/config/locales/views/account/trades/en.yml index 0db9db32594..15b83280940 100644 --- a/config/locales/views/account/trades/en.yml +++ b/config/locales/views/account/trades/en.yml @@ -44,12 +44,5 @@ en: settings: Settings symbol_label: Symbol total_return_label: Unrealized gain/loss - trade: - buy: Buy - deposit: Deposit - inflow: Inflow - outflow: Outflow - sell: Sell - withdrawal: Withdrawal update: success: Trade updated successfully. diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 6320e0394bf..585cb37d7b0 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -46,6 +46,7 @@ en: confirm_title: Delete account? edit: Edit import: Import transactions + manage: Manage accounts summary: header: accounts: Accounts diff --git a/config/locales/views/investments/en.yml b/config/locales/views/investments/en.yml index 471d88de7f3..ec2cbe86d9d 100644 --- a/config/locales/views/investments/en.yml +++ b/config/locales/views/investments/en.yml @@ -1,6 +1,8 @@ --- en: investments: + chart: + value: Total value edit: edit: Edit %{account} form: diff --git a/config/locales/views/plaid_items/en.yml b/config/locales/views/plaid_items/en.yml index db7cbdedad4..d4af2bf8af8 100644 --- a/config/locales/views/plaid_items/en.yml +++ b/config/locales/views/plaid_items/en.yml @@ -11,10 +11,10 @@ en: all associated data. confirm_title: Delete institution? delete: Delete + error: Error occurred while syncing data no_accounts_description: We could not load any accounts from this financial institution. no_accounts_title: No accounts found status: Last synced %{timestamp} ago status_never: Requires data sync syncing: Syncing... - error: Error occurred while syncing data diff --git a/config/locales/views/registrations/en.yml b/config/locales/views/registrations/en.yml index cf4b77f26e2..d79c10ffaa9 100644 --- a/config/locales/views/registrations/en.yml +++ b/config/locales/views/registrations/en.yml @@ -9,9 +9,9 @@ en: create: Continue registrations: create: + failure: There was a problem signing up. invalid_invite_code: Invalid invite code, please try again. success: You have signed up successfully. - failure: There was a problem signing up. new: invitation_message: "%{inviter} has invited you to join as a %{role}" join_family_title: Join %{family} From 297ee900179b833b815a7fcbc0fbaf6997007149 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 15 Nov 2024 13:27:21 -0500 Subject: [PATCH 22/23] Document Plaid configuration --- .env.example | 10 +++++++++- app/controllers/concerns/accountable_resource.rb | 4 ++-- app/models/concerns/plaidable.rb | 2 +- app/models/family.rb | 2 ++ config/initializers/plaid.rb | 12 ++++++++---- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 1169e684f1d..ef90f4a374d 100644 --- a/.env.example +++ b/.env.example @@ -110,4 +110,12 @@ GITHUB_REPO_BRANCH=main # STRIPE_PUBLISHABLE_KEY= STRIPE_SECRET_KEY= -STRIPE_WEBHOOK_SECRET= \ No newline at end of file +STRIPE_WEBHOOK_SECRET= + +# ====================================================================================================== +# Plaid Configuration +# ====================================================================================================== +# +PLAID_CLIENT_ID= +PLAID_SECRET= +PLAID_ENV= \ No newline at end of file diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 5da2a5de788..8f6a3244abd 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -4,7 +4,7 @@ module AccountableResource included do layout :with_sidebar before_action :set_account, only: [ :show, :edit, :update, :destroy ] - before_action :set_link_token, only: :new, unless: -> { self_hosted? } + before_action :set_link_token, only: :new end class_methods do @@ -54,7 +54,7 @@ def set_link_token def webhooks_url return webhooks_plaid_url if Rails.env.production? - base_url = ENV.fetch("WEBHOOKS_URL", root_url.chomp("/")) + base_url = ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) base_url + "/webhooks/plaid" end diff --git a/app/models/concerns/plaidable.rb b/app/models/concerns/plaidable.rb index 4accfd54134..ddecd89325b 100644 --- a/app/models/concerns/plaidable.rb +++ b/app/models/concerns/plaidable.rb @@ -3,7 +3,7 @@ module Plaidable class_methods do def plaid_provider - Provider::Plaid.new unless Rails.application.config.app_mode.self_hosted? + Provider::Plaid.new if Rails.application.config.plaid end end diff --git a/app/models/family.rb b/app/models/family.rb index 732c96c9693..0e9226f85a0 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -37,6 +37,8 @@ def syncing? end def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil) + return nil unless plaid_provider + plaid_provider.get_link_token( user_id: id, country: country, diff --git a/config/initializers/plaid.rb b/config/initializers/plaid.rb index 1d9a3e0370a..b0631110fd9 100644 --- a/config/initializers/plaid.rb +++ b/config/initializers/plaid.rb @@ -1,6 +1,10 @@ Rails.application.configure do - config.plaid = Plaid::Configuration.new - config.plaid.server_index = Plaid::Configuration::Environment[ENV["PLAID_ENV"] || "sandbox"] - config.plaid.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_CLIENT_ID"] - config.plaid.api_key["PLAID-SECRET"] = ENV["PLAID_SECRET"] + config.plaid = nil + + if ENV["PLAID_CLIENT_ID"].present? && ENV["PLAID_SECRET"].present? + config.plaid = Plaid::Configuration.new + config.plaid.server_index = Plaid::Configuration::Environment[ENV["PLAID_ENV"] || "sandbox"] + config.plaid.api_key["PLAID-CLIENT-ID"] = ENV["PLAID_CLIENT_ID"] + config.plaid.api_key["PLAID-SECRET"] = ENV["PLAID_SECRET"] + end end From 7f2a085aca4c71be373e8fdb74486163329a5a6a Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Fri, 15 Nov 2024 13:42:59 -0500 Subject: [PATCH 23/23] Listen for holding updates --- app/models/provider/plaid.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index ae46b7194bf..50c32f4ed7a 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -19,6 +19,8 @@ def process_webhook(webhook_body) item.sync_later when [ "INVESTMENTS_TRANSACTIONS", "DEFAULT_UPDATE" ] item.sync_later + when [ "HOLDINGS", "DEFAULT_UPDATE" ] + item.sync_later else Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}") end