mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-18 20:59:39 +02:00
Basic Plaid Integration (#1433)
* 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:
parent
3bc9da4105
commit
cbba2ba675
127 changed files with 1537 additions and 841 deletions
10
.env.example
10
.env.example
|
@ -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=
|
2
Gemfile
2
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]
|
||||
|
|
|
@ -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)
|
||||
|
|
10
app/assets/images/placeholder-graph.svg
Normal file
10
app/assets/images/placeholder-graph.svg
Normal 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 |
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
38
app/controllers/plaid_items_controller.rb
Normal file
38
app/controllers/plaid_items_controller.rb
Normal 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
|
|
@ -11,8 +11,7 @@ class PropertiesController < ApplicationController
|
|||
currency: Current.family.currency,
|
||||
accountable: Property.new(
|
||||
address: Address.new
|
||||
),
|
||||
institution_id: params[:institution_id]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
module InstitutionsHelper
|
||||
def institution_logo(institution)
|
||||
institution.logo.attached? ? institution.logo : institution.logo_url
|
||||
end
|
||||
end
|
52
app/javascript/controllers/plaid_controller.js
Normal file
52
app/javascript/controllers/plaid_controller.js
Normal 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
|
||||
}
|
||||
}
|
|
@ -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
7
app/jobs/destroy_job.rb
Normal 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
7
app/jobs/sync_job.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
class SyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(sync)
|
||||
sync.perform
|
||||
end
|
||||
end
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
14
app/models/concerns/plaidable.rb
Normal file
14
app/models/concerns/plaidable.rb
Normal 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
|
33
app/models/concerns/syncable.rb
Normal file
33
app/models/concerns/syncable.rb
Normal 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
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ]
|
||||
|
|
|
@ -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
208
app/models/plaid_account.rb
Normal 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
127
app/models/plaid_item.rb
Normal 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
|
220
app/models/provider/plaid.rb
Normal file
220
app/models/provider/plaid.rb
Normal 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
|
28
app/models/provider/plaid_sandbox.rb
Normal file
28
app/models/provider/plaid_sandbox.rb
Normal 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
39
app/models/sync.rb
Normal 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
|
|
@ -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>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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") %>
|
||||
|
|
|
@ -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 %>
|
||||
|
||||
|
|
|
@ -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 %>
|
|
@ -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 %>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
|
@ -1,3 +0,0 @@
|
|||
<%= modal_form_wrapper title: t(".edit", institution: @institution.name) do %>
|
||||
<%= render "form", institution: @institution %>
|
||||
<% end %>
|
|
@ -1,3 +0,0 @@
|
|||
<%= modal_form_wrapper title: t(".new_institution") do %>
|
||||
<%= render "form", institution: @institution %>
|
||||
<% end %>
|
|
@ -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>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 %>
|
||||
|
|
76
app/views/plaid_items/_plaid_item.html.erb
Normal file
76
app/views/plaid_items/_plaid_item.html.erb
Normal 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 %>
|
|
@ -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
|
||||
|
|
|
@ -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| %>
|
||||
|
|
|
@ -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==
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
10
config/initializers/plaid.rb
Normal file
10
config/initializers/plaid.rb
Normal 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
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
en:
|
||||
account:
|
||||
sync:
|
||||
failed: Sync failed
|
|
@ -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...
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
---
|
||||
en:
|
||||
layouts:
|
||||
application:
|
||||
syncing: Syncing account data...
|
||||
auth:
|
||||
existing_account: Already have an account?
|
||||
no_account: New to Maybe?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
20
config/locales/views/plaid_items/en.yml
Normal file
20
config/locales/views/plaid_items/en.yml
Normal 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...
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
56
db/migrate/20241106193743_add_plaid_domain.rb
Normal file
56
db/migrate/20241106193743_add_plaid_domain.rb
Normal 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
|
10
db/migrate/20241114164118_add_products_to_plaid_item.rb
Normal file
10
db/migrate/20241114164118_add_products_to_plaid_item.rb
Normal 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
82
db/schema.rb
generated
|
@ -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"
|
||||
|
|
|
@ -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 ]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -21,7 +21,7 @@ class Account::TransfersControllerTest < ActionDispatch::IntegrationTest
|
|||
name: "Test Transfer"
|
||||
}
|
||||
}
|
||||
assert_enqueued_with job: AccountSyncJob
|
||||
assert_enqueued_with job: SyncJob
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue