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/Gemfile b/Gemfile index 767c88d7ebf..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" @@ -50,6 +51,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 8b61eab085c..ac24d23465e 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) @@ -284,6 +286,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) @@ -495,12 +500,14 @@ DEPENDENCIES importmap-rails inline_svg intercom-rails + jwt letter_opener lucide-rails! mocha octokit pagy pg (~> 1.5) + plaid propshaft puma (>= 5.0) rails (~> 7.2.2) 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/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 cccef2d3573..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 - @institutions = Current.family.institutions - @accounts = Current.family.accounts.ungrouped.alphabetically + @manual_accounts = Current.family.accounts.manual.active.alphabetically + @plaid_items = Current.family.plaid_items.active.ordered end def summary @@ -27,11 +27,16 @@ def sync unless @account.syncing? @account.sync_later end + + redirect_to account_path(@account) end def sync_all - Current.family.accounts.active.sync - redirect_back_or_to accounts_path, notice: t(".success") + unless Current.family.syncing? + Current.family.sync_later + end + + redirect_to accounts_path end private diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 29ab519df3b..8f6a3244abd 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 end class_methods do @@ -16,8 +17,7 @@ def permitted_accountable_attributes(*attrs) def new @account = Current.family.accounts.build( currency: Current.family.currency, - accountable: accountable_type.new, - institution_id: params[:institution_id] + accountable: accountable_type.new ) end @@ -29,20 +29,35 @@ 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, + redirect_url: accounts_url, + accountable_type: accountable_type.name + ) + end + + def webhooks_url + return webhooks_plaid_url if Rails.env.production? + + base_url = ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + base_url + "/webhooks/plaid" + end + def accountable_type controller_name.classify.constantize end @@ -53,7 +68,7 @@ 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 diff --git a/app/controllers/concerns/auto_sync.rb b/app/controllers/concerns/auto_sync.rb index 122710a6f0f..fa2790345d0 100644 --- a/app/controllers/concerns/auto_sync.rb +++ b/app/controllers/concerns/auto_sync.rb @@ -2,12 +2,20 @@ module AutoSync extend ActiveSupport::Concern included do - before_action :sync_family, if: -> { Current.family.present? && Current.family.needs_sync? } + before_action :sync_family, if: :family_needs_auto_sync? end private - def sync_family - Current.family.sync + Current.family.update!(last_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_synced_at.blank? || + Current.family.last_synced_at.to_date < Date.current 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 new file mode 100644 index 00000000000..c0ac89ad744 --- /dev/null +++ b/app/controllers/plaid_items_controller.rb @@ -0,0 +1,38 @@ +class PlaidItemsController < ApplicationController + before_action :set_plaid_item, only: %i[destroy sync] + + def create + Current.family.plaid_items.create_from_public_token( + plaid_item_params[:public_token], + item_name: item_name, + ) + + redirect_to accounts_path, notice: t(".success") + end + + def destroy + @plaid_item.destroy_later + redirect_to accounts_path, notice: t(".success") + end + + def sync + unless @plaid_item.syncing? + @plaid_item.sync_later + end + + redirect_to accounts_path + 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: {}) + end + + def item_name + plaid_item_params.dig(:metadata, :institution, :name) + 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/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 23e431f7976..56ce7c98f89 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -1,7 +1,19 @@ 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, webhook_body) + Provider::Plaid.process_webhook(webhook_body) + + render json: { received: true }, status: :ok + rescue => error + render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request + end + def stripe webhook_body = request.body.read sig_header = request.env["HTTP_STRIPE_SIGNATURE"] 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 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/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js new file mode 100644 index 00000000000..a36f40af341 --- /dev/null +++ b/app/javascript/controllers/plaid_controller.js @@ -0,0 +1,52 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="plaid" +export default class extends Controller { + static values = { + linkToken: String, + }; + + 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.redirected) { + window.location.href = response.url; + } + }); + } + + handleExit(err, metadata) { + // no-op + } + + handleEvent(eventName, metadata) { + // no-op + } + + handleLoad() { + // no-op + } +} 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/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 new file mode 100644 index 00000000000..c6f062536a7 --- /dev/null +++ b/app/jobs/sync_job.rb @@ -0,0 +1,7 @@ +class SyncJob < ApplicationJob + queue_as :default + + def perform(sync) + sync.perform + end +end diff --git a/app/models/account.rb b/app/models/account.rb index bc4f7123331..fcc9dc25e44 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" @@ -14,18 +14,17 @@ 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 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) } - scope :ungrouped, -> { where(institution_id: nil) } + scope :manual, -> { where(plaid_account_id: nil) } has_one_attached :logo @@ -87,6 +86,19 @@ def create_and_sync(attributes) end end + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + 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 + end + def original_balance balance_amount = balances.chronological.first&.balance || balance Money.new(balance_amount, currency) 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/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/account/sync.rb b/app/models/account/sync.rb deleted file mode 100644 index 28ae251a9cd..00000000000 --- a/app/models/account/sync.rb +++ /dev/null @@ -1,82 +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 - 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/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/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/plaidable.rb b/app/models/concerns/plaidable.rb new file mode 100644 index 00000000000..ddecd89325b --- /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 if Rails.application.config.plaid + end + end + + private + def plaid_provider + self.class.plaid_provider + end +end diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb new file mode 100644 index 00000000000..ec6abb2e49c --- /dev/null +++ b/app/models/concerns/syncable.rb @@ -0,0 +1,33 @@ +module Syncable + extend ActiveSupport::Concern + + included do + has_many :syncs, as: :syncable, dependent: :destroy + end + + def syncing? + syncs.where(status: [ :syncing, :pending ]).any? + end + + def sync_later(start_date: nil) + new_sync = syncs.create!(start_date: start_date) + SyncJob.perform_later(new_sync) + end + + def sync(start_date: nil) + syncs.create!(start_date: start_date).perform + end + + 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 + end +end 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 8d0d063bfbb..0e9226f85a0 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,4 +1,6 @@ class Family < ApplicationRecord + 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" ] include Providable @@ -7,17 +9,46 @@ 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 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 } + 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 + + plaid_items.each do |plaid_item| + plaid_item.sync_data(start_date: start_date) + end + end + + def syncing? + super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&: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, + language: locale, + webhooks_url: webhooks_url, + redirect_url: redirect_url, + accountable_type: accountable_type + ).link_token + end + def snapshot(period = Period.all) query = accounts.active.joins(:balances) .where("account_balances.currency = ?", self.currency) @@ -116,20 +147,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 || account.last_sync_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/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/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 new file mode 100644 index 00000000000..2c1d0a305f8 --- /dev/null +++ b/app/models/plaid_account.rb @@ -0,0 +1,208 @@ +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 + + accepts_nested_attributes_for :account + + class << self + 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: 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 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 + 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 + 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 + + # 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) + 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 + 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 + + modified.each do |plaid_txn| + existing_txn = account.entries.find_by(plaid_id: plaid_txn.transaction_id) + + existing_txn.update!( + amount: plaid_txn.amount, + date: plaid_txn.date + ) + end + + removed.each do |plaid_txn| + account.entries.find_by(plaid_id: plaid_txn.transaction_id)&.destroy + end + end + + private + 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" ] + + 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" ] + + 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 new file mode 100644 index 00000000000..c2ca4cbc61a --- /dev/null +++ b/app/models/plaid_item.rb @@ -0,0 +1,127 @@ +class PlaidItem < ApplicationRecord + include Plaidable, Syncable + + encrypts :access_token, deterministic: true + validates :name, :access_token, presence: true + + before_destroy :remove_plaid_item + + belongs_to :family + has_one_attached :logo + + has_many :plaid_accounts, dependent: :destroy + 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:) + response = plaid_provider.exchange_public_token(token) + + 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 sync_data(start_date: nil) + update!(last_synced_at: Time.current) + + fetch_and_load_plaid_data + + accounts.each do |account| + account.sync_data(start_date: start_date) + end + end + + def destroy_later + update!(scheduled_for_deletion: true) + 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 + 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 + + update!(next_cursor: fetched_transactions.cursor) + end + end + + 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 + 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..50c32f4ed7a --- /dev/null +++ b/app/models/provider/plaid.rb @@ -0,0 +1,220 @@ +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 + MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730 + + class << self + def process_webhook(webhook_body) + parsed = JSON.parse(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" ] + 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 + end + + def validate_webhook!(verification_header, raw_body) + jwks_loader = ->(options) do + key_id = options[:kid] + + jwk_response = client.webhook_verification_key_get( + Plaid::WebhookVerificationKeyGetRequest.new(key_id: key_id) + ) + + jwks = JWT::JWK::Set.new([ jwk_response.key.to_hash ]) + + jwks.filter! { |key| key[:use] == "sig" } + jwks + end + + 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(raw_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 = self.class.client + end + + 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 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: MAX_HISTORY_DAYS } + }) + + 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 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) + end + + 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.next_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 + + 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 + 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/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/app/models/sync.rb b/app/models/sync.rb new file mode 100644 index 00000000000..c0a8b53c89d --- /dev/null +++ b/app/models/sync.rb @@ -0,0 +1,39 @@ +class Sync < ApplicationRecord + belongs_to :syncable, polymorphic: true + + enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" } + + scope :ordered, -> { order(created_at: :desc) } + + def perform + start! + + syncable.sync_data(start_date: start_date) + + complete! + rescue StandardError => error + fail! error + raise error if Rails.env.development? + end + + private + def family + syncable.is_a?(Family) ? syncable : syncable.family + end + + def start! + update! status: :syncing + end + + def complete! + update! status: :completed, last_ran_at: Time.current + + family.broadcast_refresh + end + + def fail!(error) + update! status: :failed, error: error.message, last_ran_at: Time.current + + family.broadcast_refresh + end +end 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/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/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/_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/_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 431fe4cdc8f..a350e607ca4 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -7,19 +7,14 @@

<%= 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 %> -
+ <%= 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 %> - <%= render "sync_all_button" %> - <%= link_to new_account_path(return_to: accounts_path), data: { turbo_frame: "modal" }, class: "btn btn--primary flex items-center gap-1" do %> @@ -30,16 +25,16 @@
- <% if @accounts.empty? && @institutions.empty? %> + <% if @manual_accounts.empty? && @plaid_items.empty? %> <%= render "empty" %> <% else %>
- <% @institutions.each do |institution| %> - <%= render "accounts/index/institution_accounts", institution: %> + <% if @plaid_items.any? %> + <%= render @plaid_items.sort_by(&:created_at) %> <% end %> - <% if @accounts.any? %> - <%= render "accounts/index/institutionless_accounts", accounts: @accounts %> + <% if @manual_accounts.any? %> + <%= render "accounts/index/manual_accounts", accounts: @manual_accounts %> <% end %>
<% end %> diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/index/_account_groups.erb similarity index 85% rename from app/views/accounts/_accountable_group.html.erb rename to app/views/accounts/index/_account_groups.erb index bef3069e00c..0b26b61875b 100644 --- a/app/views/accounts/_accountable_group.html.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/accounts/index/_institution_accounts.html.erb b/app/views/accounts/index/_institution_accounts.html.erb deleted file mode 100644 index a1127077ae0..00000000000 --- a/app/views/accounts/index/_institution_accounts.html.erb +++ /dev/null @@ -1,91 +0,0 @@ -<%# locals: (institution:) %> - -
- -
- <%= 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" %> - <% else %> -
- <%= tag.p institution.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? %> -
- <%= lucide_icon "alert-octagon", class: "shrink-0 w-4 h-4" %> - <%= tag.span t(".has_issues") %> -
- <% elsif institution.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") %>

- <% 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 %> - <%= 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), - 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: { - turbo_confirm: { - title: t(".confirm_title"), - body: t(".confirm_body"), - accept: t(".confirm_accept") - } - } do %> - <%= lucide_icon "trash-2", class: "w-5 h-5" %> - - <%= t(".delete") %> - <% end %> -
- <% end %> -
-
- -
- <% if institution.accounts.any? %> - <%= render "accountable_group", accounts: institution.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/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/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/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index de5955b0f1c..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), 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" %> + <% 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/credit_cards/new.html.erb b/app/views/credit_cards/new.html.erb index 0e13c2a5831..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(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]), 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 38cb112bb60..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(institution_id: params[:institution_id], 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/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/_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/new.html.erb b/app/views/investments/new.html.erb index 5e83350d610..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(institution_id: params[:institution_id], 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/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/layouts/application.html.erb b/app/views/layouts/application.html.erb index 1c4d3b4e7e2..305987c3ecb 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 %> @@ -30,9 +31,13 @@ <%= 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 %> + + <% 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 407e648de14..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(institution_id: params[:institution_id], 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 new file mode 100644 index 00000000000..01330318ea6 --- /dev/null +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -0,0 +1,76 @@ +<%# locals: (plaid_item:) %> + +<%= 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 %> +
+ +
+ <%= 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") %> +
+ <% 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") %> +

+ <% 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), + 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: { + turbo_confirm: { + title: t(".confirm_title"), + body: t(".confirm_body"), + accept: t(".confirm_accept") + } + } do %> + <%= lucide_icon "trash-2", class: "w-5 h-5" %> + + <%= t(".delete") %> + <% end %> +
+ <% end %> +
+
+ +
+ <% if plaid_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: plaid_item.accounts %> + <% 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/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/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/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 c9918cbc5d7..37637b907f5 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -63,5 +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.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 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/initializers/plaid.rb b/config/initializers/plaid.rb new file mode 100644 index 00000000000..b0631110fd9 --- /dev/null +++ b/config/initializers/plaid.rb @@ -0,0 +1,10 @@ +Rails.application.configure do + 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 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/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/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/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 b3dcb1af15a..585cb37d7b0 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -6,40 +6,28 @@ 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 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 - 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 - 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: + manual_accounts: other_accounts: Other accounts new_account: New account + sync: Sync all 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? @@ -58,6 +46,7 @@ en: confirm_title: Delete account? edit: Edit import: Import transactions + manage: Manage accounts summary: header: accounts: Accounts @@ -70,7 +59,5 @@ en: no_liabilities: No liabilities found no_liabilities_description: Add a liability either via connection, importing or entering manually. - sync_all: - success: Successfully queued accounts for syncing. - sync_all_button: - sync: Sync all + 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/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/investments/en.yml b/config/locales/views/investments/en.yml index ed83fb05aa8..ec2cbe86d9d 100644 --- a/config/locales/views/investments/en.yml +++ b/config/locales/views/investments/en.yml @@ -1,10 +1,8 @@ --- en: investments: - create: - success: Investment account created - destroy: - success: Investment account deleted + chart: + value: Total value edit: edit: Edit %{account} form: @@ -14,8 +12,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 new file mode 100644 index 00000000000..d4af2bf8af8 --- /dev/null +++ b/config/locales/views/plaid_items/en.yml @@ -0,0 +1,20 @@ +--- +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_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... 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/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} 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/config/routes.rb b/config/routes.rb index 4f72e198128..1462b71a7eb 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 @@ -150,8 +147,14 @@ end end - # Stripe webhook endpoint - post "webhooks/stripe", to: "webhooks#stripe" + resources :plaid_items, only: %i[create destroy] do + post :sync, on: :member + end + + namespace :webhooks do + post "plaid" + post "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/20241106193743_add_plaid_domain.rb b/db/migrate/20241106193743_add_plaid_domain.rb new file mode 100644 index 00000000000..582b8e9d9ff --- /dev/null +++ b/db/migrate/20241106193743_add_plaid_domain.rb @@ -0,0 +1,56 @@ +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 :access_token + t.string :plaid_id + t.string :name + t.string :next_cursor + t.boolean :scheduled_for_deletion, default: false + + 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 + + create_table :syncs, id: :uuid do |t| + t.references :syncable, polymorphic: true, null: false, 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 + 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 + end + + drop_table :institutions do |t| + t.timestamps + end + end +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 4329c8c7477..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" @@ -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" @@ -66,17 +67,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 @@ -117,17 +107,18 @@ t.decimal "balance", precision: 19, scale: 4 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.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" 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| @@ -220,13 +211,13 @@ 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" t.string "stripe_subscription_status", default: "incomplete" t.string "date_format", default: "%m-%d-%Y" t.string "country", default: "US" + t.datetime "last_synced_at" end create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -402,16 +393,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 @@ -481,6 +462,36 @@ 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.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_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.string "next_cursor" + t.boolean "scheduled_for_deletion", 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 + 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 @@ -553,6 +564,19 @@ 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.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 ["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" @@ -605,13 +629,12 @@ 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" 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" @@ -620,10 +643,11 @@ 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", "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/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/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index 18ff5c7d8c5..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,12 +26,11 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest test "can sync an account" do post sync_account_path(@account) - assert_response :no_content + assert_redirected_to account_path(@account) 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_redirected_to accounts_path end 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/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/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/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb new file mode 100644 index 00000000000..7f9a3afe681 --- /dev/null +++ b/test/controllers/plaid_items_controller_test.rb @@ -0,0 +1,49 @@ +require "test_helper" +require "ostruct" + +class PlaidItemsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + + @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", item_id: "item-sandbox-1234") + ) + + assert_difference "PlaidItem.count", 1 do + post plaid_items_url, params: { + 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 + delete plaid_item_url(plaid_items(:one)) + + assert_equal "Accounts scheduled for deletion.", flash[:notice] + assert_enqueued_with job: DestroyJob + 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_redirected_to accounts_path + 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/accounts.yml b/test/fixtures/accounts.yml index 5c33a1efccd..1bb48c6e857 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -21,7 +21,15 @@ depository: currency: USD accountable_type: Depository accountable: one - institution: chase + +connected: + family: dylan_family + name: Connected Account + balance: 5000 + currency: USD + accountable_type: Depository + accountable: two + plaid_account: one credit_card: family: dylan_family @@ -30,7 +38,6 @@ credit_card: currency: USD accountable_type: CreditCard accountable: one - institution: chase investment: family: dylan_family 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/families.yml b/test/fixtures/families.yml index 57697046c67..375bb175a19 100644 --- a/test/fixtures/families.yml +++ b/test/fixtures/families.yml @@ -1,8 +1,10 @@ empty: name: Family stripe_subscription_status: active + last_synced_at: <%= Time.now %> dylan_family: name: The Dylan Family stripe_subscription_status: active + last_synced_at: <%= Time.now %> 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/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..21a0b460f53 --- /dev/null +++ b/test/fixtures/plaid_items.yml @@ -0,0 +1,5 @@ +one: + family: dylan_family + plaid_id: "1234567890" + access_token: encrypted_token_1 + 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..1b01056894d --- /dev/null +++ b/test/fixtures/syncs.yml @@ -0,0 +1,17 @@ +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 ce0a6871a7b..c1fe92089b5 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 @@ -21,14 +25,14 @@ 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 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" @@ -37,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 @@ -45,7 +49,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", @@ -68,7 +71,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 @@ -84,7 +87,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..6613dbcc26a --- /dev/null +++ b/test/interfaces/syncable_interface_test.rb @@ -0,0 +1,24 @@ +require "test_helper" + +module SyncableInterfaceTest + extend ActiveSupport::Testing::Declarative + include ActiveJob::TestHelper + + test "can sync later" do + assert_difference "@syncable.syncs.count", 1 do + assert_enqueued_with job: SyncJob do + @syncable.sync_later + end + 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 "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/sync_job_test.rb b/test/jobs/sync_job_test.rb new file mode 100644 index 00000000000..b8d34400619 --- /dev/null +++ b/test/jobs/sync_job_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class SyncJobTest < ActiveJob::TestCase + test "sync is performed" do + syncable = accounts(:depository) + + 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/account/sync_test.rb b/test/models/account/sync_test.rb deleted file mode 100644 index 75b6eaec7bc..00000000000 --- a/test/models/account/sync_test.rb +++ /dev/null @@ -1,48 +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"] - assert_equal "append", streams.third["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 3da0186073b..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] @@ -47,7 +26,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/models/family_test.rb b/test/models/family_test.rb index d56888ecac4..74376a7e1c8 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_data) + .with(start_date: nil) + .times(manual_accounts_count) + + PlaidItem.any_instance.expects(:sync_data) + .with(start_date: nil) + .times(items_count) + + @syncable.sync_data(start_date: family_sync.start_date) end test "calculates assets" do @@ -48,30 +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(: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 - - @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 new file mode 100644 index 00000000000..d689e855047 --- /dev/null +++ b/test/models/plaid_item_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class PlaidItemTest < ActiveSupport::TestCase + include SyncableInterfaceTest + + 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 new file mode 100644 index 00000000000..5fdf58983ed --- /dev/null +++ b/test/models/sync_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +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(start_date: @sync.start_date).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(start_date: @sync.start_date).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 diff --git a/test/system/accounts_test.rb b/test/system/accounts_test.rb index 87907323560..9ce77814c6e 100644 --- a/test/system/accounts_test.rb +++ b/test/system/accounts_test.rb @@ -4,6 +4,8 @@ class AccountsTest < ApplicationSystemTestCase setup do sign_in @user = users(:family_admin) + Family.any_instance.stubs(:get_link_token).returns("test-link-token") + visit root_url open_new_account_modal end @@ -67,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