1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 20:59:39 +02:00

Basic Plaid Integration (#1433)
Some checks are pending
Publish Docker image / ci (push) Waiting to run
Publish Docker image / Build docker image (push) Blocked by required conditions

* Basic plaid data model and linking

* Remove institutions, add plaid items

* Improve schema and Plaid provider

* Add webhook verification sketch

* Webhook verification

* Item accounts and balances sync setup

* Provide test encryption keys

* Fix test

* Only provide encryption keys in prod

* Try defining keys in test env

* Consolidate account sync logic

* Add back plaid account initialization

* Plaid transaction sync

* Sync UI overhaul for Plaid

* Add liability and investment syncing

* Handle investment webhooks and process current day holdings

* Remove logs

* Remove "all" period select for performance

* fix amount calc

* Remove todo comment

* Coming soon for investment historical data

* Document Plaid configuration

* Listen for holding updates
This commit is contained in:
Zach Gollwitzer 2024-11-15 13:49:37 -05:00 committed by GitHub
parent 3bc9da4105
commit cbba2ba675
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
127 changed files with 1537 additions and 841 deletions

View file

@ -110,4 +110,12 @@ GITHUB_REPO_BRANCH=main
#
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_WEBHOOK_SECRET=
# ======================================================================================================
# Plaid Configuration
# ======================================================================================================
#
PLAID_CLIENT_ID=
PLAID_SECRET=
PLAID_ENV=

View file

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

View file

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

View file

@ -0,0 +1,10 @@
<svg width="944" height="201" viewBox="0 0 944 201" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 56.5502L14.4845 52.101L28.9689 50.1276L43.4534 51.7926L57.9379 40.2042L72.4224 35.6995L86.9068 35.0612L101.391 51.2218L115.876 73.6398L130.36 65.7562L144.845 64.7572L159.329 78.5795L173.814 81.9833L188.298 71.3186L202.783 80.5112L217.267 86L231.752 84.5697L246.236 83.0772L260.721 78.4002L275.205 77.343L289.689 71.8152L304.174 52.25L318.658 51.5349L333.143 48.185L347.627 47.2522L362.112 45.4586L376.596 49.2356L391.081 47.5566L405.565 31.0549L420.05 28.5641L434.534 36.6352H449.019L463.503 42.7572L477.988 37.7564L492.472 42.3467L506.957 49.3852L521.441 59.4839L535.925 52.7514L550.41 47.1535L564.894 58.6703L579.379 49.8343L593.863 50.5123H608.348L622.832 54.192L637.317 58.4763L651.801 57.2522L666.286 59.3943L677.01 62.8533L688.553 59.3943L709.129 67.4827L724.224 60.8386L738.708 52.27L753.193 58.6965L767.677 37.887L782.162 28.3178L796.646 16.383L811.13 20.9733L825.615 10.2626L840.099 11.7927L854.584 6.59032L869.068 15.771L883.553 8.12043L898.037 6.59032L912.522 2L927.006 14.8529L944 15.771" stroke="#0B0B0B" stroke-opacity="0.25" stroke-width="2" stroke-miterlimit="16"/>
<path d="M14.4845 52.5538L0 57.0432V201H944V15.8954L927.006 14.9691L912.522 2L898.037 6.63181L883.553 8.17575L869.068 15.8954L854.584 6.63181L840.099 11.8812L825.615 10.3373L811.13 21.1448L796.646 16.513L782.161 28.5557L767.677 38.2114L753.193 59.2089L738.708 52.7244L724.224 61.3704L709.129 68.0745L688.553 59.9131L677.01 63.4034L666.286 59.9131L651.801 57.7516L637.317 58.9868L622.832 54.6637L608.348 50.9508H593.863L579.379 50.2667L564.894 59.1826L550.41 47.5616L535.925 53.2102L521.441 60.0035L506.957 49.8135L492.472 42.7114L477.988 38.0796L463.503 43.1256L449.019 36.9483H434.534L420.05 28.8042L405.565 31.3175L391.081 47.9684L376.596 49.6626L362.112 45.8514L347.627 47.6612L333.143 48.6024L318.658 51.9826L304.174 52.7042L289.689 72.4463L275.205 78.024L260.721 79.0908L246.236 83.8101L231.752 85.3161L217.267 86.7593L202.783 81.2209L188.298 71.9451L173.814 82.7063L159.329 79.2716L144.845 65.3245L130.36 66.3325L115.876 74.2874L101.391 51.6667L86.9068 35.3601L72.4224 36.0041L57.9379 40.5496L43.4534 52.2427L28.9689 50.5627L14.4845 52.5538Z" fill="url(#paint0_linear_4023_1299)" fill-opacity="0.5"/>
<defs>
<linearGradient id="paint0_linear_4023_1299" x1="445.5" y1="174.496" x2="445.5" y2="51.9672" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#E5E5E5" stop-opacity="0.6"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

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

View file

@ -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 @@ class AccountsController < ApplicationController
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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,8 +11,7 @@ class PropertiesController < ApplicationController
currency: Current.family.currency,
accountable: Property.new(
address: Address.new
),
institution_id: params[:institution_id]
)
)
end

View file

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

View file

@ -18,7 +18,7 @@ module FormsHelper
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

View file

@ -1,5 +0,0 @@
module InstitutionsHelper
def institution_logo(institution)
institution.logo.attached? ? institution.logo : institution.logo_url
end
end

View file

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

View file

@ -1,7 +0,0 @@
class AccountSyncJob < ApplicationJob
queue_as :default
def perform(account, start_date: nil)
account.sync(start_date: start_date)
end
end

7
app/jobs/destroy_job.rb Normal file
View file

@ -0,0 +1,7 @@
class DestroyJob < ApplicationJob
queue_as :default
def perform(model)
model.destroy
end
end

7
app/jobs/sync_job.rb Normal file
View file

@ -0,0 +1,7 @@
class SyncJob < ApplicationJob
queue_as :default
def perform(sync)
sync.perform
end
end

View file

@ -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 @@ class Account < ApplicationRecord
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)

View file

@ -19,7 +19,12 @@ class Account::Balance::Loader
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)

View file

@ -87,7 +87,7 @@ class Account::Entry < ApplicationRecord
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)

View file

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@ class Account::Valuation < ApplicationRecord
end
def name
oldest? ? "Initial balance" : entry.name || "Balance update"
entry.name || (oldest? ? "Initial balance" : "Balance update")
end
def trend

View file

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

View file

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

View file

@ -107,8 +107,7 @@ class Demo::Generator
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 @@ class Demo::Generator
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 @@ class Demo::Generator
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 @@ class Demo::Generator
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")

View file

@ -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 @@ class Family < ApplicationRecord
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

View file

@ -8,7 +8,7 @@ class Import::AccountMapping < Import::Mapping
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 ]

View file

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

208
app/models/plaid_account.rb Normal file
View file

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

127
app/models/plaid_item.rb Normal file
View file

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

View file

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

View file

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

39
app/models/sync.rb Normal file
View file

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

View file

@ -2,23 +2,25 @@
<div class="bg-white p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between mb-4">
<%= tag.h2 t(".title"), class: "font-medium text-lg" %>
<div data-controller="menu" data-testid="activity-menu">
<button class="btn btn--secondary flex items-center gap-2" data-menu-target="button">
<%= lucide_icon("plus", class: "w-4 h-4") %>
<%= tag.span t(".new") %>
</button>
<div data-menu-target="content" class="z-10 hidden bg-white rounded-lg border border-alpha-black-25 shadow-xs p-1">
<%= 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? %>
<div data-controller="menu" data-testid="activity-menu">
<button class="btn btn--secondary flex items-center gap-2" data-menu-target="button">
<%= lucide_icon("plus", class: "w-4 h-4") %>
<%= tag.span t(".new") %>
</button>
<div data-menu-target="content" class="z-10 hidden bg-white rounded-lg border border-alpha-black-25 shadow-xs p-1">
<%= 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 %>
</div>
</div>
</div>
<% end %>
</div>
<div>

View file

@ -23,11 +23,6 @@
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<% if @holdings.any? %>
<%= render partial: "account/holdings/holding", collection: @holdings, spacer_template: "ruler" %>
<% elsif @account.needs_sync? || true %>
<div class="flex flex-col justify-center items-center pt-4 pb-8">
<p class="text-gray-500 p-4"><%= t(".needs_sync") %></p>
<%= button_to "Sync holding prices", sync_account_path(@account), class: "bg-gray-900 text-white text-sm rounded-lg px-3 py-2" %>
</div>
<% else %>
<p class="text-gray-500 text-sm p-4"><%= t(".no_holdings") %></p>
<% end %>

View file

@ -3,7 +3,7 @@
<% trade, account = entry.account_trade, entry.account %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="pr-10 flex items-center gap-4 col-span-6">
<div class="col-span-8 flex items-center gap-4">
<% if selectable %>
<%= check_box_tag dom_id(entry, "selection"),
class: "maybe-checkbox maybe-checkbox--light",
@ -30,21 +30,11 @@
</div>
</div>
<div class="flex items-center justify-end gap-1 col-span-3">
<% 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 %>
<div class="col-span-2 justify-self-end font-medium text-sm">
<%= tag.span format_money(entry.amount_money) %>
</div>
<div class="col-span-3 flex items-center justify-end">
<% 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 %>
<div class="col-span-2 justify-self-end">
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
</div>
</div>

View file

@ -68,7 +68,11 @@
<% if show_balance %>
<div class="col-span-2 justify-self-end">
<%= 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 %>
</div>
<% end %>
</div>

View file

@ -26,8 +26,8 @@
</section>
<section class="space-y-2">
<%= 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 %>
</section>

View file

@ -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 %>
<span style="background-color: color-mix(in srgb, <%= accountable.color %> 10%, white);" class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-alpha-black-25">
<%= lucide_icon(accountable.icon, style: "color: #{accountable.color}", class: "w-5 h-5") %>

View file

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

View file

@ -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" %>
<span><%= t(".sync") %></span>
<% end %>

View file

@ -7,19 +7,14 @@
<h1 class="text-xl"><%= t(".accounts") %></h1>
<div class="flex items-center gap-5">
<div class="flex items-center gap-2">
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= 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" %>
<span class="text-black"><%= t(".add_institution") %></span>
<% end %>
</div>
<%= 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" %>
<span><%= t(".sync") %></span>
<% 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 @@
</div>
</header>
<% if @accounts.empty? && @institutions.empty? %>
<% if @manual_accounts.empty? && @plaid_items.empty? %>
<%= render "empty" %>
<% else %>
<div class="space-y-2">
<% @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 %>
</div>
<% end %>

View file

@ -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| %>
<div class="bg-gray-25 p-1 rounded-xl">
<div class="flex items-center px-4 py-2 text-xs font-medium text-gray-500">
<p><%= to_accountable_title(Accountable.from_type(group)) %></p>

View file

@ -1,91 +0,0 @@
<%# locals: (institution:) %>
<details open class="group bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
<summary class="flex items-center justify-between gap-2 focus-visible:outline-none">
<div class="flex items-center gap-2">
<%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-gray-500 w-5" %>
<div class="flex items-center justify-center h-8 w-8 bg-blue-600/10 rounded-full bg-black/5">
<% if institution_logo(institution) %>
<%= image_tag institution_logo(institution), class: "rounded-full h-full w-full" %>
<% else %>
<div class="flex items-center justify-center">
<%= tag.p institution.name.first.upcase, class: "text-blue-600 text-xs font-medium" %>
</div>
<% end %>
</div>
<div class="pl-1 text-sm">
<%= link_to institution.name, edit_institution_path(institution), data: { turbo_frame: :modal }, class: "font-medium text-gray-900 hover:underline" %>
<% if institution.has_issues? %>
<div class="flex items-center gap-1 text-error">
<%= lucide_icon "alert-octagon", class: "shrink-0 w-4 h-4" %>
<%= tag.span t(".has_issues") %>
</div>
<% elsif institution.syncing? %>
<div class="text-gray-500 flex items-center gap-1">
<%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %>
<%= tag.span t(".syncing") %>
</div>
<% else %>
<p class="text-gray-500"><%= institution.last_synced_at ? t(".status", last_synced_at: time_ago_in_words(institution.last_synced_at)) : t(".status_never") %></p>
<% end %>
</div>
</div>
<div class="flex items-center gap-2">
<%= 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 %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= 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" %>
<span><%= t(".add_account_to_institution") %></span>
<% 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" %>
<span><%= t(".edit") %></span>
<% 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" %>
<span><%= t(".delete") %></span>
<% end %>
</div>
<% end %>
</div>
</summary>
<div class="space-y-4 mt-4">
<% if institution.accounts.any? %>
<%= render "accountable_group", accounts: institution.accounts %>
<% else %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-gray-500 text-sm">There are no accounts in this financial institution</p>
<%= 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") %>
<span><%= t(".new_account") %></span>
<% end %>
</div>
<% end %>
</div>
</details>

View file

@ -12,6 +12,6 @@
</summary>
<div class="space-y-4 mt-4">
<%= render "accountable_group", accounts: accounts %>
<%= render "accounts/index/account_groups", accounts: accounts %>
</div>
</details>

View file

@ -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 %>
<div class="text-sm">
@ -9,11 +9,13 @@
<%= t("accounts.new.method_selector.manual_entry") %>
<% end %>
<span class="flex items-center w-full gap-4 p-2 px-2 text-center border border-transparent rounded-lg cursor-not-allowed focus:outline-none focus:bg-gray-50 focus:border focus:border-gray-200 text-gray-400">
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= lucide_icon("link-2", class: "text-gray-500 w-5 h-5") %>
</span>
<%= t("accounts.new.method_selector.connected_entry") %>
</span>
<% if link_token.present? %>
<button data-controller="plaid" data-action="plaid#open modal#close" data-plaid-link-token-value="<%= @link_token %>" class="flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2">
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= lucide_icon("link-2", class: "text-gray-500 w-5 h-5") %>
</span>
<%= t("accounts.new.method_selector.connected_entry") %>
</button>
<% end %>
</div>
<% end %>

View file

@ -20,8 +20,10 @@
<% end %>
<div class="flex items-center gap-3 ml-auto">
<%= 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 %>

View file

@ -2,23 +2,32 @@
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= 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" %>
<span><%= t(".manage") %></span>
<% 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" %>
<span><%= t(".edit") %></span>
<% end %>
<span><%= t(".edit") %></span>
<% 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" %>
<span><%= t(".import") %></span>
<% end %>
<span><%= t(".import") %></span>
<% 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 %>
</div>
<% end %>

View file

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

View file

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

View file

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

View file

@ -1,26 +0,0 @@
<%= styled_form_with model: institution, class: "space-y-4", data: { turbo_frame: "_top", controller: "profile-image-preview" } do |f| %>
<div class="flex justify-center items-center py-4">
<%= f.label :logo do %>
<div class="relative cursor-pointer hover:opacity-80 w-16 h-16 rounded-full bg-gray-50">
<% 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 %>
<div data-profile-image-preview-target="imagePreview" class="absolute inset-0 h-full w-full flex items-center justify-center">
<% unless persisted_logo %>
<%= lucide_icon "image-plus", class: "w-5 h-5 text-gray-500 cursor-pointer", data: { profile_image_preview_target: "template" } %>
<% end %>
</div>
</div>
<% end %>
</div>
<%= 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 %>

View file

@ -1,3 +0,0 @@
<%= modal_form_wrapper title: t(".edit", institution: @institution.name) do %>
<%= render "form", institution: @institution %>
<% end %>

View file

@ -1,3 +0,0 @@
<%= modal_form_wrapper title: t(".new_institution") do %>
<%= render "form", institution: @institution %>
<% end %>

View file

@ -1 +1,25 @@
<%# locals: (account:) %>
<% period = Period.from_param(params[:period]) %>
<% series = account.series(period: period) %>
<% trend = series.trend %>
<div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
<div class="p-4 flex justify-between">
<div class="space-y-2">
<div class="flex items-center gap-1">
<%= tag.p t(".value"), class: "text-sm font-medium text-gray-500" %>
</div>
<%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %>
</div>
</div>
<div class="relative h-64 flex items-center justify-center">
<%= image_tag "placeholder-graph.svg", class: "w-full h-full object-cover rounded-bl-lg rounded-br-lg opacity-50" %>
<div class="absolute top-0 left-0 w-full h-full flex flex-col items-center justify-center space-y-1">
<p class="text-gray-900 text-sm">Historical investment data coming soon.</p>
<p class="text-gray-500 text-sm">We're working to bring you the full picture.</p>
</div>
</div>
</div>

View file

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

View file

@ -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 %>
<div class="min-h-[800px]">
<%= render "accounts/show/tabs", account: @account, tabs: [

View file

@ -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? %>
<div class="fixed z-50 space-y-1 top-6 right-10">
<div id="notification-tray">
<div class="fixed z-50 bottom-6 left-6">
<div id="notification-tray" class="space-y-1">
<%= render_flash_notifications %>
<% if Current.family&.syncing? %>
<%= render "shared/notification", id: "syncing-notification", type: :processing, message: t(".syncing") %>
<% end %>
</div>
</div>

View file

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

View file

@ -0,0 +1,76 @@
<%# locals: (plaid_item:) %>
<%= tag.div id: dom_id(plaid_item) do %>
<details open class="group bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
<summary class="flex items-center justify-between gap-2 focus-visible:outline-none">
<div class="flex items-center gap-2">
<%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-gray-500 w-5" %>
<div class="flex items-center justify-center h-8 w-8 bg-blue-600/10 rounded-full bg-black/5">
<% if plaid_item.logo.attached? %>
<%= image_tag plaid_item.logo, class: "rounded-full h-full w-full" %>
<% else %>
<div class="flex items-center justify-center">
<%= tag.p plaid_item.name.first.upcase, class: "text-blue-600 text-xs font-medium" %>
</div>
<% end %>
</div>
<div class="pl-1 text-sm">
<%= tag.p plaid_item.name, class: "font-medium text-gray-900" %>
<% if plaid_item.syncing? %>
<div class="text-gray-500 flex items-center gap-1">
<%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %>
<%= tag.span t(".syncing") %>
</div>
<% elsif plaid_item.sync_error.present? %>
<div class="text-gray-500 flex items-center gap-1">
<%= lucide_icon "alert-circle", class: "w-4 h-4 text-red-500" %>
<%= tag.span t(".error"), class: "text-red-500" %>
</div>
<% else %>
<p class="text-gray-500">
<%= plaid_item.last_synced_at ? t(".status", timestamp: time_ago_in_words(plaid_item.last_synced_at)) : t(".status_never") %>
</p>
<% end %>
</div>
</div>
<div class="flex items-center gap-2">
<%= 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 %>
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= 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" %>
<span><%= t(".delete") %></span>
<% end %>
</div>
<% end %>
</div>
</summary>
<div class="space-y-4 mt-4">
<% if plaid_item.accounts.any? %>
<%= render "accounts/index/account_groups", accounts: plaid_item.accounts %>
<% else %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-gray-900 font-medium text-sm"><%= t(".no_accounts_title") %></p>
<p class="text-gray-500 text-sm"><%= t(".no_accounts_description") %></p>
</div>
<% end %>
</div>
</details>
<% end %>

View file

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

View file

@ -12,7 +12,7 @@
<section class="space-y-2 overflow-hidden">
<%= 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| %>

View file

@ -1 +1 @@
KRWOjsn+8XsQvywXPDphgdRfbxouM6sML006oZNoiXQE3QMj/B7ee53nsT40Pgt37yyu6Adn84SZTkOs0sbOtHlEKQOr8fagJ96bZsQiIqtWA3JIDrHSVygssRIYVD8LyB1ezS+Oa4rZh4NsGJGQdyxlyUVmdFpqT6s18ptFnraDJuf54pkPyl5zAxtIVufXWO2wbyXryE2XEoYfHNmuPO3rtvXOQS9gEYe5yxh1EqgTG32UKnwxCNXBCksgrzuy9qebVTiBRun1L2S7diRw94dZ1mgkIweDAJCwO5wBtgfqo8DWBPunKbqJ5gwRJTvELDXXCWcnPjCUGPSPPBw4clUXNbjkxMltFlC5EReiTb7fi2rRGM64cRZlgReh8RVy6pyiKM2tHUI3Tmdk4q7nwTBCy6ot--ZUWi92DcBx1HlcZg--SVhHEO5AJCLD9LlhuXA+kA==
HMC62biPQuF61XA8tnd/kvwdV2xr/zpfJxG+IHNgGtpuvPXi9oS+YemBGMLte+1Q7elzAAbmKg73699hVLkRcBCk/FaMQjGRF2lnJ9MpxSR/br8Uma2bSH40lIEjxAfzjr4JPSfsHxlArF30hfd+B9obPDOptLQbpENPBsmiuEHX7S0Y8SmKuzDUVrvdfeLoVuMiAZqOP5izpBAbXfvMjI3YH70iJAaPlfAxQqR89O2nSt+N27siyyfkypE3NHQKZFz+Rmo8uJDlaD3eo/uvQN4xsgRCMUar4X2iY4UOd+MIGAPqLzIUhhJ56G5MRDJ4XpJA6RDuGFc/LNyxdXt0WinUX8Yz7zKiKah1NkEhTkH+b2ylFbsN6cjlqcX0yw8Gw8B4osyHQGnj7Tuf1c8k1z3gBoaQALm8zxKCaJ9k6CopVM2GmbpCLcJqjN1L71wCe6MiWsv9LDF/pwuZNG6hWn0oykdkWeBEQyK8g4Wo1AHqgEi8XtRwbaX6yugO5WQFhjQG/LzXcG02E5Co5/r/G7ZSFpRC9ngoOx3LY6MihPRkTIOumCg3HHtAsWBeHe4L/rDIe4A=--hlLxVbnyuYXf7Rku--A6Cwdr3CAW6bRkl1rcRmRw==

View file

@ -94,4 +94,6 @@ Rails.application.configure do
# ]
# 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

View file

@ -63,5 +63,10 @@ Rails.application.configure do
# 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

View file

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

View file

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

View file

@ -1,5 +0,0 @@
---
en:
account:
sync:
failed: Sync failed

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,8 @@
---
en:
layouts:
application:
syncing: Syncing account data...
auth:
existing_account: Already have an account?
no_account: New to Maybe?

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -114,9 +114,6 @@ Rails.application.routes.draw do
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 @@ Rails.application.routes.draw do
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.

View file

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

View file

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

82
db/schema.rb generated
View file

@ -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 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do
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 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do
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 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do
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 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do
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 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do
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 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do
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 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do
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 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do
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 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_08_150422) do
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"

View file

@ -3,9 +3,6 @@ require "test_helper"
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 ]

View file

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

View file

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

View file

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

View file

@ -21,7 +21,7 @@ class Account::TransfersControllerTest < ActionDispatch::IntegrationTest
name: "Test Transfer"
}
}
assert_enqueued_with job: AccountSyncJob
assert_enqueued_with job: SyncJob
end
end

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more