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" %>
-
-
-
- <%= link_to new_account_valuation_path(@account), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
- <%= lucide_icon("circle-dollar-sign", class: "text-gray-500 w-5 h-5") %>
- <%= tag.span t(".new_balance"), class: "text-sm" %>
- <% end %>
+ <% unless @account.plaid_account_id.present? %>
+
+
+
+ <%= link_to new_account_valuation_path(@account), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
+ <%= lucide_icon("circle-dollar-sign", class: "text-gray-500 w-5 h-5") %>
+ <%= tag.span t(".new_balance"), class: "text-sm" %>
+ <% end %>
- <%= link_to @account.investment? ? new_account_trade_path(@account) : new_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
- <%= lucide_icon("credit-card", class: "text-gray-500 w-5 h-5") %>
- <%= tag.span t(".new_transaction"), class: "text-sm" %>
- <% end %>
+ <%= link_to @account.investment? ? new_account_trade_path(@account) : new_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
+ <%= lucide_icon("credit-card", class: "text-gray-500 w-5 h-5") %>
+ <%= tag.span t(".new_transaction"), class: "text-sm" %>
+ <% end %>
+
-
+ <% end %>
diff --git a/app/views/account/holdings/index.html.erb b/app/views/account/holdings/index.html.erb
index 993e6b15014..af87088b767 100644
--- a/app/views/account/holdings/index.html.erb
+++ b/app/views/account/holdings/index.html.erb
@@ -23,11 +23,6 @@
<% if @holdings.any? %>
<%= render partial: "account/holdings/holding", collection: @holdings, spacer_template: "ruler" %>
- <% elsif @account.needs_sync? || true %>
-
-
<%= t(".needs_sync") %>
- <%= button_to "Sync holding prices", sync_account_path(@account), class: "bg-gray-900 text-white text-sm rounded-lg px-3 py-2" %>
-
<% else %>
<%= t(".no_holdings") %>
<% end %>
diff --git a/app/views/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