mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-18 20:59:39 +02:00
Improve account sync performance, handle concurrent market data syncing (#2236)
* PlaidConnectable concern * Remove bad abstraction * Put sync implementations in own concerns * Sync strategies * Move sync orchestration to Sync class * Clean up sync class, add state machine * Basic market data sync cron * Fix price sync * Improve sync window column names, add timestamps * 30 day syncs by default * Clean up market data methods * Report high duplicate sync counts to Sentry * Add sync states throughout app * account tab session * Persistent account tab selections * Remove manual sleep * Add migration to clear stale syncs on self hosted apps * Tweak sync states * Sync completion event broadcasts * Fix timezones in tests * Cleanup * More cleanup * Plaid item UI broadcasts for sync * Fix account ID namespace conflict * Sync broadcasters * Smoother account sync refreshes * Remove test sync delay
This commit is contained in:
parent
9793cc74f9
commit
10dd9e061a
97 changed files with 1837 additions and 949 deletions
5
Gemfile
5
Gemfile
|
@ -29,6 +29,7 @@ gem "hotwire_combobox"
|
||||||
|
|
||||||
# Background Jobs
|
# Background Jobs
|
||||||
gem "sidekiq"
|
gem "sidekiq"
|
||||||
|
gem "sidekiq-cron"
|
||||||
|
|
||||||
# Monitoring
|
# Monitoring
|
||||||
gem "vernier"
|
gem "vernier"
|
||||||
|
@ -63,6 +64,10 @@ gem "rotp", "~> 6.3"
|
||||||
gem "rqrcode", "~> 3.0"
|
gem "rqrcode", "~> 3.0"
|
||||||
gem "activerecord-import"
|
gem "activerecord-import"
|
||||||
|
|
||||||
|
# State machines
|
||||||
|
gem "aasm"
|
||||||
|
gem "after_commit_everywhere", "~> 1.0"
|
||||||
|
|
||||||
# AI
|
# AI
|
||||||
gem "ruby-openai"
|
gem "ruby-openai"
|
||||||
|
|
||||||
|
|
23
Gemfile.lock
23
Gemfile.lock
|
@ -8,6 +8,8 @@ GIT
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
|
aasm (5.5.0)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
actioncable (7.2.2.1)
|
actioncable (7.2.2.1)
|
||||||
actionpack (= 7.2.2.1)
|
actionpack (= 7.2.2.1)
|
||||||
activesupport (= 7.2.2.1)
|
activesupport (= 7.2.2.1)
|
||||||
|
@ -83,6 +85,9 @@ GEM
|
||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
addressable (2.8.7)
|
addressable (2.8.7)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
|
after_commit_everywhere (1.6.0)
|
||||||
|
activerecord (>= 4.2)
|
||||||
|
activesupport
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
aws-eventstream (1.3.2)
|
aws-eventstream (1.3.2)
|
||||||
aws-partitions (1.1093.0)
|
aws-partitions (1.1093.0)
|
||||||
|
@ -139,6 +144,9 @@ GEM
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
|
cronex (0.15.0)
|
||||||
|
tzinfo
|
||||||
|
unicode (>= 0.4.4.5)
|
||||||
css_parser (1.21.1)
|
css_parser (1.21.1)
|
||||||
addressable
|
addressable
|
||||||
csv (3.3.4)
|
csv (3.3.4)
|
||||||
|
@ -160,6 +168,8 @@ GEM
|
||||||
rubocop (>= 1)
|
rubocop (>= 1)
|
||||||
smart_properties
|
smart_properties
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
|
et-orbi (1.2.11)
|
||||||
|
tzinfo
|
||||||
event_stream_parser (1.0.0)
|
event_stream_parser (1.0.0)
|
||||||
faker (3.5.1)
|
faker (3.5.1)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
|
@ -182,6 +192,9 @@ GEM
|
||||||
ffi (1.17.2-x86_64-linux-gnu)
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
ffi (1.17.2-x86_64-linux-musl)
|
ffi (1.17.2-x86_64-linux-musl)
|
||||||
foreman (0.88.1)
|
foreman (0.88.1)
|
||||||
|
fugit (1.11.1)
|
||||||
|
et-orbi (~> 1, >= 1.2.11)
|
||||||
|
raabro (~> 1.4)
|
||||||
globalid (1.2.1)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
hashdiff (1.1.2)
|
hashdiff (1.1.2)
|
||||||
|
@ -346,6 +359,7 @@ GEM
|
||||||
public_suffix (6.0.1)
|
public_suffix (6.0.1)
|
||||||
puma (6.6.0)
|
puma (6.6.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.1.13)
|
rack (3.1.13)
|
||||||
rack-mini-profiler (3.3.1)
|
rack-mini-profiler (3.3.1)
|
||||||
|
@ -486,6 +500,11 @@ GEM
|
||||||
logger (>= 1.6.2)
|
logger (>= 1.6.2)
|
||||||
rack (>= 3.1.0)
|
rack (>= 3.1.0)
|
||||||
redis-client (>= 0.23.2)
|
redis-client (>= 0.23.2)
|
||||||
|
sidekiq-cron (2.2.0)
|
||||||
|
cronex (>= 0.13.0)
|
||||||
|
fugit (~> 1.8, >= 1.11.1)
|
||||||
|
globalid (>= 1.0.1)
|
||||||
|
sidekiq (>= 6.5.0)
|
||||||
simplecov (0.22.0)
|
simplecov (0.22.0)
|
||||||
docile (~> 1.1)
|
docile (~> 1.1)
|
||||||
simplecov-html (~> 0.11)
|
simplecov-html (~> 0.11)
|
||||||
|
@ -519,6 +538,7 @@ GEM
|
||||||
railties (>= 7.1.0)
|
railties (>= 7.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
|
unicode (0.4.4.5)
|
||||||
unicode-display_width (3.1.4)
|
unicode-display_width (3.1.4)
|
||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
|
@ -561,7 +581,9 @@ PLATFORMS
|
||||||
x86_64-linux-musl
|
x86_64-linux-musl
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
aasm
|
||||||
activerecord-import
|
activerecord-import
|
||||||
|
after_commit_everywhere (~> 1.0)
|
||||||
aws-sdk-s3 (~> 1.177.0)
|
aws-sdk-s3 (~> 1.177.0)
|
||||||
bcrypt (~> 3.1)
|
bcrypt (~> 3.1)
|
||||||
benchmark-ips
|
benchmark-ips
|
||||||
|
@ -612,6 +634,7 @@ DEPENDENCIES
|
||||||
sentry-ruby
|
sentry-ruby
|
||||||
sentry-sidekiq
|
sentry-sidekiq
|
||||||
sidekiq
|
sidekiq
|
||||||
|
sidekiq-cron
|
||||||
simplecov
|
simplecov
|
||||||
skylight
|
skylight
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
|
|
|
@ -240,7 +240,7 @@
|
||||||
100% {
|
100% {
|
||||||
stroke-dashoffset: 0;
|
stroke-dashoffset: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Specific override for strong tags in prose under dark mode */
|
/* Specific override for strong tags in prose under dark mode */
|
||||||
|
@ -429,5 +429,3 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -93,3 +93,7 @@
|
||||||
background-color: var(--color-alpha-black-900);
|
background-color: var(--color-alpha-black-900);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility bg-loader {
|
||||||
|
@apply bg-surface-inset animate-pulse;
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<%= tag.div data: {
|
<%= tag.div data: {
|
||||||
controller: "tabs",
|
controller: "tabs",
|
||||||
testid: testid,
|
testid: testid,
|
||||||
|
tabs_session_key_value: session_key,
|
||||||
tabs_url_param_key_value: url_param_key,
|
tabs_url_param_key_value: url_param_key,
|
||||||
tabs_nav_btn_active_class: active_btn_classes,
|
tabs_nav_btn_active_class: active_btn_classes,
|
||||||
tabs_nav_btn_inactive_class: inactive_btn_classes
|
tabs_nav_btn_inactive_class: inactive_btn_classes
|
||||||
|
|
|
@ -27,11 +27,12 @@ class TabsComponent < ViewComponent::Base
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
attr_reader :active_tab, :url_param_key, :variant, :testid
|
attr_reader :active_tab, :url_param_key, :session_key, :variant, :testid
|
||||||
|
|
||||||
def initialize(active_tab:, url_param_key: nil, variant: :default, active_btn_classes: "", inactive_btn_classes: "", testid: nil)
|
def initialize(active_tab:, url_param_key: nil, session_key: nil, variant: :default, active_btn_classes: "", inactive_btn_classes: "", testid: nil)
|
||||||
@active_tab = active_tab
|
@active_tab = active_tab
|
||||||
@url_param_key = url_param_key
|
@url_param_key = url_param_key
|
||||||
|
@session_key = session_key
|
||||||
@variant = variant.to_sym
|
@variant = variant.to_sym
|
||||||
@active_btn_classes = active_btn_classes
|
@active_btn_classes = active_btn_classes
|
||||||
@inactive_btn_classes = inactive_btn_classes
|
@inactive_btn_classes = inactive_btn_classes
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { Controller } from "@hotwired/stimulus";
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static classes = ["navBtnActive", "navBtnInactive"];
|
static classes = ["navBtnActive", "navBtnInactive"];
|
||||||
static targets = ["panel", "navBtn"];
|
static targets = ["panel", "navBtn"];
|
||||||
static values = { urlParamKey: String };
|
static values = { sessionKey: String, urlParamKey: String };
|
||||||
|
|
||||||
show(e) {
|
show(e) {
|
||||||
const btn = e.target.closest("button");
|
const btn = e.target.closest("button");
|
||||||
|
@ -28,11 +28,30 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update URL with the selected tab
|
|
||||||
if (this.urlParamKeyValue) {
|
if (this.urlParamKeyValue) {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set(this.urlParamKeyValue, selectedTabId);
|
url.searchParams.set(this.urlParamKeyValue, selectedTabId);
|
||||||
window.history.replaceState({}, "", url);
|
window.history.replaceState({}, "", url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update URL with the selected tab
|
||||||
|
if (this.sessionKeyValue) {
|
||||||
|
this.#updateSessionPreference(selectedTabId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#updateSessionPreference(selectedTabId) {
|
||||||
|
fetch("/current_session", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"X-CSRF-Token": document.querySelector('[name="csrf-token"]').content,
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
"current_session[tab_key]": this.sessionKeyValue,
|
||||||
|
"current_session[tab_value]": selectedTabId,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,14 +26,6 @@ class AccountsController < ApplicationController
|
||||||
render layout: false
|
render layout: false
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_all
|
|
||||||
unless family.syncing?
|
|
||||||
family.sync_later
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_back_or_to accounts_path
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
def family
|
def family
|
||||||
Current.family
|
Current.family
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable, Notifiable
|
include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable,
|
||||||
|
SelfHostable, StoreLocation, Impersonatable, Breadcrumbable,
|
||||||
|
FeatureGuardable, Notifiable
|
||||||
|
|
||||||
include Pagy::Backend
|
include Pagy::Backend
|
||||||
|
|
||||||
before_action :detect_os
|
before_action :detect_os
|
||||||
|
|
|
@ -46,8 +46,6 @@ module Notifiable
|
||||||
[ { partial: "shared/notifications/alert", locals: { message: data } } ]
|
[ { partial: "shared/notifications/alert", locals: { message: data } } ]
|
||||||
when "cta"
|
when "cta"
|
||||||
[ resolve_cta(data) ]
|
[ resolve_cta(data) ]
|
||||||
when "loading"
|
|
||||||
[ { partial: "shared/notifications/loading", locals: { message: data } } ]
|
|
||||||
when "notice"
|
when "notice"
|
||||||
messages = Array(data)
|
messages = Array(data)
|
||||||
messages.map { |message| { partial: "shared/notifications/notice", locals: { message: message } } }
|
messages.map { |message| { partial: "shared/notifications/notice", locals: { message: message } } }
|
||||||
|
|
24
app/controllers/concerns/restore_layout_preferences.rb
Normal file
24
app/controllers/concerns/restore_layout_preferences.rb
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
module RestoreLayoutPreferences
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
before_action :restore_active_tabs
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def restore_active_tabs
|
||||||
|
last_selected_tab = Current.session&.get_preferred_tab("account_sidebar_tab") || "asset"
|
||||||
|
|
||||||
|
@account_group_tab = account_group_tab_param || last_selected_tab
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_account_group_tabs
|
||||||
|
%w[asset liability all]
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_group_tab_param
|
||||||
|
param_value = params[:account_sidebar_tab]
|
||||||
|
return nil unless param_value.in?(valid_account_group_tabs)
|
||||||
|
param_value
|
||||||
|
end
|
||||||
|
end
|
22
app/controllers/cookie_sessions_controller.rb
Normal file
22
app/controllers/cookie_sessions_controller.rb
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
class CookieSessionsController < ApplicationController
|
||||||
|
def update
|
||||||
|
save_kv_to_session(
|
||||||
|
cookie_session_params[:tab_key],
|
||||||
|
cookie_session_params[:tab_value]
|
||||||
|
)
|
||||||
|
|
||||||
|
redirect_back_or_to root_path
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def cookie_session_params
|
||||||
|
params.require(:cookie_session).permit(:tab_key, :tab_value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def save_kv_to_session(key, value)
|
||||||
|
raise "Key must be a string" unless key.is_a?(String)
|
||||||
|
raise "Value must be a string" unless value.is_a?(String)
|
||||||
|
|
||||||
|
session["custom_#{key}"] = value
|
||||||
|
end
|
||||||
|
end
|
14
app/controllers/current_sessions_controller.rb
Normal file
14
app/controllers/current_sessions_controller.rb
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
class CurrentSessionsController < ApplicationController
|
||||||
|
def update
|
||||||
|
if session_params[:tab_key].present? && session_params[:tab_value].present?
|
||||||
|
Current.session.set_preferred_tab(session_params[:tab_key], session_params[:tab_value])
|
||||||
|
end
|
||||||
|
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def session_params
|
||||||
|
params.require(:current_session).permit(:tab_key, :tab_value)
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,8 +2,8 @@ class PlaidItemsController < ApplicationController
|
||||||
before_action :set_plaid_item, only: %i[destroy sync]
|
before_action :set_plaid_item, only: %i[destroy sync]
|
||||||
|
|
||||||
def create
|
def create
|
||||||
Current.family.plaid_items.create_from_public_token(
|
Current.family.create_plaid_item!(
|
||||||
plaid_item_params[:public_token],
|
public_token: plaid_item_params[:public_token],
|
||||||
item_name: item_name,
|
item_name: item_name,
|
||||||
region: plaid_item_params[:region]
|
region: plaid_item_params[:region]
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,16 +0,0 @@
|
||||||
import { Controller } from "@hotwired/stimulus";
|
|
||||||
|
|
||||||
// Connects to data-controller="sidebar-tabs"
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["account"];
|
|
||||||
|
|
||||||
select(event) {
|
|
||||||
this.accountTargets.forEach((account) => {
|
|
||||||
if (account.contains(event.target)) {
|
|
||||||
account.classList.add("bg-container");
|
|
||||||
} else {
|
|
||||||
account.classList.remove("bg-container");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
7
app/jobs/sync_market_data_job.rb
Normal file
7
app/jobs/sync_market_data_job.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
class SyncMarketDataJob < ApplicationJob
|
||||||
|
queue_as :scheduled
|
||||||
|
|
||||||
|
def perform
|
||||||
|
MarketDataSyncer.new.sync_all
|
||||||
|
end
|
||||||
|
end
|
|
@ -61,6 +61,18 @@ class Account < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def syncing?
|
||||||
|
self_syncing = syncs.incomplete.any?
|
||||||
|
|
||||||
|
# Since Plaid Items sync as a "group", if the item is syncing, even if the account
|
||||||
|
# sync hasn't yet started (i.e. we're still fetching the Plaid data), show it as syncing in UI.
|
||||||
|
if linked?
|
||||||
|
plaid_account&.plaid_item&.syncing? || self_syncing
|
||||||
|
else
|
||||||
|
self_syncing
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def institution_domain
|
def institution_domain
|
||||||
url_string = plaid_account&.plaid_item&.institution_url
|
url_string = plaid_account&.plaid_item&.institution_url
|
||||||
return nil unless url_string.present?
|
return nil unless url_string.present?
|
||||||
|
@ -81,21 +93,6 @@ class Account < ApplicationRecord
|
||||||
DestroyJob.perform_later(self)
|
DestroyJob.perform_later(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_data(sync, start_date: nil)
|
|
||||||
Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})")
|
|
||||||
sync_balances
|
|
||||||
end
|
|
||||||
|
|
||||||
def post_sync(sync)
|
|
||||||
family.remove_syncing_notice!
|
|
||||||
|
|
||||||
accountable.post_sync(sync)
|
|
||||||
|
|
||||||
unless sync.child?
|
|
||||||
family.auto_match_transfers!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_holdings
|
def current_holdings
|
||||||
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
|
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
|
||||||
end
|
end
|
||||||
|
@ -172,10 +169,4 @@ class Account < ApplicationRecord
|
||||||
def long_subtype_label
|
def long_subtype_label
|
||||||
accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name
|
accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
def sync_balances
|
|
||||||
strategy = linked? ? :reverse : :forward
|
|
||||||
Balance::Syncer.new(self, strategy: strategy).sync_balances
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
54
app/models/account/sync_complete_event.rb
Normal file
54
app/models/account/sync_complete_event.rb
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
class Account::SyncCompleteEvent
|
||||||
|
attr_reader :account
|
||||||
|
|
||||||
|
def initialize(account)
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast
|
||||||
|
# Replace account row in accounts list
|
||||||
|
account.broadcast_replace_to(
|
||||||
|
account.family,
|
||||||
|
target: "account_#{account.id}",
|
||||||
|
partial: "accounts/account",
|
||||||
|
locals: { account: account }
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace the groups this account belongs to in the sidebar
|
||||||
|
account_group_ids.each do |id|
|
||||||
|
account.broadcast_replace_to(
|
||||||
|
account.family,
|
||||||
|
target: id,
|
||||||
|
partial: "accounts/accountable_group",
|
||||||
|
locals: { account_group: account_group, open: true }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# If this is a manual, unlinked account (i.e. not part of a Plaid Item),
|
||||||
|
# trigger the family sync complete broadcast so net worth graph is updated
|
||||||
|
unless account.linked?
|
||||||
|
account.family.broadcast_sync_complete
|
||||||
|
end
|
||||||
|
|
||||||
|
# Refresh entire account page (only applies if currently viewing this account)
|
||||||
|
account.broadcast_refresh
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
# The sidebar will show the account in both its classification tab and the "all" tab,
|
||||||
|
# so we need to broadcast to both.
|
||||||
|
def account_group_ids
|
||||||
|
id = account_group.id
|
||||||
|
[ id, "#{account_group.classification}_#{id}" ]
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_group
|
||||||
|
family_balance_sheet.account_groups.find do |group|
|
||||||
|
group.accounts.any? { |a| a.id == account.id }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def family_balance_sheet
|
||||||
|
account.family.balance_sheet
|
||||||
|
end
|
||||||
|
end
|
22
app/models/account/syncer.rb
Normal file
22
app/models/account/syncer.rb
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
class Account::Syncer
|
||||||
|
attr_reader :account
|
||||||
|
|
||||||
|
def initialize(account)
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_sync(sync)
|
||||||
|
Rails.logger.info("Processing balances (#{account.linked? ? 'reverse' : 'forward'})")
|
||||||
|
sync_balances
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_post_sync
|
||||||
|
account.family.auto_match_transfers!
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def sync_balances
|
||||||
|
strategy = account.linked? ? :reverse : :forward
|
||||||
|
Balance::Syncer.new(account, strategy: strategy).sync_balances
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,35 +0,0 @@
|
||||||
class Balance::BaseCalculator
|
|
||||||
attr_reader :account
|
|
||||||
|
|
||||||
def initialize(account)
|
|
||||||
@account = account
|
|
||||||
end
|
|
||||||
|
|
||||||
def calculate
|
|
||||||
Rails.logger.tagged(self.class.name) do
|
|
||||||
calculate_balances
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def sync_cache
|
|
||||||
@sync_cache ||= Balance::SyncCache.new(account)
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_balance(date, cash_balance, holdings_value)
|
|
||||||
Balance.new(
|
|
||||||
account_id: account.id,
|
|
||||||
date: date,
|
|
||||||
balance: holdings_value + cash_balance,
|
|
||||||
cash_balance: cash_balance,
|
|
||||||
currency: account.currency
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def calculate_next_balance(prior_balance, transactions, direction: :forward)
|
|
||||||
flows = transactions.sum(&:amount)
|
|
||||||
negated = direction == :forward ? account.asset? : account.liability?
|
|
||||||
flows *= -1 if negated
|
|
||||||
prior_balance + flows
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,4 +1,16 @@
|
||||||
class Balance::ForwardCalculator < Balance::BaseCalculator
|
class Balance::ForwardCalculator
|
||||||
|
attr_reader :account
|
||||||
|
|
||||||
|
def initialize(account)
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate
|
||||||
|
Rails.logger.tagged("Balance::ForwardCalculator") do
|
||||||
|
calculate_balances
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def calculate_balances
|
def calculate_balances
|
||||||
current_cash_balance = 0
|
current_cash_balance = 0
|
||||||
|
@ -25,4 +37,25 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
|
||||||
|
|
||||||
@balances
|
@balances
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sync_cache
|
||||||
|
@sync_cache ||= Balance::SyncCache.new(account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_balance(date, cash_balance, holdings_value)
|
||||||
|
Balance.new(
|
||||||
|
account_id: account.id,
|
||||||
|
date: date,
|
||||||
|
balance: holdings_value + cash_balance,
|
||||||
|
cash_balance: cash_balance,
|
||||||
|
currency: account.currency
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_next_balance(prior_balance, transactions, direction: :forward)
|
||||||
|
flows = transactions.sum(&:amount)
|
||||||
|
negated = direction == :forward ? account.asset? : account.liability?
|
||||||
|
flows *= -1 if negated
|
||||||
|
prior_balance + flows
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,4 +1,16 @@
|
||||||
class Balance::ReverseCalculator < Balance::BaseCalculator
|
class Balance::ReverseCalculator
|
||||||
|
attr_reader :account
|
||||||
|
|
||||||
|
def initialize(account)
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate
|
||||||
|
Rails.logger.tagged("Balance::ReverseCalculator") do
|
||||||
|
calculate_balances
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def calculate_balances
|
def calculate_balances
|
||||||
current_cash_balance = account.cash_balance
|
current_cash_balance = account.cash_balance
|
||||||
|
@ -35,4 +47,25 @@ class Balance::ReverseCalculator < Balance::BaseCalculator
|
||||||
|
|
||||||
@balances
|
@balances
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sync_cache
|
||||||
|
@sync_cache ||= Balance::SyncCache.new(account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_balance(date, cash_balance, holdings_value)
|
||||||
|
Balance.new(
|
||||||
|
account_id: account.id,
|
||||||
|
date: date,
|
||||||
|
balance: holdings_value + cash_balance,
|
||||||
|
cash_balance: cash_balance,
|
||||||
|
currency: account.currency
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_next_balance(prior_balance, transactions, direction: :forward)
|
||||||
|
flows = transactions.sum(&:amount)
|
||||||
|
negated = direction == :forward ? account.asset? : account.liability?
|
||||||
|
flows *= -1 if negated
|
||||||
|
prior_balance + flows
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -22,20 +22,25 @@ class BalanceSheet
|
||||||
end
|
end
|
||||||
|
|
||||||
def classification_groups
|
def classification_groups
|
||||||
|
asset_groups = account_groups("asset")
|
||||||
|
liability_groups = account_groups("liability")
|
||||||
|
|
||||||
[
|
[
|
||||||
ClassificationGroup.new(
|
ClassificationGroup.new(
|
||||||
key: "asset",
|
key: "asset",
|
||||||
display_name: "Assets",
|
display_name: "Assets",
|
||||||
icon: "plus",
|
icon: "plus",
|
||||||
total_money: total_assets_money,
|
total_money: total_assets_money,
|
||||||
account_groups: account_groups("asset")
|
account_groups: asset_groups,
|
||||||
|
syncing?: asset_groups.any?(&:syncing?)
|
||||||
),
|
),
|
||||||
ClassificationGroup.new(
|
ClassificationGroup.new(
|
||||||
key: "liability",
|
key: "liability",
|
||||||
display_name: "Debts",
|
display_name: "Debts",
|
||||||
icon: "minus",
|
icon: "minus",
|
||||||
total_money: total_liabilities_money,
|
total_money: total_liabilities_money,
|
||||||
account_groups: account_groups("liability")
|
account_groups: liability_groups,
|
||||||
|
syncing?: liability_groups.any?(&:syncing?)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
@ -43,13 +48,17 @@ class BalanceSheet
|
||||||
def account_groups(classification = nil)
|
def account_groups(classification = nil)
|
||||||
classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query
|
classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query
|
||||||
classification_total = classification_accounts.sum(&:converted_balance)
|
classification_total = classification_accounts.sum(&:converted_balance)
|
||||||
account_groups = classification_accounts.group_by(&:accountable_type).transform_keys { |k| Accountable.from_type(k) }
|
account_groups = classification_accounts.group_by(&:accountable_type)
|
||||||
|
.transform_keys { |k| Accountable.from_type(k) }
|
||||||
|
|
||||||
account_groups.map do |accountable, accounts|
|
groups = account_groups.map do |accountable, accounts|
|
||||||
group_total = accounts.sum(&:converted_balance)
|
group_total = accounts.sum(&:converted_balance)
|
||||||
|
|
||||||
|
key = accountable.model_name.param_key
|
||||||
|
|
||||||
AccountGroup.new(
|
AccountGroup.new(
|
||||||
key: accountable.model_name.param_key,
|
id: classification ? "#{classification}_#{key}_group" : "#{key}_group",
|
||||||
|
key: key,
|
||||||
name: accountable.display_name,
|
name: accountable.display_name,
|
||||||
classification: accountable.classification,
|
classification: accountable.classification,
|
||||||
total: group_total,
|
total: group_total,
|
||||||
|
@ -57,6 +66,7 @@ class BalanceSheet
|
||||||
weight: classification_total.zero? ? 0 : group_total / classification_total.to_d * 100,
|
weight: classification_total.zero? ? 0 : group_total / classification_total.to_d * 100,
|
||||||
missing_rates?: accounts.any? { |a| a.missing_rates? },
|
missing_rates?: accounts.any? { |a| a.missing_rates? },
|
||||||
color: accountable.color,
|
color: accountable.color,
|
||||||
|
syncing?: accounts.any?(&:is_syncing),
|
||||||
accounts: accounts.map do |account|
|
accounts: accounts.map do |account|
|
||||||
account.define_singleton_method(:weight) do
|
account.define_singleton_method(:weight) do
|
||||||
classification_total.zero? ? 0 : account.converted_balance / classification_total.to_d * 100
|
classification_total.zero? ? 0 : account.converted_balance / classification_total.to_d * 100
|
||||||
|
@ -65,7 +75,13 @@ class BalanceSheet
|
||||||
account
|
account
|
||||||
end.sort_by(&:weight).reverse
|
end.sort_by(&:weight).reverse
|
||||||
)
|
)
|
||||||
end.sort_by(&:weight).reverse
|
end
|
||||||
|
|
||||||
|
groups.sort_by do |group|
|
||||||
|
manual_order = Accountable::TYPES
|
||||||
|
type_name = group.key.camelize
|
||||||
|
manual_order.index(type_name) || Float::INFINITY
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def net_worth_series(period: Period.last_30_days)
|
def net_worth_series(period: Period.last_30_days)
|
||||||
|
@ -76,9 +92,13 @@ class BalanceSheet
|
||||||
family.currency
|
family.currency
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def syncing?
|
||||||
|
classification_groups.any? { |group| group.syncing? }
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, keyword_init: true)
|
ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, :syncing?, keyword_init: true)
|
||||||
AccountGroup = Struct.new(:key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, keyword_init: true)
|
AccountGroup = Struct.new(:id, :key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, :syncing?, keyword_init: true)
|
||||||
|
|
||||||
def active_accounts
|
def active_accounts
|
||||||
family.accounts.active.with_attached_logo
|
family.accounts.active.with_attached_logo
|
||||||
|
@ -87,9 +107,11 @@ class BalanceSheet
|
||||||
def totals_query
|
def totals_query
|
||||||
@totals_query ||= active_accounts
|
@totals_query ||= active_accounts
|
||||||
.joins(ActiveRecord::Base.sanitize_sql_array([ "LEFT JOIN exchange_rates ON exchange_rates.date = CURRENT_DATE AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?", currency ]))
|
.joins(ActiveRecord::Base.sanitize_sql_array([ "LEFT JOIN exchange_rates ON exchange_rates.date = CURRENT_DATE AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?", currency ]))
|
||||||
|
.joins("LEFT JOIN syncs ON syncs.syncable_id = accounts.id AND syncs.syncable_type = 'Account' AND (syncs.status = 'pending' OR syncs.status = 'syncing')")
|
||||||
.select(
|
.select(
|
||||||
"accounts.*",
|
"accounts.*",
|
||||||
"SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance",
|
"SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance",
|
||||||
|
"COUNT(syncs.id) > 0 as is_syncing",
|
||||||
ActiveRecord::Base.sanitize_sql_array([ "COUNT(CASE WHEN accounts.currency <> ? AND exchange_rates.rate IS NULL THEN 1 END) as missing_rates", currency ])
|
ActiveRecord::Base.sanitize_sql_array([ "COUNT(CASE WHEN accounts.currency <> ? AND exchange_rates.rate IS NULL THEN 1 END) as missing_rates", currency ])
|
||||||
)
|
)
|
||||||
.group(:classification, :accountable_type, :id)
|
.group(:classification, :accountable_type, :id)
|
||||||
|
|
|
@ -68,15 +68,6 @@ module Accountable
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_sync(sync)
|
|
||||||
broadcast_replace_to(
|
|
||||||
account,
|
|
||||||
target: "chart_account_#{account.id}",
|
|
||||||
partial: "accounts/show/chart",
|
|
||||||
locals: { account: account }
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def display_name
|
def display_name
|
||||||
self.class.display_name
|
self.class.display_name
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,24 +6,24 @@ module Syncable
|
||||||
end
|
end
|
||||||
|
|
||||||
def syncing?
|
def syncing?
|
||||||
syncs.where(status: [ :syncing, :pending ]).any?
|
raise NotImplementedError, "Subclasses must implement the syncing? method"
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_later(start_date: nil, parent_sync: nil)
|
def sync_later(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
||||||
new_sync = syncs.create!(start_date: start_date, parent: parent_sync)
|
new_sync = syncs.create!(parent: parent_sync, window_start_date: window_start_date, window_end_date: window_end_date)
|
||||||
SyncJob.perform_later(new_sync)
|
SyncJob.perform_later(new_sync)
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync(start_date: nil)
|
def perform_sync(sync)
|
||||||
syncs.create!(start_date: start_date).perform
|
syncer.perform_sync(sync)
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_data(sync, start_date: nil)
|
def perform_post_sync
|
||||||
raise NotImplementedError, "Subclasses must implement the `sync_data` method"
|
syncer.perform_post_sync
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_sync(sync)
|
def broadcast_sync_complete
|
||||||
# no-op, syncable can optionally provide implementation
|
sync_broadcaster.broadcast
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_error
|
def sync_error
|
||||||
|
@ -31,7 +31,7 @@ module Syncable
|
||||||
end
|
end
|
||||||
|
|
||||||
def last_synced_at
|
def last_synced_at
|
||||||
latest_sync&.last_ran_at
|
latest_sync&.completed_at
|
||||||
end
|
end
|
||||||
|
|
||||||
def last_sync_created_at
|
def last_sync_created_at
|
||||||
|
@ -40,6 +40,14 @@ module Syncable
|
||||||
|
|
||||||
private
|
private
|
||||||
def latest_sync
|
def latest_sync
|
||||||
syncs.order(created_at: :desc).first
|
syncs.ordered.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def syncer
|
||||||
|
self.class::Syncer.new(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_broadcaster
|
||||||
|
self.class::SyncCompleteEvent.new(self)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -45,7 +45,7 @@ class Entry < ApplicationRecord
|
||||||
|
|
||||||
def sync_account_later
|
def sync_account_later
|
||||||
sync_start_date = [ date_previously_was, date ].compact.min unless destroyed?
|
sync_start_date = [ date_previously_was, date ].compact.min unless destroyed?
|
||||||
account.sync_later(start_date: sync_start_date)
|
account.sync_later(window_start_date: sync_start_date)
|
||||||
end
|
end
|
||||||
|
|
||||||
def entryable_name_short
|
def entryable_name_short
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class Family < ApplicationRecord
|
class Family < ApplicationRecord
|
||||||
include Syncable, AutoTransferMatchable, Subscribeable
|
include PlaidConnectable, Syncable, AutoTransferMatchable, Subscribeable
|
||||||
|
|
||||||
DATE_FORMATS = [
|
DATE_FORMATS = [
|
||||||
[ "MM-DD-YYYY", "%m-%d-%Y" ],
|
[ "MM-DD-YYYY", "%m-%d-%Y" ],
|
||||||
|
@ -15,7 +15,6 @@ class Family < ApplicationRecord
|
||||||
|
|
||||||
has_many :users, dependent: :destroy
|
has_many :users, dependent: :destroy
|
||||||
has_many :accounts, dependent: :destroy
|
has_many :accounts, dependent: :destroy
|
||||||
has_many :plaid_items, dependent: :destroy
|
|
||||||
has_many :invitations, dependent: :destroy
|
has_many :invitations, dependent: :destroy
|
||||||
|
|
||||||
has_many :imports, dependent: :destroy
|
has_many :imports, dependent: :destroy
|
||||||
|
@ -36,6 +35,15 @@ class Family < ApplicationRecord
|
||||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||||
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
|
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
|
||||||
|
|
||||||
|
# If any accounts or plaid items are syncing, the family is also syncing, even if a formal "Family Sync" is not running.
|
||||||
|
def syncing?
|
||||||
|
Sync.joins("LEFT JOIN plaid_items ON plaid_items.id = syncs.syncable_id AND syncs.syncable_type = 'PlaidItem'")
|
||||||
|
.joins("LEFT JOIN accounts ON accounts.id = syncs.syncable_id AND syncs.syncable_type = 'Account'")
|
||||||
|
.where("syncs.syncable_id = ? OR accounts.family_id = ? OR plaid_items.family_id = ?", id, id, id)
|
||||||
|
.incomplete
|
||||||
|
.exists?
|
||||||
|
end
|
||||||
|
|
||||||
def assigned_merchants
|
def assigned_merchants
|
||||||
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
|
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
|
||||||
Merchant.where(id: merchant_ids)
|
Merchant.where(id: merchant_ids)
|
||||||
|
@ -65,64 +73,10 @@ class Family < ApplicationRecord
|
||||||
@income_statement ||= IncomeStatement.new(self)
|
@income_statement ||= IncomeStatement.new(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_data(sync, start_date: nil)
|
|
||||||
# We don't rely on this value to guard the app, but keep it eventually consistent
|
|
||||||
sync_trial_status!
|
|
||||||
|
|
||||||
Rails.logger.info("Syncing accounts for family #{id}")
|
|
||||||
accounts.manual.each do |account|
|
|
||||||
account.sync_later(start_date: start_date, parent_sync: sync)
|
|
||||||
end
|
|
||||||
|
|
||||||
Rails.logger.info("Applying rules for family #{id}")
|
|
||||||
rules.each do |rule|
|
|
||||||
rule.apply_later
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove_syncing_notice!
|
|
||||||
broadcast_remove target: "syncing-notice"
|
|
||||||
end
|
|
||||||
|
|
||||||
def post_sync(sync)
|
|
||||||
auto_match_transfers!
|
|
||||||
broadcast_refresh
|
|
||||||
end
|
|
||||||
|
|
||||||
# If family has any syncs pending/syncing within the last 10 minutes, we show a persistent "syncing" notice.
|
|
||||||
# Ignore syncs older than 10 minutes as they are considered "stale"
|
|
||||||
def syncing?
|
|
||||||
Sync.where(
|
|
||||||
"(syncable_type = 'Family' AND syncable_id = ?) OR
|
|
||||||
(syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR
|
|
||||||
(syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))",
|
|
||||||
id, id, id
|
|
||||||
).where(status: [ "pending", "syncing" ], created_at: 10.minutes.ago..).exists?
|
|
||||||
end
|
|
||||||
|
|
||||||
def eu?
|
def eu?
|
||||||
country != "US" && country != "CA"
|
country != "US" && country != "CA"
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil)
|
|
||||||
provider = if region.to_sym == :eu
|
|
||||||
Provider::Registry.get_provider(:plaid_eu)
|
|
||||||
else
|
|
||||||
Provider::Registry.get_provider(:plaid_us)
|
|
||||||
end
|
|
||||||
|
|
||||||
# early return when no provider
|
|
||||||
return nil unless provider
|
|
||||||
|
|
||||||
provider.get_link_token(
|
|
||||||
user_id: id,
|
|
||||||
webhooks_url: webhooks_url,
|
|
||||||
redirect_url: redirect_url,
|
|
||||||
accountable_type: accountable_type,
|
|
||||||
access_token: access_token
|
|
||||||
).link_token
|
|
||||||
end
|
|
||||||
|
|
||||||
def requires_data_provider?
|
def requires_data_provider?
|
||||||
# If family has any trades, they need a provider for historical prices
|
# If family has any trades, they need a provider for historical prices
|
||||||
return true if trades.any?
|
return true if trades.any?
|
||||||
|
|
51
app/models/family/plaid_connectable.rb
Normal file
51
app/models/family/plaid_connectable.rb
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
module Family::PlaidConnectable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
has_many :plaid_items, dependent: :destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_plaid_item!(public_token:, item_name:, region:)
|
||||||
|
provider = plaid_provider_for_region(region)
|
||||||
|
|
||||||
|
public_token_response = provider.exchange_public_token(public_token)
|
||||||
|
|
||||||
|
plaid_item = plaid_items.create!(
|
||||||
|
name: item_name,
|
||||||
|
plaid_id: public_token_response.item_id,
|
||||||
|
access_token: public_token_response.access_token,
|
||||||
|
plaid_region: region
|
||||||
|
)
|
||||||
|
|
||||||
|
plaid_item.sync_later
|
||||||
|
|
||||||
|
plaid_item
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil)
|
||||||
|
return nil unless plaid_us || plaid_eu
|
||||||
|
|
||||||
|
provider = plaid_provider_for_region(region)
|
||||||
|
|
||||||
|
provider.get_link_token(
|
||||||
|
user_id: self.id,
|
||||||
|
webhooks_url: webhooks_url,
|
||||||
|
redirect_url: redirect_url,
|
||||||
|
accountable_type: accountable_type,
|
||||||
|
access_token: access_token
|
||||||
|
).link_token
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def plaid_us
|
||||||
|
@plaid ||= Provider::Registry.get_provider(:plaid_us)
|
||||||
|
end
|
||||||
|
|
||||||
|
def plaid_eu
|
||||||
|
@plaid_eu ||= Provider::Registry.get_provider(:plaid_eu)
|
||||||
|
end
|
||||||
|
|
||||||
|
def plaid_provider_for_region(region)
|
||||||
|
region.to_sym == :eu ? plaid_eu : plaid_us
|
||||||
|
end
|
||||||
|
end
|
|
@ -72,10 +72,9 @@ module Family::Subscribeable
|
||||||
(1 - days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100
|
(1 - days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
def sync_trial_status!
|
||||||
def sync_trial_status!
|
if subscription&.status == "trialing" && days_left_in_trial < 0
|
||||||
if subscription&.status == "trialing" && days_left_in_trial < 0
|
subscription.update!(status: "paused")
|
||||||
subscription.update!(status: "paused")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
21
app/models/family/sync_complete_event.rb
Normal file
21
app/models/family/sync_complete_event.rb
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
class Family::SyncCompleteEvent
|
||||||
|
attr_reader :family
|
||||||
|
|
||||||
|
def initialize(family)
|
||||||
|
@family = family
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast
|
||||||
|
family.broadcast_replace(
|
||||||
|
target: "balance-sheet",
|
||||||
|
partial: "pages/dashboard/balance_sheet",
|
||||||
|
locals: { balance_sheet: family.balance_sheet }
|
||||||
|
)
|
||||||
|
|
||||||
|
family.broadcast_replace(
|
||||||
|
target: "net-worth-chart",
|
||||||
|
partial: "pages/dashboard/net_worth_chart",
|
||||||
|
locals: { balance_sheet: family.balance_sheet, period: Period.last_30_days }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
31
app/models/family/syncer.rb
Normal file
31
app/models/family/syncer.rb
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
class Family::Syncer
|
||||||
|
attr_reader :family
|
||||||
|
|
||||||
|
def initialize(family)
|
||||||
|
@family = family
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_sync(sync)
|
||||||
|
# We don't rely on this value to guard the app, but keep it eventually consistent
|
||||||
|
family.sync_trial_status!
|
||||||
|
|
||||||
|
Rails.logger.info("Applying rules for family #{family.id}")
|
||||||
|
family.rules.each do |rule|
|
||||||
|
rule.apply_later
|
||||||
|
end
|
||||||
|
|
||||||
|
# Schedule child syncs
|
||||||
|
child_syncables.each do |syncable|
|
||||||
|
syncable.sync_later(parent_sync: sync, window_start_date: sync.window_start_date, window_end_date: sync.window_end_date)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_post_sync
|
||||||
|
family.auto_match_transfers!
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def child_syncables
|
||||||
|
family.plaid_items + family.accounts.manual
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,62 +0,0 @@
|
||||||
class Holding::BaseCalculator
|
|
||||||
attr_reader :account
|
|
||||||
|
|
||||||
def initialize(account)
|
|
||||||
@account = account
|
|
||||||
end
|
|
||||||
|
|
||||||
def calculate
|
|
||||||
Rails.logger.tagged(self.class.name) do
|
|
||||||
holdings = calculate_holdings
|
|
||||||
Holding.gapfill(holdings)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def portfolio_cache
|
|
||||||
@portfolio_cache ||= Holding::PortfolioCache.new(account)
|
|
||||||
end
|
|
||||||
|
|
||||||
def empty_portfolio
|
|
||||||
securities = portfolio_cache.get_securities
|
|
||||||
securities.each_with_object({}) { |security, hash| hash[security.id] = 0 }
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_starting_portfolio
|
|
||||||
empty_portfolio
|
|
||||||
end
|
|
||||||
|
|
||||||
def transform_portfolio(previous_portfolio, trade_entries, direction: :forward)
|
|
||||||
new_quantities = previous_portfolio.dup
|
|
||||||
|
|
||||||
trade_entries.each do |trade_entry|
|
|
||||||
trade = trade_entry.entryable
|
|
||||||
security_id = trade.security_id
|
|
||||||
qty_change = trade.qty
|
|
||||||
qty_change = qty_change * -1 if direction == :reverse
|
|
||||||
new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
|
|
||||||
end
|
|
||||||
|
|
||||||
new_quantities
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_holdings(portfolio, date, price_source: nil)
|
|
||||||
portfolio.map do |security_id, qty|
|
|
||||||
price = portfolio_cache.get_price(security_id, date, source: price_source)
|
|
||||||
|
|
||||||
if price.nil?
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
Holding.new(
|
|
||||||
account_id: account.id,
|
|
||||||
security_id: security_id,
|
|
||||||
date: date,
|
|
||||||
qty: qty,
|
|
||||||
price: price.price,
|
|
||||||
currency: price.currency,
|
|
||||||
amount: qty * price.price
|
|
||||||
)
|
|
||||||
end.compact
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,10 +1,12 @@
|
||||||
class Holding::ForwardCalculator < Holding::BaseCalculator
|
class Holding::ForwardCalculator
|
||||||
private
|
attr_reader :account
|
||||||
def portfolio_cache
|
|
||||||
@portfolio_cache ||= Holding::PortfolioCache.new(account)
|
|
||||||
end
|
|
||||||
|
|
||||||
def calculate_holdings
|
def initialize(account)
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate
|
||||||
|
Rails.logger.tagged("Holding::ForwardCalculator") do
|
||||||
current_portfolio = generate_starting_portfolio
|
current_portfolio = generate_starting_portfolio
|
||||||
next_portfolio = {}
|
next_portfolio = {}
|
||||||
holdings = []
|
holdings = []
|
||||||
|
@ -16,6 +18,55 @@ class Holding::ForwardCalculator < Holding::BaseCalculator
|
||||||
current_portfolio = next_portfolio
|
current_portfolio = next_portfolio
|
||||||
end
|
end
|
||||||
|
|
||||||
holdings
|
Holding.gapfill(holdings)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def portfolio_cache
|
||||||
|
@portfolio_cache ||= Holding::PortfolioCache.new(account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def empty_portfolio
|
||||||
|
securities = portfolio_cache.get_securities
|
||||||
|
securities.each_with_object({}) { |security, hash| hash[security.id] = 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_starting_portfolio
|
||||||
|
empty_portfolio
|
||||||
|
end
|
||||||
|
|
||||||
|
def transform_portfolio(previous_portfolio, trade_entries, direction: :forward)
|
||||||
|
new_quantities = previous_portfolio.dup
|
||||||
|
|
||||||
|
trade_entries.each do |trade_entry|
|
||||||
|
trade = trade_entry.entryable
|
||||||
|
security_id = trade.security_id
|
||||||
|
qty_change = trade.qty
|
||||||
|
qty_change = qty_change * -1 if direction == :reverse
|
||||||
|
new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
|
||||||
|
end
|
||||||
|
|
||||||
|
new_quantities
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_holdings(portfolio, date, price_source: nil)
|
||||||
|
portfolio.map do |security_id, qty|
|
||||||
|
price = portfolio_cache.get_price(security_id, date, source: price_source)
|
||||||
|
|
||||||
|
if price.nil?
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
Holding.new(
|
||||||
|
account_id: account.id,
|
||||||
|
security_id: security_id,
|
||||||
|
date: date,
|
||||||
|
qty: qty,
|
||||||
|
price: price.price,
|
||||||
|
currency: price.currency,
|
||||||
|
amount: qty * price.price
|
||||||
|
)
|
||||||
|
end.compact
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -83,9 +83,6 @@ class Holding::PortfolioCache
|
||||||
securities.each do |security|
|
securities.each do |security|
|
||||||
Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}"
|
Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}"
|
||||||
|
|
||||||
# Load prices from provider to DB
|
|
||||||
security.sync_provider_prices(start_date: account.start_date)
|
|
||||||
|
|
||||||
# High priority prices from DB (synced from provider)
|
# High priority prices from DB (synced from provider)
|
||||||
db_prices = security.prices.where(date: account.start_date..Date.current).map do |price|
|
db_prices = security.prices.where(date: account.start_date..Date.current).map do |price|
|
||||||
PriceWithPriority.new(
|
PriceWithPriority.new(
|
||||||
|
|
|
@ -1,4 +1,17 @@
|
||||||
class Holding::ReverseCalculator < Holding::BaseCalculator
|
class Holding::ReverseCalculator
|
||||||
|
attr_reader :account
|
||||||
|
|
||||||
|
def initialize(account)
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate
|
||||||
|
Rails.logger.tagged("Holding::ReverseCalculator") do
|
||||||
|
holdings = calculate_holdings
|
||||||
|
Holding.gapfill(holdings)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
# Reverse calculators will use the existing holdings as a source of security ids and prices
|
# Reverse calculators will use the existing holdings as a source of security ids and prices
|
||||||
# since it is common for a provider to supply "current day" holdings but not all the historical
|
# since it is common for a provider to supply "current day" holdings but not all the historical
|
||||||
|
@ -25,6 +38,11 @@ class Holding::ReverseCalculator < Holding::BaseCalculator
|
||||||
holdings
|
holdings
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def empty_portfolio
|
||||||
|
securities = portfolio_cache.get_securities
|
||||||
|
securities.each_with_object({}) { |security, hash| hash[security.id] = 0 }
|
||||||
|
end
|
||||||
|
|
||||||
# Since this is a reverse sync, we start with today's holdings
|
# Since this is a reverse sync, we start with today's holdings
|
||||||
def generate_starting_portfolio
|
def generate_starting_portfolio
|
||||||
holding_quantities = empty_portfolio
|
holding_quantities = empty_portfolio
|
||||||
|
@ -37,4 +55,38 @@ class Holding::ReverseCalculator < Holding::BaseCalculator
|
||||||
|
|
||||||
holding_quantities
|
holding_quantities
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def transform_portfolio(previous_portfolio, trade_entries, direction: :forward)
|
||||||
|
new_quantities = previous_portfolio.dup
|
||||||
|
|
||||||
|
trade_entries.each do |trade_entry|
|
||||||
|
trade = trade_entry.entryable
|
||||||
|
security_id = trade.security_id
|
||||||
|
qty_change = trade.qty
|
||||||
|
qty_change = qty_change * -1 if direction == :reverse
|
||||||
|
new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
|
||||||
|
end
|
||||||
|
|
||||||
|
new_quantities
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_holdings(portfolio, date, price_source: nil)
|
||||||
|
portfolio.map do |security_id, qty|
|
||||||
|
price = portfolio_cache.get_price(security_id, date, source: price_source)
|
||||||
|
|
||||||
|
if price.nil?
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
Holding.new(
|
||||||
|
account_id: account.id,
|
||||||
|
security_id: security_id,
|
||||||
|
date: date,
|
||||||
|
qty: qty,
|
||||||
|
price: price.price,
|
||||||
|
currency: price.currency,
|
||||||
|
amount: qty * price.price
|
||||||
|
)
|
||||||
|
end.compact
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -62,7 +62,7 @@ class Import < ApplicationRecord
|
||||||
def publish
|
def publish
|
||||||
import!
|
import!
|
||||||
|
|
||||||
family.sync
|
family.sync_later
|
||||||
|
|
||||||
update! status: :complete
|
update! status: :complete
|
||||||
rescue => error
|
rescue => error
|
||||||
|
|
196
app/models/market_data_syncer.rb
Normal file
196
app/models/market_data_syncer.rb
Normal file
|
@ -0,0 +1,196 @@
|
||||||
|
class MarketDataSyncer
|
||||||
|
DEFAULT_HISTORY_DAYS = 30
|
||||||
|
RATE_PROVIDER_NAME = :synth
|
||||||
|
PRICE_PROVIDER_NAME = :synth
|
||||||
|
|
||||||
|
MissingExchangeRateError = Class.new(StandardError)
|
||||||
|
InvalidExchangeRateDataError = Class.new(StandardError)
|
||||||
|
MissingSecurityPriceError = Class.new(StandardError)
|
||||||
|
InvalidSecurityPriceDataError = Class.new(StandardError)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def for(family: nil, account: nil)
|
||||||
|
new(family: family, account: account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Syncer can optionally be scoped. Otherwise, it syncs all user data
|
||||||
|
def initialize(family: nil, account: nil)
|
||||||
|
@family = family
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_all(full_history: false)
|
||||||
|
sync_exchange_rates(full_history: full_history)
|
||||||
|
sync_prices(full_history: full_history)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_exchange_rates(full_history: false)
|
||||||
|
unless rate_provider
|
||||||
|
Rails.logger.warn("No rate provider configured for MarketDataSyncer.sync_exchange_rates, skipping sync")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Finds distinct currency pairs
|
||||||
|
entry_pairs = entries_scope.joins(:account)
|
||||||
|
.where.not("entries.currency = accounts.currency")
|
||||||
|
.select("entries.currency as source, accounts.currency as target")
|
||||||
|
.distinct
|
||||||
|
|
||||||
|
# All accounts in currency not equal to the family currency require exchange rates to show a normalized historical graph
|
||||||
|
account_pairs = accounts_scope.joins(:family)
|
||||||
|
.where.not("families.currency = accounts.currency")
|
||||||
|
.select("accounts.currency as source, families.currency as target")
|
||||||
|
.distinct
|
||||||
|
|
||||||
|
pairs = (entry_pairs + account_pairs).uniq
|
||||||
|
|
||||||
|
pairs.each do |pair|
|
||||||
|
sync_exchange_rate(from: pair.source, to: pair.target, full_history: full_history)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_prices(full_history: false)
|
||||||
|
unless price_provider
|
||||||
|
Rails.logger.warn("No price provider configured for MarketDataSyncer.sync_prices, skipping sync")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
securities_scope.each do |security|
|
||||||
|
sync_security_price(security: security, full_history: full_history)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :family, :account
|
||||||
|
|
||||||
|
def accounts_scope
|
||||||
|
return Account.where(id: account.id) if account
|
||||||
|
return family.accounts if family
|
||||||
|
Account.all
|
||||||
|
end
|
||||||
|
|
||||||
|
def entries_scope
|
||||||
|
account&.entries || family&.entries || Entry.all
|
||||||
|
end
|
||||||
|
|
||||||
|
def securities_scope
|
||||||
|
if account
|
||||||
|
account.trades.joins(:security).where.not(securities: { exchange_operating_mic: nil })
|
||||||
|
elsif family
|
||||||
|
family.trades.joins(:security).where.not(securities: { exchange_operating_mic: nil })
|
||||||
|
else
|
||||||
|
Security.where.not(exchange_operating_mic: nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_security_price(security:, full_history:)
|
||||||
|
start_date = full_history ? find_oldest_required_price(security: security) : default_start_date
|
||||||
|
|
||||||
|
Rails.logger.info("Syncing security price for: #{security.ticker}, start_date: #{start_date}, end_date: #{end_date}")
|
||||||
|
|
||||||
|
fetched_prices = price_provider.fetch_security_prices(
|
||||||
|
security,
|
||||||
|
start_date: start_date,
|
||||||
|
end_date: end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
unless fetched_prices.success?
|
||||||
|
error = MissingSecurityPriceError.new(
|
||||||
|
"#{PRICE_PROVIDER_NAME} could not fetch security price for: #{security.ticker} between: #{start_date} and: #{Date.current}. Provider error: #{fetched_prices.error.message}"
|
||||||
|
)
|
||||||
|
|
||||||
|
Rails.logger.warn(error.message)
|
||||||
|
Sentry.capture_exception(error, level: :warning)
|
||||||
|
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
prices_for_upsert = fetched_prices.data.map do |price|
|
||||||
|
if price.security.nil? || price.date.nil? || price.price.nil? || price.currency.nil?
|
||||||
|
error = InvalidSecurityPriceDataError.new(
|
||||||
|
"#{PRICE_PROVIDER_NAME} returned invalid price data for security: #{security.ticker} on: #{price.date}. Price data: #{price.inspect}"
|
||||||
|
)
|
||||||
|
|
||||||
|
Rails.logger.warn(error.message)
|
||||||
|
Sentry.capture_exception(error, level: :warning)
|
||||||
|
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
security_id: price.security.id,
|
||||||
|
date: price.date,
|
||||||
|
price: price.price,
|
||||||
|
currency: price.currency
|
||||||
|
}
|
||||||
|
end.compact
|
||||||
|
|
||||||
|
Security::Price.upsert_all(
|
||||||
|
prices_for_upsert,
|
||||||
|
unique_by: %i[security_id date currency]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_exchange_rate(from:, to:, full_history:)
|
||||||
|
start_date = full_history ? find_oldest_required_rate(from_currency: from) : default_start_date
|
||||||
|
|
||||||
|
Rails.logger.info("Syncing exchange rate from: #{from}, to: #{to}, start_date: #{start_date}, end_date: #{end_date}")
|
||||||
|
|
||||||
|
fetched_rates = rate_provider.fetch_exchange_rates(
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
start_date: start_date,
|
||||||
|
end_date: end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
unless fetched_rates.success?
|
||||||
|
message = "#{RATE_PROVIDER_NAME} could not fetch exchange rate pair from: #{from} to: #{to} between: #{start_date} and: #{Date.current}. Provider error: #{fetched_rates.error.message}"
|
||||||
|
Rails.logger.warn(message)
|
||||||
|
Sentry.capture_exception(MissingExchangeRateError.new(message))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
rates_for_upsert = fetched_rates.data.map do |rate|
|
||||||
|
if rate.from.nil? || rate.to.nil? || rate.date.nil? || rate.rate.nil?
|
||||||
|
message = "#{RATE_PROVIDER_NAME} returned invalid rate data for pair from: #{from} to: #{to} on: #{rate.date}. Rate data: #{rate.inspect}"
|
||||||
|
Rails.logger.warn(message)
|
||||||
|
Sentry.capture_exception(InvalidExchangeRateDataError.new(message))
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
from_currency: rate.from,
|
||||||
|
to_currency: rate.to,
|
||||||
|
date: rate.date,
|
||||||
|
rate: rate.rate
|
||||||
|
}
|
||||||
|
end.compact
|
||||||
|
|
||||||
|
ExchangeRate.upsert_all(
|
||||||
|
rates_for_upsert,
|
||||||
|
unique_by: %i[from_currency to_currency date]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def rate_provider
|
||||||
|
Provider::Registry.for_concept(:exchange_rates).get_provider(RATE_PROVIDER_NAME)
|
||||||
|
end
|
||||||
|
|
||||||
|
def price_provider
|
||||||
|
Provider::Registry.for_concept(:securities).get_provider(PRICE_PROVIDER_NAME)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_oldest_required_rate(from_currency:)
|
||||||
|
entries_scope.where(currency: from_currency).minimum(:date) || default_start_date
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_start_date
|
||||||
|
DEFAULT_HISTORY_DAYS.days.ago.to_date
|
||||||
|
end
|
||||||
|
|
||||||
|
# Since we're querying market data from a US-based API, end date should always be today (EST)
|
||||||
|
def end_date
|
||||||
|
Date.current.in_time_zone("America/New_York").to_date
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,5 +1,5 @@
|
||||||
class PlaidItem < ApplicationRecord
|
class PlaidItem < ApplicationRecord
|
||||||
include Provided, Syncable
|
include Syncable
|
||||||
|
|
||||||
enum :plaid_region, { us: "us", eu: "eu" }
|
enum :plaid_region, { us: "us", eu: "eu" }
|
||||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||||
|
@ -22,39 +22,6 @@ class PlaidItem < ApplicationRecord
|
||||||
scope :ordered, -> { order(created_at: :desc) }
|
scope :ordered, -> { order(created_at: :desc) }
|
||||||
scope :needs_update, -> { where(status: :requires_update) }
|
scope :needs_update, -> { where(status: :requires_update) }
|
||||||
|
|
||||||
class << self
|
|
||||||
def create_from_public_token(token, item_name:, region:)
|
|
||||||
response = plaid_provider_for_region(region).exchange_public_token(token)
|
|
||||||
|
|
||||||
new_plaid_item = create!(
|
|
||||||
name: item_name,
|
|
||||||
plaid_id: response.item_id,
|
|
||||||
access_token: response.access_token,
|
|
||||||
plaid_region: region
|
|
||||||
)
|
|
||||||
|
|
||||||
new_plaid_item.sync_later
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def sync_data(sync, start_date: nil)
|
|
||||||
begin
|
|
||||||
Rails.logger.info("Fetching and loading Plaid data")
|
|
||||||
fetch_and_load_plaid_data(sync)
|
|
||||||
update!(status: :good) if requires_update?
|
|
||||||
|
|
||||||
# Schedule account syncs
|
|
||||||
accounts.each do |account|
|
|
||||||
account.sync_later(start_date: start_date, parent_sync: sync)
|
|
||||||
end
|
|
||||||
|
|
||||||
Rails.logger.info("Plaid data fetched and loaded")
|
|
||||||
rescue Plaid::ApiError => e
|
|
||||||
handle_plaid_error(e)
|
|
||||||
raise e
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_update_link_token(webhooks_url:, redirect_url:)
|
def get_update_link_token(webhooks_url:, redirect_url:)
|
||||||
begin
|
begin
|
||||||
family.get_link_token(
|
family.get_link_token(
|
||||||
|
@ -76,9 +43,8 @@ class PlaidItem < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_sync(sync)
|
def build_category_alias_matcher(user_categories)
|
||||||
auto_match_categories!
|
Provider::Plaid::CategoryAliasMatcher.new(user_categories)
|
||||||
family.broadcast_refresh
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy_later
|
def destroy_later
|
||||||
|
@ -86,6 +52,14 @@ class PlaidItem < ApplicationRecord
|
||||||
DestroyJob.perform_later(self)
|
DestroyJob.perform_later(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def syncing?
|
||||||
|
Sync.joins("LEFT JOIN accounts a ON a.id = syncs.syncable_id AND syncs.syncable_type = 'Account'")
|
||||||
|
.joins("LEFT JOIN plaid_accounts pa ON pa.id = a.plaid_account_id")
|
||||||
|
.where("syncs.syncable_id = ? OR pa.plaid_item_id = ?", id, id)
|
||||||
|
.incomplete
|
||||||
|
.exists?
|
||||||
|
end
|
||||||
|
|
||||||
def auto_match_categories!
|
def auto_match_categories!
|
||||||
if family.categories.none?
|
if family.categories.none?
|
||||||
family.categories.bootstrap!
|
family.categories.bootstrap!
|
||||||
|
@ -117,123 +91,11 @@ class PlaidItem < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def fetch_and_load_plaid_data(sync)
|
|
||||||
data = {}
|
|
||||||
|
|
||||||
# Log what we're about to fetch
|
|
||||||
Rails.logger.info "Starting Plaid data fetch (accounts, transactions, investments, liabilities)"
|
|
||||||
|
|
||||||
item = plaid_provider.get_item(access_token).item
|
|
||||||
update!(available_products: item.available_products, billed_products: item.billed_products)
|
|
||||||
|
|
||||||
# Institution details
|
|
||||||
if item.institution_id.present?
|
|
||||||
begin
|
|
||||||
Rails.logger.info "Fetching Plaid institution details for #{item.institution_id}"
|
|
||||||
institution = plaid_provider.get_institution(item.institution_id)
|
|
||||||
update!(
|
|
||||||
institution_id: item.institution_id,
|
|
||||||
institution_url: institution.institution.url,
|
|
||||||
institution_color: institution.institution.primary_color
|
|
||||||
)
|
|
||||||
rescue Plaid::ApiError => e
|
|
||||||
Rails.logger.warn "Failed to fetch Plaid institution details: #{e.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Accounts
|
|
||||||
fetched_accounts = plaid_provider.get_item_accounts(self).accounts
|
|
||||||
data[:accounts] = fetched_accounts || []
|
|
||||||
sync.update!(data: data)
|
|
||||||
Rails.logger.info "Processing Plaid accounts (count: #{fetched_accounts.size})"
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Transactions
|
|
||||||
fetched_transactions = safe_fetch_plaid_data(:get_item_transactions)
|
|
||||||
data[:transactions] = fetched_transactions || []
|
|
||||||
sync.update!(data: data)
|
|
||||||
|
|
||||||
if fetched_transactions
|
|
||||||
Rails.logger.info "Processing Plaid transactions (added: #{fetched_transactions.added.size}, modified: #{fetched_transactions.modified.size}, removed: #{fetched_transactions.removed.size})"
|
|
||||||
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
|
|
||||||
|
|
||||||
# Investments
|
|
||||||
fetched_investments = safe_fetch_plaid_data(:get_item_investments)
|
|
||||||
data[:investments] = fetched_investments || []
|
|
||||||
sync.update!(data: data)
|
|
||||||
|
|
||||||
if fetched_investments
|
|
||||||
Rails.logger.info "Processing Plaid investments (transactions: #{fetched_investments.transactions.size}, holdings: #{fetched_investments.holdings.size}, securities: #{fetched_investments.securities.size})"
|
|
||||||
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
|
|
||||||
|
|
||||||
# Liabilities
|
|
||||||
fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities)
|
|
||||||
data[:liabilities] = fetched_liabilities || []
|
|
||||||
sync.update!(data: data)
|
|
||||||
|
|
||||||
if fetched_liabilities
|
|
||||||
Rails.logger.info "Processing Plaid liabilities (credit: #{fetched_liabilities.credit&.size || 0}, mortgage: #{fetched_liabilities.mortgage&.size || 0}, student: #{fetched_liabilities.student&.size || 0})"
|
|
||||||
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
|
def remove_plaid_item
|
||||||
plaid_provider.remove_item(access_token)
|
plaid_provider.remove_item(access_token)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}")
|
Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_plaid_error(error)
|
|
||||||
error_body = JSON.parse(error.response_body)
|
|
||||||
|
|
||||||
if error_body["error_code"] == "ITEM_LOGIN_REQUIRED"
|
|
||||||
update!(status: :requires_update)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class PlaidConnectionLostError < StandardError; end
|
class PlaidConnectionLostError < StandardError; end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
module PlaidItem::Provided
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
class_methods do
|
|
||||||
def plaid_us_provider
|
|
||||||
Provider::Registry.get_provider(:plaid_us)
|
|
||||||
end
|
|
||||||
|
|
||||||
def plaid_eu_provider
|
|
||||||
Provider::Registry.get_provider(:plaid_eu)
|
|
||||||
end
|
|
||||||
|
|
||||||
def plaid_provider_for_region(region)
|
|
||||||
region.to_sym == :eu ? plaid_eu_provider : plaid_us_provider
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_category_alias_matcher(user_categories)
|
|
||||||
Provider::Plaid::CategoryAliasMatcher.new(user_categories)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def eu?
|
|
||||||
raise "eu? is not implemented for #{self.class.name}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def plaid_provider
|
|
||||||
eu? ? self.class.plaid_eu_provider : self.class.plaid_us_provider
|
|
||||||
end
|
|
||||||
end
|
|
22
app/models/plaid_item/sync_complete_event.rb
Normal file
22
app/models/plaid_item/sync_complete_event.rb
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
class PlaidItem::SyncCompleteEvent
|
||||||
|
attr_reader :plaid_item
|
||||||
|
|
||||||
|
def initialize(plaid_item)
|
||||||
|
@plaid_item = plaid_item
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast
|
||||||
|
plaid_item.accounts.each do |account|
|
||||||
|
account.broadcast_sync_complete
|
||||||
|
end
|
||||||
|
|
||||||
|
plaid_item.broadcast_replace_to(
|
||||||
|
plaid_item.family,
|
||||||
|
target: "plaid_item_#{plaid_item.id}",
|
||||||
|
partial: "plaid_items/plaid_item",
|
||||||
|
locals: { plaid_item: plaid_item }
|
||||||
|
)
|
||||||
|
|
||||||
|
plaid_item.family.broadcast_sync_complete
|
||||||
|
end
|
||||||
|
end
|
149
app/models/plaid_item/syncer.rb
Normal file
149
app/models/plaid_item/syncer.rb
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
class PlaidItem::Syncer
|
||||||
|
attr_reader :plaid_item
|
||||||
|
|
||||||
|
def initialize(plaid_item)
|
||||||
|
@plaid_item = plaid_item
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_sync(sync)
|
||||||
|
begin
|
||||||
|
Rails.logger.info("Fetching and loading Plaid data")
|
||||||
|
fetch_and_load_plaid_data
|
||||||
|
plaid_item.update!(status: :good) if plaid_item.requires_update?
|
||||||
|
|
||||||
|
plaid_item.accounts.each do |account|
|
||||||
|
account.sync_later(parent_sync: sync, window_start_date: sync.window_start_date, window_end_date: sync.window_end_date)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info("Plaid data fetched and loaded")
|
||||||
|
rescue Plaid::ApiError => e
|
||||||
|
handle_plaid_error(e)
|
||||||
|
raise e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_post_sync
|
||||||
|
plaid_item.auto_match_categories!
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def plaid
|
||||||
|
plaid_item.plaid_region == "eu" ? plaid_eu : plaid_us
|
||||||
|
end
|
||||||
|
|
||||||
|
def plaid_eu
|
||||||
|
@plaid_eu ||= Provider::Registry.get_provider(:plaid_eu)
|
||||||
|
end
|
||||||
|
|
||||||
|
def plaid_us
|
||||||
|
@plaid_us ||= Provider::Registry.get_provider(:plaid_us)
|
||||||
|
end
|
||||||
|
|
||||||
|
def safe_fetch_plaid_data(method)
|
||||||
|
begin
|
||||||
|
plaid.send(method, plaid_item)
|
||||||
|
rescue Plaid::ApiError => e
|
||||||
|
Rails.logger.warn("Error fetching #{method} for item #{plaid_item.id}: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_plaid_error(error)
|
||||||
|
error_body = JSON.parse(error.response_body)
|
||||||
|
|
||||||
|
if error_body["error_code"] == "ITEM_LOGIN_REQUIRED"
|
||||||
|
plaid_item.update!(status: :requires_update)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_and_load_plaid_data
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
# Log what we're about to fetch
|
||||||
|
Rails.logger.info "Starting Plaid data fetch (accounts, transactions, investments, liabilities)"
|
||||||
|
|
||||||
|
item = plaid.get_item(plaid_item.access_token).item
|
||||||
|
plaid_item.update!(available_products: item.available_products, billed_products: item.billed_products)
|
||||||
|
|
||||||
|
# Institution details
|
||||||
|
if item.institution_id.present?
|
||||||
|
begin
|
||||||
|
Rails.logger.info "Fetching Plaid institution details for #{item.institution_id}"
|
||||||
|
institution = plaid.get_institution(item.institution_id)
|
||||||
|
plaid_item.update!(
|
||||||
|
institution_id: item.institution_id,
|
||||||
|
institution_url: institution.institution.url,
|
||||||
|
institution_color: institution.institution.primary_color
|
||||||
|
)
|
||||||
|
rescue Plaid::ApiError => e
|
||||||
|
Rails.logger.warn "Failed to fetch Plaid institution details: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Accounts
|
||||||
|
fetched_accounts = plaid.get_item_accounts(plaid_item).accounts
|
||||||
|
data[:accounts] = fetched_accounts || []
|
||||||
|
Rails.logger.info "Processing Plaid accounts (count: #{fetched_accounts.size})"
|
||||||
|
|
||||||
|
internal_plaid_accounts = fetched_accounts.map do |account|
|
||||||
|
internal_plaid_account = plaid_item.plaid_accounts.find_or_create_from_plaid_data!(account, plaid_item.family)
|
||||||
|
internal_plaid_account.sync_account_data!(account)
|
||||||
|
internal_plaid_account
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transactions
|
||||||
|
fetched_transactions = safe_fetch_plaid_data(:get_item_transactions)
|
||||||
|
data[:transactions] = fetched_transactions || []
|
||||||
|
|
||||||
|
if fetched_transactions
|
||||||
|
Rails.logger.info "Processing Plaid transactions (added: #{fetched_transactions.added.size}, modified: #{fetched_transactions.modified.size}, removed: #{fetched_transactions.removed.size})"
|
||||||
|
PlaidItem.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
|
||||||
|
|
||||||
|
plaid_item.update!(next_cursor: fetched_transactions.cursor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Investments
|
||||||
|
fetched_investments = safe_fetch_plaid_data(:get_item_investments)
|
||||||
|
data[:investments] = fetched_investments || []
|
||||||
|
|
||||||
|
if fetched_investments
|
||||||
|
Rails.logger.info "Processing Plaid investments (transactions: #{fetched_investments.transactions.size}, holdings: #{fetched_investments.holdings.size}, securities: #{fetched_investments.securities.size})"
|
||||||
|
PlaidItem.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
|
||||||
|
|
||||||
|
# Liabilities
|
||||||
|
fetched_liabilities = safe_fetch_plaid_data(:get_item_liabilities)
|
||||||
|
data[:liabilities] = fetched_liabilities || []
|
||||||
|
|
||||||
|
if fetched_liabilities
|
||||||
|
Rails.logger.info "Processing Plaid liabilities (credit: #{fetched_liabilities.credit&.size || 0}, mortgage: #{fetched_liabilities.mortgage&.size || 0}, student: #{fetched_liabilities.student&.size || 0})"
|
||||||
|
PlaidItem.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
|
||||||
|
end
|
|
@ -36,8 +36,6 @@ class Provider
|
||||||
default_error_transformer(error)
|
default_error_transformer(error)
|
||||||
end
|
end
|
||||||
|
|
||||||
Sentry.capture_exception(transformed_error)
|
|
||||||
|
|
||||||
Response.new(
|
Response.new(
|
||||||
success?: false,
|
success?: false,
|
||||||
data: nil,
|
data: nil,
|
||||||
|
|
|
@ -28,44 +28,6 @@ module Security::Provided
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_provider_prices(start_date:, end_date: Date.current)
|
|
||||||
unless has_prices?
|
|
||||||
Rails.logger.warn("Security id=#{id} ticker=#{ticker} is not known by provider, skipping price sync")
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
unless provider.present?
|
|
||||||
Rails.logger.warn("No security provider configured, cannot sync prices for id=#{id} ticker=#{ticker}")
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
response = provider.fetch_security_prices(self, start_date: start_date, end_date: end_date)
|
|
||||||
|
|
||||||
unless response.success?
|
|
||||||
Rails.logger.error("Provider error for sync_provider_prices with id=#{id} ticker=#{ticker}: #{response.error}")
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
fetched_prices = response.data.map do |price|
|
|
||||||
{
|
|
||||||
security_id: price.security.id,
|
|
||||||
date: price.date,
|
|
||||||
price: price.price,
|
|
||||||
currency: price.currency
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
valid_prices = fetched_prices.reject do |price|
|
|
||||||
is_invalid = price[:date].nil? || price[:price].nil? || price[:currency].nil?
|
|
||||||
if is_invalid
|
|
||||||
Rails.logger.warn("Invalid price data for security_id=#{id}: Missing required fields in price record: #{price.inspect}")
|
|
||||||
end
|
|
||||||
is_invalid
|
|
||||||
end
|
|
||||||
|
|
||||||
Security::Price.upsert_all(valid_prices, unique_by: %i[security_id date currency])
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_or_fetch_price(date: Date.current, cache: true)
|
def find_or_fetch_price(date: Date.current, cache: true)
|
||||||
price = prices.find_by(date: date)
|
price = prices.find_by(date: date)
|
||||||
|
|
||||||
|
|
|
@ -9,4 +9,14 @@ class Session < ApplicationRecord
|
||||||
self.user_agent = Current.user_agent
|
self.user_agent = Current.user_agent
|
||||||
self.ip_address = Current.ip_address
|
self.ip_address = Current.ip_address
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_preferred_tab(tab_key)
|
||||||
|
data.dig("tab_preferences", tab_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_preferred_tab(tab_key, tab_value)
|
||||||
|
data["tab_preferences"] ||= {}
|
||||||
|
data["tab_preferences"][tab_key] = tab_value
|
||||||
|
save!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,6 +17,7 @@ class Subscription < ApplicationRecord
|
||||||
|
|
||||||
validates :stripe_id, presence: true, if: :active?
|
validates :stripe_id, presence: true, if: :active?
|
||||||
validates :trial_ends_at, presence: true, if: :trialing?
|
validates :trial_ends_at, presence: true, if: :trialing?
|
||||||
|
validates :family_id, uniqueness: true
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def new_trial_ends_at
|
def new_trial_ends_at
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
class Sync < ApplicationRecord
|
class Sync < ApplicationRecord
|
||||||
|
include AASM
|
||||||
|
|
||||||
Error = Class.new(StandardError)
|
Error = Class.new(StandardError)
|
||||||
|
|
||||||
belongs_to :syncable, polymorphic: true
|
belongs_to :syncable, polymorphic: true
|
||||||
|
@ -6,12 +8,31 @@ class Sync < ApplicationRecord
|
||||||
belongs_to :parent, class_name: "Sync", optional: true
|
belongs_to :parent, class_name: "Sync", optional: true
|
||||||
has_many :children, class_name: "Sync", foreign_key: :parent_id, dependent: :destroy
|
has_many :children, class_name: "Sync", foreign_key: :parent_id, dependent: :destroy
|
||||||
|
|
||||||
enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" }
|
|
||||||
|
|
||||||
scope :ordered, -> { order(created_at: :desc) }
|
scope :ordered, -> { order(created_at: :desc) }
|
||||||
|
scope :incomplete, -> { where(status: [ :pending, :syncing ]) }
|
||||||
|
|
||||||
def child?
|
validate :window_valid
|
||||||
parent_id.present?
|
|
||||||
|
# Sync state machine
|
||||||
|
aasm column: :status, timestamps: true do
|
||||||
|
state :pending, initial: true
|
||||||
|
state :syncing
|
||||||
|
state :completed
|
||||||
|
state :failed
|
||||||
|
|
||||||
|
after_all_transitions :log_status_change
|
||||||
|
|
||||||
|
event :start, after_commit: :report_warnings do
|
||||||
|
transitions from: :pending, to: :syncing
|
||||||
|
end
|
||||||
|
|
||||||
|
event :complete do
|
||||||
|
transitions from: :syncing, to: :completed
|
||||||
|
end
|
||||||
|
|
||||||
|
event :fail do
|
||||||
|
transitions from: :syncing, to: :failed
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
|
@ -19,43 +40,83 @@ class Sync < ApplicationRecord
|
||||||
start!
|
start!
|
||||||
|
|
||||||
begin
|
begin
|
||||||
syncable.sync_data(self, start_date: start_date)
|
syncable.perform_sync(self)
|
||||||
|
rescue => e
|
||||||
complete!
|
fail!
|
||||||
Rails.logger.info("Sync completed, starting post-sync")
|
update(error: e.message)
|
||||||
syncable.post_sync(self)
|
report_error(e)
|
||||||
Rails.logger.info("Post-sync completed")
|
ensure
|
||||||
rescue StandardError => error
|
finalize_if_all_children_finalized
|
||||||
fail! error, report_error: true
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
# Finalizes the current sync AND parent (if it exists)
|
||||||
def start!
|
def finalize_if_all_children_finalized
|
||||||
Rails.logger.info("Starting sync")
|
Sync.transaction do
|
||||||
update! status: :syncing
|
lock!
|
||||||
end
|
|
||||||
|
|
||||||
def complete!
|
# If this is the "parent" and there are still children running, don't finalize.
|
||||||
Rails.logger.info("Sync completed")
|
return unless all_children_finalized?
|
||||||
update! status: :completed, last_ran_at: Time.current
|
|
||||||
end
|
|
||||||
|
|
||||||
def fail!(error, report_error: false)
|
if syncing?
|
||||||
Rails.logger.error("Sync failed: #{error.message}")
|
if has_failed_children?
|
||||||
|
fail!
|
||||||
if report_error
|
else
|
||||||
Sentry.capture_exception(error) do |scope|
|
complete!
|
||||||
scope.set_context("sync", { id: id, syncable_type: syncable_type, syncable_id: syncable_id })
|
|
||||||
scope.set_tags(sync_id: id)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
update!(
|
# If we make it here, the sync is finalized. Run post-sync, regardless of failure/success.
|
||||||
status: :failed,
|
perform_post_sync
|
||||||
error: error.message,
|
end
|
||||||
last_ran_at: Time.current
|
|
||||||
)
|
# If this sync has a parent, try to finalize it so the child status propagates up the chain.
|
||||||
|
parent&.finalize_if_all_children_finalized
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def log_status_change
|
||||||
|
Rails.logger.info("changing from #{aasm.from_state} to #{aasm.to_state} (event: #{aasm.current_event})")
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_failed_children?
|
||||||
|
children.failed.any?
|
||||||
|
end
|
||||||
|
|
||||||
|
def all_children_finalized?
|
||||||
|
children.incomplete.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_post_sync
|
||||||
|
Rails.logger.info("Performing post-sync for #{syncable_type} (#{syncable.id})")
|
||||||
|
syncable.perform_post_sync
|
||||||
|
syncable.broadcast_sync_complete
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error("Error performing post-sync for #{syncable_type} (#{syncable.id}): #{e.message}")
|
||||||
|
report_error(e)
|
||||||
|
end
|
||||||
|
|
||||||
|
def report_error(error)
|
||||||
|
Sentry.capture_exception(error) do |scope|
|
||||||
|
scope.set_tags(sync_id: id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def report_warnings
|
||||||
|
todays_sync_count = syncable.syncs.where(created_at: Date.current.all_day).count
|
||||||
|
|
||||||
|
if todays_sync_count > 10
|
||||||
|
Sentry.capture_exception(
|
||||||
|
Error.new("#{syncable_type} (#{syncable.id}) has exceeded 10 syncs today (count: #{todays_sync_count})"),
|
||||||
|
level: :warning
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def window_valid
|
||||||
|
if window_start_date && window_end_date && window_start_date > window_end_date
|
||||||
|
errors.add(:window_end_date, "must be greater than window_start_date")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -30,9 +30,13 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-8">
|
<div class="flex items-center gap-8">
|
||||||
<p class="text-sm font-medium <%= account.is_active ? "text-primary" : "text-subdued" %>">
|
<% if account.syncing? %>
|
||||||
<%= format_money account.balance_money %>
|
<div class="w-16 h-6 bg-loader rounded-full animate-pulse"></div>
|
||||||
</p>
|
<% else %>
|
||||||
|
<p class="text-sm font-medium <%= account.is_active ? "text-primary" : "text-subdued" %>">
|
||||||
|
<%= format_money account.balance_money %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% unless account.scheduled_for_deletion? %>
|
<% unless account.scheduled_for_deletion? %>
|
||||||
<%= styled_form_with model: account, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %>
|
<%= styled_form_with model: account, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<%# locals: (family:, active_account_group_tab:) %>
|
<%# locals: (family:, active_tab:, mobile: false) %>
|
||||||
|
|
||||||
<div>
|
<div id="account-sidebar-tabs">
|
||||||
<% if family.missing_data_provider? %>
|
<% if family.missing_data_provider? %>
|
||||||
<details class="group bg-yellow-tint-10 rounded-lg p-2 text-yellow-600 mb-3 text-xs">
|
<details class="group bg-yellow-tint-10 rounded-lg p-2 text-yellow-600 mb-3 text-xs">
|
||||||
<summary class="flex items-center justify-between gap-2">
|
<summary class="flex items-center justify-between gap-2">
|
||||||
|
@ -21,73 +21,71 @@
|
||||||
</details>
|
</details>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div data-controller="sidebar-tabs">
|
<%= render TabsComponent.new(active_tab: active_tab, session_key: "account_sidebar_tab", testid: "account-sidebar-tabs") do |tabs| %>
|
||||||
<%= render TabsComponent.new(active_tab: active_account_group_tab, url_param_key: "account_group_tab", testid: "account-sidebar-tabs") do |tabs| %>
|
<% tabs.with_nav do |nav| %>
|
||||||
<% tabs.with_nav do |nav| %>
|
<% nav.with_btn(id: "asset", label: "Assets") %>
|
||||||
<% nav.with_btn(id: "assets", label: "Assets") %>
|
<% nav.with_btn(id: "liability", label: "Debts") %>
|
||||||
<% nav.with_btn(id: "debts", label: "Debts") %>
|
<% nav.with_btn(id: "all", label: "All") %>
|
||||||
<% nav.with_btn(id: "all", label: "All") %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% tabs.with_panel(tab_id: "assets") do %>
|
<% tabs.with_panel(tab_id: "asset") do %>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<%= render LinkComponent.new(
|
<%= render LinkComponent.new(
|
||||||
text: "New asset",
|
text: "New asset",
|
||||||
variant: "ghost",
|
|
||||||
href: new_account_path(step: "method_select", classification: "asset"),
|
|
||||||
icon: "plus",
|
|
||||||
frame: :modal,
|
|
||||||
full_width: true,
|
|
||||||
class: "justify-start"
|
|
||||||
) %>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<% family.balance_sheet.account_groups("asset").each do |group| %>
|
|
||||||
<%= render "accounts/accountable_group", account_group: group %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% tabs.with_panel(tab_id: "debts") do %>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<%= render LinkComponent.new(
|
|
||||||
text: "New debt",
|
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
href: new_account_path(step: "method_select", classification: "liability"),
|
href: new_account_path(step: "method_select", classification: "asset"),
|
||||||
icon: "plus",
|
icon: "plus",
|
||||||
frame: :modal,
|
frame: :modal,
|
||||||
full_width: true,
|
|
||||||
class: "justify-start"
|
|
||||||
) %>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<% family.balance_sheet.account_groups("liability").each do |group| %>
|
|
||||||
<%= render "accounts/accountable_group", account_group: group %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% tabs.with_panel(tab_id: "all") do %>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<%= render LinkComponent.new(
|
|
||||||
text: "New account",
|
|
||||||
variant: "ghost",
|
|
||||||
full_width: true,
|
full_width: true,
|
||||||
href: new_account_path(step: "method_select"),
|
class: "justify-start"
|
||||||
icon: "plus",
|
) %>
|
||||||
frame: :modal,
|
|
||||||
class: "justify-start"
|
|
||||||
) %>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<% family.balance_sheet.account_groups.each do |group| %>
|
<% family.balance_sheet.account_groups("asset").each do |group| %>
|
||||||
<%= render "accounts/accountable_group", account_group: group %>
|
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
|
||||||
|
<% tabs.with_panel(tab_id: "liability") do %>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<%= render LinkComponent.new(
|
||||||
|
text: "New debt",
|
||||||
|
variant: "ghost",
|
||||||
|
href: new_account_path(step: "method_select", classification: "liability"),
|
||||||
|
icon: "plus",
|
||||||
|
frame: :modal,
|
||||||
|
full_width: true,
|
||||||
|
class: "justify-start"
|
||||||
|
) %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<% family.balance_sheet.account_groups("liability").each do |group| %>
|
||||||
|
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% tabs.with_panel(tab_id: "all") do %>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<%= render LinkComponent.new(
|
||||||
|
text: "New account",
|
||||||
|
variant: "ghost",
|
||||||
|
full_width: true,
|
||||||
|
href: new_account_path(step: "method_select"),
|
||||||
|
icon: "plus",
|
||||||
|
frame: :modal,
|
||||||
|
class: "justify-start"
|
||||||
|
) %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<% family.balance_sheet.account_groups.each do |group| %>
|
||||||
|
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,49 +1,69 @@
|
||||||
<%# locals: (account_group:) %>
|
<%# locals: (account_group:, mobile: false, open: nil, **args) %>
|
||||||
|
|
||||||
<%= render DisclosureComponent.new(title: account_group.name, align: :left, open: account_group.accounts.any? { |account| page_active?(account_path(account)) }) do |disclosure| %>
|
<div id="<%= mobile ? "mobile_#{account_group.id}" : account_group.id %>">
|
||||||
<% disclosure.with_summary_content do %>
|
<% is_open = open.nil? ? account_group.accounts.any? { |account| page_active?(account_path(account)) } : open %>
|
||||||
<div class="ml-auto text-right grow">
|
<%= render DisclosureComponent.new(title: account_group.name, align: :left, open: is_open) do |disclosure| %>
|
||||||
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
|
<% disclosure.with_summary_content do %>
|
||||||
|
<div class="ml-auto text-right grow">
|
||||||
|
<% if account_group.syncing? %>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="h-5 w-24 rounded ml-auto bg-loader"></div>
|
||||||
|
<div class="flex items-center w-8 h-4 ml-auto">
|
||||||
|
<div class="w-6 h-px bg-loader"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
|
||||||
|
<%= turbo_frame_tag "#{account_group.key}_sparkline", src: accountable_sparkline_path(account_group.key), loading: "lazy" do %>
|
||||||
|
<div class="flex items-center w-8 h-4 ml-auto">
|
||||||
|
<div class="w-6 h-px bg-loader"></div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= turbo_frame_tag "#{account_group.key}_sparkline", src: accountable_sparkline_path(account_group.key), loading: "lazy" do %>
|
<div class="space-y-1">
|
||||||
<div class="flex items-center w-8 h-4 ml-auto">
|
<% account_group.accounts.each do |account| %>
|
||||||
<div class="w-6 h-px bg-surface-inset"></div>
|
<%= link_to account_path(account),
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
<% account_group.accounts.each do |account| %>
|
|
||||||
<%= link_to account_path(account),
|
|
||||||
class: class_names(
|
class: class_names(
|
||||||
"block flex items-center gap-2 px-3 py-2 rounded-lg",
|
"block flex items-center gap-2 px-3 py-2 rounded-lg",
|
||||||
page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover"
|
page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover"
|
||||||
),
|
),
|
||||||
data: { sidebar_tabs_target: "account", action: "click->sidebar-tabs#select" },
|
|
||||||
title: account.name do %>
|
title: account.name do %>
|
||||||
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
|
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
|
||||||
|
|
||||||
<div class="min-w-0 grow">
|
<div class="min-w-0 grow">
|
||||||
<%= tag.p account.name, class: "text-sm text-primary font-medium mb-0.5 truncate" %>
|
<%= tag.p account.name, class: "text-sm text-primary font-medium mb-0.5 truncate" %>
|
||||||
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
|
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-auto text-right grow h-10">
|
<% if account.syncing? %>
|
||||||
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
|
<div class="ml-auto text-right grow h-10">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="h-5 w-24 bg-loader rounded ml-auto"></div>
|
||||||
|
<div class="flex items-center w-8 h-4 ml-auto">
|
||||||
|
<div class="w-6 h-px bg-loader"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="ml-auto text-right grow h-10">
|
||||||
|
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %>
|
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %>
|
||||||
<div class="flex items-center w-8 h-5 ml-auto">
|
<div class="flex items-center w-8 h-4 ml-auto">
|
||||||
<div class="w-6 h-px bg-surface-inset"></div>
|
<div class="w-6 h-px bg-loader"></div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-2">
|
<div class="my-2">
|
||||||
<%= render LinkComponent.new(
|
<%= render LinkComponent.new(
|
||||||
href: new_polymorphic_path(account_group.key, step: "method_select"),
|
href: new_polymorphic_path(account_group.key, step: "method_select"),
|
||||||
text: "New #{account_group.name.downcase.singularize}",
|
text: "New #{account_group.name.downcase.singularize}",
|
||||||
icon: "plus",
|
icon: "plus",
|
||||||
|
@ -52,5 +72,6 @@
|
||||||
frame: :modal,
|
frame: :modal,
|
||||||
class: "justify-start"
|
class: "justify-start"
|
||||||
) %>
|
) %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
<div class="h-10">
|
<div class="px-4">
|
||||||
|
<div class="bg-loader rounded-md h-5 w-32"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-64 flex items-center justify-center">
|
|
||||||
<p class="text-secondary animate-pulse text-sm">Loading...</p>
|
<div class="p-4 h-60 flex items-center justify-center">
|
||||||
|
<div class="bg-loader rounded-md h-full w-full"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,21 +2,25 @@
|
||||||
<% trend = series.trend %>
|
<% trend = series.trend %>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
|
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
|
||||||
<div class="px-4">
|
<% if @account.syncing? %>
|
||||||
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %>
|
<%= render "accounts/chart_loader" %>
|
||||||
</div>
|
<% else %>
|
||||||
|
<div class="px-4">
|
||||||
|
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="h-64 pb-4">
|
<div class="h-64 pb-4">
|
||||||
<% if series.any? %>
|
<% if series.any? %>
|
||||||
<div
|
<div
|
||||||
id="lineChart"
|
id="lineChart"
|
||||||
class="w-full h-full"
|
class="w-full h-full"
|
||||||
data-controller="time-series-chart"
|
data-controller="time-series-chart"
|
||||||
data-time-series-chart-data-value="<%= series.to_json %>"></div>
|
data-time-series-chart-data-value="<%= series.to_json %>"></div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="w-full h-full flex items-center justify-center">
|
<div class="w-full h-full flex items-center justify-center">
|
||||||
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
|
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -2,17 +2,6 @@
|
||||||
<h1 class="text-xl"><%= t(".accounts") %></h1>
|
<h1 class="text-xl"><%= t(".accounts") %></h1>
|
||||||
<div class="flex items-center gap-5">
|
<div class="flex items-center gap-5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<% if Rails.env.development? %>
|
|
||||||
<%= render ButtonComponent.new(
|
|
||||||
text: "Sync all",
|
|
||||||
href: sync_all_accounts_path,
|
|
||||||
method: :post,
|
|
||||||
variant: "outline",
|
|
||||||
disabled: Current.family.syncing?,
|
|
||||||
icon: "refresh-cw",
|
|
||||||
) %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= render LinkComponent.new(
|
<%= render LinkComponent.new(
|
||||||
text: "New account",
|
text: "New account",
|
||||||
href: new_account_path(return_to: accounts_path),
|
href: new_account_path(return_to: accounts_path),
|
||||||
|
|
|
@ -6,9 +6,12 @@
|
||||||
<p><%= Accountable.from_type(group).display_name %></p>
|
<p><%= Accountable.from_type(group).display_name %></p>
|
||||||
<span class="text-subdued mx-2">·</span>
|
<span class="text-subdued mx-2">·</span>
|
||||||
<p><%= accounts.count %></p>
|
<p><%= accounts.count %></p>
|
||||||
<p class="ml-auto"><%= totals_by_currency(collection: accounts, money_method: :balance_money) %></p>
|
|
||||||
|
<% unless accounts.any?(&:syncing?) %>
|
||||||
|
<p class="ml-auto"><%= totals_by_currency(collection: accounts, money_method: :balance_money) %></p>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-container">
|
<div class="bg-container rounded-md">
|
||||||
<% accounts.each do |account| %>
|
<% accounts.each do |account| %>
|
||||||
<%= render account %>
|
<%= render account %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,17 +1,24 @@
|
||||||
<%# locals: (account:, title: nil, tooltip: nil, chart_view: nil, **args) %>
|
<%# locals: (account:, tooltip: nil, chart_view: nil, **args) %>
|
||||||
|
|
||||||
<% period = @period || Period.last_30_days %>
|
<% period = @period || Period.last_30_days %>
|
||||||
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
|
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
|
||||||
|
|
||||||
<div id="<%= dom_id(account, :chart) %>" class="bg-container shadow-xs rounded-xl border border-alpha-black-25 rounded-lg space-y-2">
|
<div id="<%= dom_id(account, :chart) %>" class="bg-container shadow-border-xs rounded-xl space-y-2">
|
||||||
<div class="flex justify-between flex-col-reverse lg:flex-row gap-2 px-4 pt-4 mb-2">
|
<div class="flex justify-between flex-col-reverse lg:flex-row gap-2 px-4 pt-4 mb-2">
|
||||||
<div class="space-y-2 w-full">
|
<div class="space-y-2 w-full">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<%= tag.p title || default_value_title, class: "text-sm font-medium text-secondary" %>
|
<%= tag.p account.investment? ? "Total value" : default_value_title, class: "text-sm font-medium text-secondary" %>
|
||||||
<%= tooltip %>
|
|
||||||
|
<% if !account.syncing? && account.investment? %>
|
||||||
|
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: account.balance_money - account.cash_balance_money, cash: account.cash_balance_money %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
|
<% if account.syncing? %>
|
||||||
|
<div class="bg-loader rounded-md h-7 w-20"></div>
|
||||||
|
<% else %>
|
||||||
|
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<%# locals: (account:, header: nil, chart: nil, tabs: nil) %>
|
<%# locals: (account:, header: nil, chart: nil, chart_view: nil, tabs: nil) %>
|
||||||
|
|
||||||
<%= turbo_stream_from account %>
|
<%= turbo_stream_from account %>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(account) do %>
|
<%= turbo_frame_tag dom_id(account, :container) do %>
|
||||||
<%= tag.div class: "space-y-4 pb-32" do %>
|
<%= tag.div class: "space-y-4 pb-32" do %>
|
||||||
<% if header.present? %>
|
<% if header.present? %>
|
||||||
<%= header %>
|
<%= header %>
|
||||||
|
@ -13,7 +13,7 @@
|
||||||
<% if chart.present? %>
|
<% if chart.present? %>
|
||||||
<%= chart %>
|
<%= chart %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= render "accounts/show/chart", account: account %>
|
<%= render "accounts/show/chart", account: account, chart_view: chart_view %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="min-h-[800px]" data-testid="account-details">
|
<div class="min-h-[800px]" data-testid="account-details">
|
||||||
|
|
|
@ -2,13 +2,13 @@
|
||||||
<div class="flex flex-col relative" data-controller="list-filter">
|
<div class="flex flex-col relative" data-controller="list-filter">
|
||||||
<div class="grow p-1.5">
|
<div class="grow p-1.5">
|
||||||
<div class="relative flex items-center bg-container border border-secondary rounded-lg">
|
<div class="relative flex items-center bg-container border border-secondary rounded-lg">
|
||||||
<input
|
<input
|
||||||
placeholder="<%= t(".search_placeholder") %>"
|
placeholder="<%= t(".search_placeholder") %>"
|
||||||
autocomplete="nope"
|
autocomplete="nope"
|
||||||
type="search"
|
type="search"
|
||||||
class="bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
|
class="bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
|
||||||
data-list-filter-target="input"
|
data-list-filter-target="input"
|
||||||
data-action="list-filter#filter" />
|
data-action="list-filter#filter">
|
||||||
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
|
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -19,8 +19,13 @@
|
||||||
|
|
||||||
<div class="col-span-2 flex justify-end items-center gap-2">
|
<div class="col-span-2 flex justify-end items-center gap-2">
|
||||||
<% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %>
|
<% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %>
|
||||||
<%= render "shared/progress_circle", progress: cash_weight %>
|
|
||||||
<%= tag.p number_to_percentage(cash_weight, precision: 1) %>
|
<% if account.syncing? %>
|
||||||
|
<div class="w-16 h-6 bg-loader rounded-full"></div>
|
||||||
|
<% else %>
|
||||||
|
<%= render "shared/progress_circle", progress: cash_weight %>
|
||||||
|
<%= tag.p number_to_percentage(cash_weight, precision: 1) %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-right">
|
<div class="col-span-2 text-right">
|
||||||
|
@ -28,7 +33,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-right">
|
<div class="col-span-2 text-right">
|
||||||
<%= tag.p format_money account.cash_balance_money %>
|
<% if account.syncing? %>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<div class="w-16 h-6 bg-loader rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<%= tag.p format_money account.cash_balance_money %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-right">
|
<div class="col-span-2 text-right">
|
||||||
|
|
|
@ -17,7 +17,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 flex justify-end items-center gap-2">
|
<div class="col-span-2 flex justify-end items-center gap-2">
|
||||||
<% if holding.weight %>
|
<% if holding.account.syncing? %>
|
||||||
|
<div class="w-16 h-6 bg-loader rounded-full"></div>
|
||||||
|
<% elsif holding.weight %>
|
||||||
<%= render "shared/progress_circle", progress: holding.weight %>
|
<%= render "shared/progress_circle", progress: holding.weight %>
|
||||||
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
|
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
@ -26,21 +28,39 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-right">
|
<div class="col-span-2 text-right">
|
||||||
<%= tag.p format_money holding.avg_cost %>
|
<% if holding.account.syncing? %>
|
||||||
<%= tag.p t(".per_share"), class: "font-normal text-secondary" %>
|
<div class="flex justify-end">
|
||||||
</div>
|
<div class="w-16 h-6 bg-loader rounded-full"></div>
|
||||||
|
</div>
|
||||||
<div class="col-span-2 text-right">
|
|
||||||
<% if holding.amount_money %>
|
|
||||||
<%= tag.p format_money holding.amount_money %>
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= tag.p "--", class: "text-secondary" %>
|
<%= tag.p format_money holding.avg_cost %>
|
||||||
|
<%= tag.p t(".per_share"), class: "font-normal text-secondary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-secondary" %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 text-right">
|
<div class="col-span-2 text-right">
|
||||||
<% if holding.trend %>
|
<% if holding.account.syncing? %>
|
||||||
|
<div class="flex flex-col gap-2 items-end">
|
||||||
|
<div class="w-16 h-4 bg-loader rounded-full"></div>
|
||||||
|
<div class="w-16 h-2 bg-loader rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<% if holding.amount_money %>
|
||||||
|
<%= tag.p format_money holding.amount_money %>
|
||||||
|
<% else %>
|
||||||
|
<%= tag.p "--", class: "text-secondary" %>
|
||||||
|
<% end %>
|
||||||
|
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-secondary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-2 text-right">
|
||||||
|
<% if holding.account.syncing? %>
|
||||||
|
<div class="flex flex-col gap-2 items-end">
|
||||||
|
<div class="w-16 h-4 bg-loader rounded-full"></div>
|
||||||
|
<div class="w-16 h-2 bg-loader rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<% elsif holding.trend %>
|
||||||
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
|
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
|
||||||
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
|
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|
|
@ -1,25 +1,7 @@
|
||||||
<%= turbo_stream_from @account %>
|
<%= render "accounts/show/template",
|
||||||
|
account: @account,
|
||||||
<%= turbo_frame_tag dom_id(@account) do %>
|
chart_view: @chart_view,
|
||||||
<%= tag.div class: "space-y-4" do %>
|
tabs: render("accounts/show/tabs", account: @account, tabs: [
|
||||||
<%= render "accounts/show/header", account: @account %>
|
{ key: "activity", contents: render("accounts/show/activity", account: @account) },
|
||||||
|
{ key: "holdings", contents: render("investments/holdings_tab", account: @account) },
|
||||||
<%= render "accounts/show/chart",
|
]) %>
|
||||||
account: @account,
|
|
||||||
title: t(".chart_title"),
|
|
||||||
chart_view: @chart_view,
|
|
||||||
tooltip: render(
|
|
||||||
"investments/value_tooltip",
|
|
||||||
balance: @account.balance_money,
|
|
||||||
holdings: @account.balance_money - @account.cash_balance_money,
|
|
||||||
cash: @account.cash_balance_money
|
|
||||||
) %>
|
|
||||||
|
|
||||||
<div class="min-h-[800px]">
|
|
||||||
<%= render "accounts/show/tabs", account: @account, tabs: [
|
|
||||||
{ key: "activity", contents: render("accounts/show/activity", account: @account) },
|
|
||||||
{ key: "holdings", contents: render("investments/holdings_tab", account: @account) },
|
|
||||||
] %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
|
|
|
@ -26,7 +26,8 @@
|
||||||
<%= render(
|
<%= render(
|
||||||
"accounts/account_sidebar_tabs",
|
"accounts/account_sidebar_tabs",
|
||||||
family: Current.family,
|
family: Current.family,
|
||||||
active_account_group_tab: params[:account_group_tab] || "assets"
|
active_tab: @account_group_tab,
|
||||||
|
mobile: true
|
||||||
) %>
|
) %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -80,8 +81,8 @@
|
||||||
<%= yield :sidebar %>
|
<%= yield :sidebar %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="h-full flex flex-col">
|
<div class="h-full flex flex-col">
|
||||||
<div class="overflow-y-auto grow" id="account-sidebar-tabs" data-turbo-permanent>
|
<div class="overflow-y-auto grow">
|
||||||
<%= render "accounts/account_sidebar_tabs", family: Current.family, active_account_group_tab: params[:account_group_tab] || "assets" %>
|
<%= render "accounts/account_sidebar_tabs", family: Current.family, active_tab: @account_group_tab %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if Current.family.trialing? && !self_hosted? %>
|
<% if Current.family.trialing? && !self_hosted? %>
|
||||||
|
|
|
@ -23,10 +23,6 @@
|
||||||
<%= render_flash_notifications %>
|
<%= render_flash_notifications %>
|
||||||
|
|
||||||
<div id="cta"></div>
|
<div id="cta"></div>
|
||||||
|
|
||||||
<% if Current.family&.syncing? %>
|
|
||||||
<%= render "shared/notifications/loading", id: "syncing-notice", message: "Syncing accounts data..." %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -25,11 +25,14 @@
|
||||||
|
|
||||||
<div class="w-full space-y-6 pb-24">
|
<div class="w-full space-y-6 pb-24">
|
||||||
<% if Current.family.accounts.any? %>
|
<% if Current.family.accounts.any? %>
|
||||||
<section class="bg-container py-4 rounded-xl shadow-border-xs px-0.5">
|
<section class="bg-container py-4 rounded-xl shadow-border-xs">
|
||||||
<%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @balance_sheet.net_worth_series(period: @period), period: @period } %>
|
<%= render partial: "pages/dashboard/net_worth_chart", locals: {
|
||||||
|
balance_sheet: @balance_sheet,
|
||||||
|
period: @period
|
||||||
|
} %>
|
||||||
</section>
|
</section>
|
||||||
<% else %>
|
<% else %>
|
||||||
<section class="p-0.5">
|
<section>
|
||||||
<%= render "pages/dashboard/no_accounts_graph_placeholder" %>
|
<%= render "pages/dashboard/no_accounts_graph_placeholder" %>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<%# locals: (balance_sheet:) %>
|
<%# locals: (balance_sheet:, **args) %>
|
||||||
|
|
||||||
<div class="space-y-4 overflow-x-auto p-0.5">
|
<div class="space-y-4" id="balance-sheet">
|
||||||
<% balance_sheet.classification_groups.each do |classification_group| %>
|
<% balance_sheet.classification_groups.each do |classification_group| %>
|
||||||
<div class="bg-container shadow-border-xs rounded-xl space-y-4 p-4">
|
<div class="bg-container shadow-border-xs rounded-xl space-y-4 p-4">
|
||||||
<h2 class="text-lg font-medium inline-flex items-center gap-1.5">
|
<h2 class="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
|
@ -11,26 +11,38 @@
|
||||||
<% if classification_group.account_groups.any? %>
|
<% if classification_group.account_groups.any? %>
|
||||||
<span class="text-secondary">·</span>
|
<span class="text-secondary">·</span>
|
||||||
|
|
||||||
<span class="text-secondary font-medium text-lg"><%= classification_group.total_money.format(precision: 0) %></span>
|
<% if classification_group.syncing? %>
|
||||||
|
<div class="flex items-center w-8 h-4 ml-auto">
|
||||||
|
<div class="bg-loader w-full h-full rounded-md"></div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-secondary font-medium text-lg"><%= classification_group.total_money.format(precision: 0) %></span>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<% if classification_group.account_groups.any? %>
|
<% if classification_group.account_groups.any? %>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<% classification_group.account_groups.each do |account_group| %>
|
<% classification_group.account_groups.each do |account_group| %>
|
||||||
<div class="h-1.5 rounded-sm" style="width: <%= account_group.weight %>%; background-color: <%= account_group.color %>;"></div>
|
<div class="h-1.5 rounded-sm" style="width: <%= account_group.weight %>%; background-color: <%= account_group.color %>;"></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-4">
|
|
||||||
<% classification_group.account_groups.each do |account_group| %>
|
<% if classification_group.syncing? %>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<p class="text-xs text-subdued animate-pulse">Calculating latest balance data...</p>
|
||||||
<div class="h-2.5 w-2.5 rounded-full" style="background-color: <%= account_group.color %>;"></div>
|
<% else %>
|
||||||
<p class="text-secondary"><%= account_group.name %></p>
|
<div class="flex flex-wrap gap-4">
|
||||||
<p class="text-primary font-mono"><%= number_to_percentage(account_group.weight, precision: 0) %></p>
|
<% classification_group.account_groups.each do |account_group| %>
|
||||||
</div>
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<% end %>
|
<div class="h-2.5 w-2.5 rounded-full" style="background-color: <%= account_group.color %>;"></div>
|
||||||
</div>
|
<p class="text-secondary"><%= account_group.name %></p>
|
||||||
|
<p class="text-primary font-mono"><%= number_to_percentage(account_group.weight, precision: 0) %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-surface rounded-xl p-1 space-y-1 overflow-x-auto">
|
<div class="bg-surface rounded-xl p-1 space-y-1 overflow-x-auto">
|
||||||
|
@ -56,15 +68,27 @@
|
||||||
<p><%= account_group.name %></p>
|
<p><%= account_group.name %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between text-right gap-6">
|
<% if account_group.syncing? %>
|
||||||
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
<div class="flex items-center justify-between text-right gap-6">
|
||||||
<%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %>
|
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
||||||
</div>
|
<div class="bg-loader rounded-md h-4 w-12"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="w-40 shrink-0">
|
<div class="w-40 shrink-0 flex justify-end">
|
||||||
<p><%= format_money(account_group.total_money) %></p>
|
<div class="bg-loader rounded-md h-4 w-12"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<% else %>
|
||||||
|
<div class="flex items-center justify-between text-right gap-6">
|
||||||
|
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
||||||
|
<%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-40 shrink-0">
|
||||||
|
<p><%= format_money(account_group.total_money) %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -76,15 +100,27 @@
|
||||||
<%= link_to account.name, account_path(account) %>
|
<%= link_to account.name, account_path(account) %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center text-right gap-6">
|
<% if account.syncing? %>
|
||||||
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
<div class="ml-auto flex items-center text-right gap-6">
|
||||||
<%= render "pages/dashboard/group_weight", weight: account.weight, color: account_group.color %>
|
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
||||||
</div>
|
<div class="bg-loader rounded-md h-4 w-12"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="w-40 shrink-0">
|
<div class="w-40 shrink-0 flex justify-end">
|
||||||
<p><%= format_money(account.balance_money) %></p>
|
<div class="bg-loader rounded-md h-4 w-12"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<% else %>
|
||||||
|
<div class="ml-auto flex items-center text-right gap-6">
|
||||||
|
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
||||||
|
<%= render "pages/dashboard/group_weight", weight: account.weight, color: account_group.color %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-40 shrink-0">
|
||||||
|
<p><%= format_money(account.balance_money) %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if idx < account_group.accounts.size - 1 %>
|
<% if idx < account_group.accounts.size - 1 %>
|
||||||
|
|
|
@ -1,37 +1,55 @@
|
||||||
<%# locals: (series:, period:) %>
|
<%# locals: (balance_sheet:, period:, **args) %>
|
||||||
|
|
||||||
<div class="flex justify-between gap-4 px-4">
|
<div id="net-worth-chart">
|
||||||
<div class="space-y-2">
|
<% series = balance_sheet.net_worth_series(period: period) %>
|
||||||
|
<div class="flex justify-between gap-4 px-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="text-sm text-secondary font-medium"><%= t(".title") %></p>
|
<div class="space-y-2">
|
||||||
<p class="text-primary -space-x-0.5 text-3xl font-medium">
|
<p class="text-sm text-secondary font-medium"><%= t(".title") %></p>
|
||||||
<%= series.current.format %>
|
|
||||||
</p>
|
|
||||||
<% if series.trend.nil? %>
|
|
||||||
<p class="text-sm text-secondary"><%= t(".data_not_available") %></p>
|
|
||||||
<% else %>
|
|
||||||
<%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
<% if balance_sheet.syncing? %>
|
||||||
<%= form.select :period,
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="bg-loader rounded-md h-7 w-20"></div>
|
||||||
|
<div class="bg-loader rounded-md h-5 w-32"></div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-primary -space-x-0.5 text-3xl font-medium">
|
||||||
|
<%= series.current.format %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<% if series.trend.nil? %>
|
||||||
|
<p class="text-sm text-secondary"><%= t(".data_not_available") %></p>
|
||||||
|
<% else %>
|
||||||
|
<%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||||
|
<%= form.select :period,
|
||||||
Period.as_options,
|
Period.as_options,
|
||||||
{ selected: period.key },
|
{ selected: period.key },
|
||||||
data: { "auto-submit-form-target": "auto" },
|
data: { "auto-submit-form-target": "auto" },
|
||||||
class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
|
class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if series.any? %>
|
<% if balance_sheet.syncing? %>
|
||||||
<div
|
<div class="w-full flex items-center justify-center p-4 h-52">
|
||||||
|
<div class="bg-loader rounded-md h-full w-full"></div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<% if series.any? %>
|
||||||
|
<div
|
||||||
id="netWorthChart"
|
id="netWorthChart"
|
||||||
class="w-full flex-1 min-h-52"
|
class="w-full flex-1 min-h-52"
|
||||||
data-controller="time-series-chart"
|
data-controller="time-series-chart"
|
||||||
data-time-series-chart-data-value="<%= series.to_json %>"></div>
|
data-time-series-chart-data-value="<%= series.to_json %>"></div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="w-full h-full flex items-center justify-center">
|
<div class="w-full h-full flex items-center justify-center">
|
||||||
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
|
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%# locals: (rule:) %>
|
<%# locals: (rule:) %>
|
||||||
<div class="flex justify-between items-center p-4 <%= rule.active? ? 'text-primary' : 'text-secondary' %>">
|
<div class="flex justify-between items-center p-4 <%= rule.active? ? "text-primary" : "text-secondary" %>">
|
||||||
<div class="text-sm space-y-1.5">
|
<div class="text-sm space-y-1.5">
|
||||||
<% if rule.name.present? %>
|
<% if rule.name.present? %>
|
||||||
<h3 class="font-medium text-md"><%= rule.name %></h3>
|
<h3 class="font-medium text-md"><%= rule.name %></h3>
|
||||||
|
@ -49,7 +49,7 @@
|
||||||
<% if rule.effective_date.nil? %>
|
<% if rule.effective_date.nil? %>
|
||||||
All past and future <%= rule.resource_type.pluralize %>
|
All past and future <%= rule.resource_type.pluralize %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= rule.resource_type.pluralize %> on or after <%= rule.effective_date.strftime('%b %-d, %Y') %>
|
<%= rule.resource_type.pluralize %> on or after <%= rule.effective_date.strftime("%b %-d, %Y") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%= render DialogComponent.new(reload_on_close: true) do |dialog| %>
|
<%= render DialogComponent.new(reload_on_close: true) do |dialog| %>
|
||||||
<%
|
<%
|
||||||
title = if @rule.name.present?
|
title = if @rule.name.present?
|
||||||
"Confirm changes to \"#{@rule.name}\""
|
"Confirm changes to \"#{@rule.name}\""
|
||||||
else
|
else
|
||||||
|
@ -7,7 +7,7 @@
|
||||||
end
|
end
|
||||||
%>
|
%>
|
||||||
<% dialog.with_header(title: title) %>
|
<% dialog.with_header(title: title) %>
|
||||||
|
|
||||||
<% dialog.with_body do %>
|
<% dialog.with_body do %>
|
||||||
<p class="text-secondary text-sm mb-4">
|
<p class="text-secondary text-sm mb-4">
|
||||||
You are about to apply this rule to
|
You are about to apply this rule to
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<%= link_to "Back to rules", rules_path %>
|
<%= link_to "Back to rules", rules_path %>
|
||||||
|
|
||||||
<%= render DialogComponent.new do |dialog| %>
|
<%= render DialogComponent.new do |dialog| %>
|
||||||
<%
|
<%
|
||||||
title = if @rule.name.present?
|
title = if @rule.name.present?
|
||||||
"Edit #{@rule.resource_type} rule \"#{@rule.name}\""
|
"Edit #{@rule.resource_type} rule \"#{@rule.name}\""
|
||||||
else
|
else
|
||||||
|
|
|
@ -60,7 +60,7 @@
|
||||||
<div class="p-1">
|
<div class="p-1">
|
||||||
<div class="flex flex-col bg-container rounded-xl shadow-border-xs first_child:rounded-t-xl last_child:rounded-b-xl">
|
<div class="flex flex-col bg-container rounded-xl shadow-border-xs first_child:rounded-t-xl last_child:rounded-b-xl">
|
||||||
<% @rules.each_with_index do |rule, idx| %>
|
<% @rules.each_with_index do |rule, idx| %>
|
||||||
<%= render "rule", rule: rule%>
|
<%= render "rule", rule: rule %>
|
||||||
<% unless idx == @rules.size - 1 %>
|
<% unless idx == @rules.size - 1 %>
|
||||||
<div class="h-px bg-divider ml-4 mr-6"></div>
|
<div class="h-px bg-divider ml-4 mr-6"></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,31 +1,31 @@
|
||||||
<%
|
<%
|
||||||
nav_sections = [
|
nav_sections = [
|
||||||
{
|
{
|
||||||
header: t('.general_section_title'),
|
header: t(".general_section_title"),
|
||||||
items: [
|
items: [
|
||||||
{ label: t('.profile_label'), path: settings_profile_path, icon: 'circle-user' },
|
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
|
||||||
{ label: t('.preferences_label'), path: settings_preferences_path, icon: 'bolt' },
|
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
|
||||||
{ label: t('.security_label'), path: settings_security_path, icon: 'shield-check' },
|
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
|
||||||
{ label: t('.self_hosting_label'), path: settings_hosting_path, icon: 'database', if: self_hosted? },
|
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
|
||||||
{ label: t('.billing_label'), path: settings_billing_path, icon: 'circle-dollar-sign', if: !self_hosted? },
|
{ label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? },
|
||||||
{ label: t('.accounts_label'), path: accounts_path, icon: 'layers' },
|
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },
|
||||||
{ label: t('.imports_label'), path: imports_path, icon: 'download' }
|
{ label: t(".imports_label"), path: imports_path, icon: "download" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t('.transactions_section_title'),
|
header: t(".transactions_section_title"),
|
||||||
items: [
|
items: [
|
||||||
{ label: t('.tags_label'), path: tags_path, icon: 'tags' },
|
{ label: t(".tags_label"), path: tags_path, icon: "tags" },
|
||||||
{ label: t('.categories_label'), path: categories_path, icon: 'shapes' },
|
{ label: t(".categories_label"), path: categories_path, icon: "shapes" },
|
||||||
{ label: t('.rules_label'), path: rules_path, icon: 'git-branch' },
|
{ label: t(".rules_label"), path: rules_path, icon: "git-branch" },
|
||||||
{ label: t('.merchants_label'), path: family_merchants_path, icon: 'store' }
|
{ label: t(".merchants_label"), path: family_merchants_path, icon: "store" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: t('.other_section_title'),
|
header: t(".other_section_title"),
|
||||||
items: [
|
items: [
|
||||||
{ label: t('.whats_new_label'), path: changelog_path, icon: 'box' },
|
{ label: t(".whats_new_label"), path: changelog_path, icon: "box" },
|
||||||
{ label: t('.feedback_label'), path: feedback_path, icon: 'megaphone' }
|
{ label: t(".feedback_label"), path: feedback_path, icon: "megaphone" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
<%# locals: (message:, id: nil) %>
|
|
||||||
|
|
||||||
<%= tag.div id: id, class: "flex gap-3 rounded-lg bg-container p-4 group w-full md:max-w-80 shadow-border-xs" do %>
|
|
||||||
<div class="h-5 w-5 shrink-0 p-px text-primary">
|
|
||||||
<%= icon "loader", class: "animate-pulse" %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= tag.p message, class: "text-primary text-sm font-medium" %>
|
|
||||||
<% end %>
|
|
|
@ -4,9 +4,6 @@
|
||||||
<div class="flex items-center gap-5">
|
<div class="flex items-center gap-5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<%= render MenuComponent.new do |menu| %>
|
<%= render MenuComponent.new do |menu| %>
|
||||||
<% if Rails.env.development? %>
|
|
||||||
<% menu.with_item(variant: "button", text: "Dev only: Sync all", href: sync_all_accounts_path, method: :post, icon: "refresh-cw") %>
|
|
||||||
<% end %>
|
|
||||||
<% menu.with_item(variant: "link", text: "New rule", href: new_rule_path(resource_type: "transaction"), icon: "plus", data: { turbo_frame: :modal }) %>
|
<% menu.with_item(variant: "link", text: "New rule", href: new_rule_path(resource_type: "transaction"), icon: "plus", data: { turbo_frame: :modal }) %>
|
||||||
<% menu.with_item(variant: "link", text: "Edit rules", href: rules_path, icon: "git-branch", data: { turbo_frame: :_top }) %>
|
<% menu.with_item(variant: "link", text: "Edit rules", href: rules_path, icon: "git-branch", data: { turbo_frame: :_top }) %>
|
||||||
<% menu.with_item(variant: "link", text: "Edit categories", href: categories_path, icon: "shapes", data: { turbo_frame: :_top }) %>
|
<% menu.with_item(variant: "link", text: "Edit categories", href: categories_path, icon: "shapes", data: { turbo_frame: :_top }) %>
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
Rails.application.configure do
|
Rails.application.configure do
|
||||||
Rack::MiniProfiler.config.skip_paths = [ "/design-system" ]
|
Rack::MiniProfiler.config.skip_paths = [ "/design-system", "/assets", "/cable", "/manifest", "/favicon.ico", "/hotwire-livereload", "/logo-pwa.png" ]
|
||||||
|
Rack::MiniProfiler.config.max_traces_to_show = 50
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,3 +7,8 @@ Sidekiq::Web.use(Rack::Auth::Basic) do |username, password|
|
||||||
ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) &&
|
ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) &&
|
||||||
ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password)
|
ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Sidekiq::Cron.configure do |config|
|
||||||
|
# 10 min "catch-up" window in case worker process is re-deploying when cron tick occurs
|
||||||
|
config.reschedule_grace_period = 600
|
||||||
|
end
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
require "sidekiq/web"
|
require "sidekiq/web"
|
||||||
|
require "sidekiq/cron/web"
|
||||||
|
|
||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
# MFA routes
|
# MFA routes
|
||||||
|
@ -25,6 +26,8 @@ Rails.application.routes.draw do
|
||||||
get "changelog", to: "pages#changelog"
|
get "changelog", to: "pages#changelog"
|
||||||
get "feedback", to: "pages#feedback"
|
get "feedback", to: "pages#feedback"
|
||||||
|
|
||||||
|
resource :current_session, only: %i[update]
|
||||||
|
|
||||||
resource :registration, only: %i[new create]
|
resource :registration, only: %i[new create]
|
||||||
resources :sessions, only: %i[new create destroy]
|
resources :sessions, only: %i[new create destroy]
|
||||||
resource :password_reset, only: %i[new create edit update]
|
resource :password_reset, only: %i[new create edit update]
|
||||||
|
@ -104,10 +107,6 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :accounts, only: %i[index new], shallow: true do
|
resources :accounts, only: %i[index new], shallow: true do
|
||||||
collection do
|
|
||||||
post :sync_all
|
|
||||||
end
|
|
||||||
|
|
||||||
member do
|
member do
|
||||||
post :sync
|
post :sync
|
||||||
get :chart
|
get :chart
|
||||||
|
|
5
config/schedule.yml
Normal file
5
config/schedule.yml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
sync_market_data:
|
||||||
|
cron: "0 17 * * 1-5" # 5:00 PM EST (1 hour after market close)
|
||||||
|
class: "SyncMarketDataJob"
|
||||||
|
queue: "scheduled"
|
||||||
|
description: "Syncs market data daily at 5:00 PM EST (1 hour after market close)"
|
|
@ -1,6 +1,7 @@
|
||||||
concurrency: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %>
|
concurrency: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %>
|
||||||
queues:
|
queues:
|
||||||
- [high_priority, 6]
|
- [scheduled, 10] # For cron-like jobs (e.g. "daily market data sync")
|
||||||
|
- [high_priority, 4]
|
||||||
- [medium_priority, 2]
|
- [medium_priority, 2]
|
||||||
- [low_priority, 1]
|
- [low_priority, 1]
|
||||||
- [default, 1]
|
- [default, 1]
|
||||||
|
|
65
db/migrate/20250512171654_update_sync_timestamps.rb
Normal file
65
db/migrate/20250512171654_update_sync_timestamps.rb
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
class UpdateSyncTimestamps < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
# Timestamps, managed by aasm
|
||||||
|
add_column :syncs, :pending_at, :datetime
|
||||||
|
add_column :syncs, :syncing_at, :datetime
|
||||||
|
add_column :syncs, :completed_at, :datetime
|
||||||
|
add_column :syncs, :failed_at, :datetime
|
||||||
|
|
||||||
|
add_column :syncs, :window_start_date, :date
|
||||||
|
add_column :syncs, :window_end_date, :date
|
||||||
|
|
||||||
|
reversible do |dir|
|
||||||
|
dir.up do
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE syncs
|
||||||
|
SET
|
||||||
|
completed_at = CASE
|
||||||
|
WHEN status = 'completed' THEN last_ran_at
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
failed_at = CASE
|
||||||
|
WHEN status = 'failed' THEN last_ran_at
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
SQL
|
||||||
|
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE syncs
|
||||||
|
SET window_start_date = start_date
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Due to some recent bugs, some self hosters have syncs that are stuck.
|
||||||
|
# This manually fails those syncs so they stop seeing syncing UI notices.
|
||||||
|
if Rails.application.config.app_mode.self_hosted?
|
||||||
|
puts "Self hosted: Fail syncs older than 2 hours"
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE syncs
|
||||||
|
SET status = 'failed'
|
||||||
|
WHERE (
|
||||||
|
status = 'syncing' AND
|
||||||
|
created_at < NOW() - INTERVAL '2 hours'
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
dir.down do
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE syncs
|
||||||
|
SET
|
||||||
|
last_ran_at = COALESCE(completed_at, failed_at)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE syncs
|
||||||
|
SET start_date = window_start_date
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
remove_column :syncs, :start_date, :date
|
||||||
|
remove_column :syncs, :last_ran_at, :datetime
|
||||||
|
remove_column :syncs, :error_backtrace, :text, array: true
|
||||||
|
end
|
||||||
|
end
|
5
db/migrate/20250514214242_add_metadata_to_session.rb
Normal file
5
db/migrate/20250514214242_add_metadata_to_session.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class AddMetadataToSession < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :sessions, :data, :jsonb, default: {}
|
||||||
|
end
|
||||||
|
end
|
12
db/schema.rb
generated
12
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2025_05_13_122703) do
|
ActiveRecord::Schema[7.2].define(version: 2025_05_14_214242) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -537,6 +537,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_13_122703) do
|
||||||
t.uuid "active_impersonator_session_id"
|
t.uuid "active_impersonator_session_id"
|
||||||
t.datetime "subscribed_at"
|
t.datetime "subscribed_at"
|
||||||
t.jsonb "prev_transaction_page_params", default: {}
|
t.jsonb "prev_transaction_page_params", default: {}
|
||||||
|
t.jsonb "data", default: {}
|
||||||
t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id"
|
t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id"
|
||||||
t.index ["user_id"], name: "index_sessions_on_user_id"
|
t.index ["user_id"], name: "index_sessions_on_user_id"
|
||||||
end
|
end
|
||||||
|
@ -587,15 +588,18 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_13_122703) do
|
||||||
create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "syncable_type", null: false
|
t.string "syncable_type", null: false
|
||||||
t.uuid "syncable_id", null: false
|
t.uuid "syncable_id", null: false
|
||||||
t.datetime "last_ran_at"
|
|
||||||
t.date "start_date"
|
|
||||||
t.string "status", default: "pending"
|
t.string "status", default: "pending"
|
||||||
t.string "error"
|
t.string "error"
|
||||||
t.jsonb "data"
|
t.jsonb "data"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.text "error_backtrace", array: true
|
|
||||||
t.uuid "parent_id"
|
t.uuid "parent_id"
|
||||||
|
t.datetime "pending_at"
|
||||||
|
t.datetime "syncing_at"
|
||||||
|
t.datetime "completed_at"
|
||||||
|
t.datetime "failed_at"
|
||||||
|
t.date "window_start_date"
|
||||||
|
t.date "window_end_date"
|
||||||
t.index ["parent_id"], name: "index_syncs_on_parent_id"
|
t.index ["parent_id"], name: "index_syncs_on_parent_id"
|
||||||
t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable"
|
t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable"
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,9 +15,4 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||||
post sync_account_path(@account)
|
post sync_account_path(@account)
|
||||||
assert_redirected_to account_path(@account)
|
assert_redirected_to account_path(@account)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can sync all accounts" do
|
|
||||||
post sync_all_accounts_path
|
|
||||||
assert_redirected_to accounts_path
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
15
test/controllers/current_sessions_controller_test.rb
Normal file
15
test/controllers/current_sessions_controller_test.rb
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class CurrentSessionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = users(:family_admin)
|
||||||
|
sign_in @user
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update the preferred tab for any namespace" do
|
||||||
|
put current_session_url, params: { current_session: { tab_key: "accounts_sidebar_tab", tab_value: "asset" } }
|
||||||
|
assert_response :success
|
||||||
|
session = Session.order(updated_at: :desc).first
|
||||||
|
assert_equal "asset", session.get_preferred_tab("accounts_sidebar_tab")
|
||||||
|
end
|
||||||
|
end
|
|
@ -8,7 +8,7 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
test "create" do
|
test "create" do
|
||||||
@plaid_provider = mock
|
@plaid_provider = mock
|
||||||
PlaidItem.expects(:plaid_provider_for_region).with("us").returns(@plaid_provider)
|
Provider::Registry.expects(:get_provider).with(:plaid_us).returns(@plaid_provider)
|
||||||
|
|
||||||
public_token = "public-sandbox-1234"
|
public_token = "public-sandbox-1234"
|
||||||
|
|
||||||
|
|
6
test/fixtures/syncs.yml
vendored
6
test/fixtures/syncs.yml
vendored
|
@ -1,17 +1,17 @@
|
||||||
account:
|
account:
|
||||||
syncable_type: Account
|
syncable_type: Account
|
||||||
syncable: depository
|
syncable: depository
|
||||||
last_ran_at: <%= Time.now %>
|
|
||||||
status: completed
|
status: completed
|
||||||
|
completed_at: <%= Time.now %>
|
||||||
|
|
||||||
plaid_item:
|
plaid_item:
|
||||||
syncable_type: PlaidItem
|
syncable_type: PlaidItem
|
||||||
syncable: one
|
syncable: one
|
||||||
last_ran_at: <%= Time.now %>
|
|
||||||
status: completed
|
status: completed
|
||||||
|
completed_at: <%= Time.now %>
|
||||||
|
|
||||||
family:
|
family:
|
||||||
syncable_type: Family
|
syncable_type: Family
|
||||||
syncable: dylan_family
|
syncable: dylan_family
|
||||||
last_ran_at: <%= Time.now %>
|
|
||||||
status: completed
|
status: completed
|
||||||
|
completed_at: <%= Time.now %>
|
||||||
|
|
|
@ -7,18 +7,14 @@ module SyncableInterfaceTest
|
||||||
test "can sync later" do
|
test "can sync later" do
|
||||||
assert_difference "@syncable.syncs.count", 1 do
|
assert_difference "@syncable.syncs.count", 1 do
|
||||||
assert_enqueued_with job: SyncJob do
|
assert_enqueued_with job: SyncJob do
|
||||||
@syncable.sync_later
|
@syncable.sync_later(window_start_date: 2.days.ago.to_date)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can sync" do
|
test "can perform sync" do
|
||||||
assert_difference "@syncable.syncs.count", 1 do
|
mock_sync = mock
|
||||||
@syncable.sync(start_date: 2.days.ago.to_date)
|
@syncable.class.any_instance.expects(:perform_sync).with(mock_sync).once
|
||||||
end
|
@syncable.perform_sync(mock_sync)
|
||||||
end
|
|
||||||
|
|
||||||
test "implements sync_data" do
|
|
||||||
assert_respond_to @syncable, :sync_data
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,7 +4,7 @@ class SyncJobTest < ActiveJob::TestCase
|
||||||
test "sync is performed" do
|
test "sync is performed" do
|
||||||
syncable = accounts(:depository)
|
syncable = accounts(:depository)
|
||||||
|
|
||||||
sync = syncable.syncs.create!(start_date: 2.days.ago.to_date)
|
sync = syncable.syncs.create!(window_start_date: 2.days.ago.to_date)
|
||||||
|
|
||||||
sync.expects(:perform).once
|
sync.expects(:perform).once
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,7 @@ class EntryTest < ActiveSupport::TestCase
|
||||||
prior_date = @entry.date - 1
|
prior_date = @entry.date - 1
|
||||||
@entry.update! date: prior_date
|
@entry.update! date: prior_date
|
||||||
|
|
||||||
@entry.account.expects(:sync_later).with(start_date: prior_date)
|
@entry.account.expects(:sync_later).with(window_start_date: prior_date)
|
||||||
@entry.sync_account_later
|
@entry.sync_account_later
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -38,14 +38,14 @@ class EntryTest < ActiveSupport::TestCase
|
||||||
prior_date = @entry.date
|
prior_date = @entry.date
|
||||||
@entry.update! date: @entry.date + 1
|
@entry.update! date: @entry.date + 1
|
||||||
|
|
||||||
@entry.account.expects(:sync_later).with(start_date: prior_date)
|
@entry.account.expects(:sync_later).with(window_start_date: prior_date)
|
||||||
@entry.sync_account_later
|
@entry.sync_account_later
|
||||||
end
|
end
|
||||||
|
|
||||||
test "triggers sync with correct start date when transaction deleted" do
|
test "triggers sync with correct start date when transaction deleted" do
|
||||||
@entry.destroy!
|
@entry.destroy!
|
||||||
|
|
||||||
@entry.account.expects(:sync_later).with(start_date: nil)
|
@entry.account.expects(:sync_later).with(window_start_date: nil)
|
||||||
@entry.sync_account_later
|
@entry.sync_account_later
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -15,19 +15,19 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
test "balance generation respects user timezone and last generated date is current user date" do
|
test "balance generation respects user timezone and last generated date is current user date" do
|
||||||
# Simulate user in EST timezone
|
# Simulate user in EST timezone
|
||||||
Time.zone = "America/New_York"
|
Time.use_zone("America/New_York") do
|
||||||
|
# Set current time to 1am UTC on Jan 5, 2025
|
||||||
|
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for)
|
||||||
|
travel_to Time.utc(2025, 01, 05, 1, 0, 0)
|
||||||
|
|
||||||
# Set current time to 1am UTC on Jan 5, 2025
|
# Create a valuation for Jan 3, 2025
|
||||||
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for)
|
create_valuation(account: @account, date: "2025-01-03", amount: 17000)
|
||||||
travel_to Time.utc(2025, 01, 05, 1, 0, 0)
|
|
||||||
|
|
||||||
# Create a valuation for Jan 3, 2025
|
expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 17000 ], [ "2025-01-04", 17000 ] ]
|
||||||
create_valuation(account: @account, date: "2025-01-03", amount: 17000)
|
calculated = Balance::ForwardCalculator.new(@account).calculate
|
||||||
|
|
||||||
expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 17000 ], [ "2025-01-04", 17000 ] ]
|
assert_equal expected, calculated.map { |b| [ b.date.to_s, b.balance ] }
|
||||||
calculated = Balance::ForwardCalculator.new(@account).calculate
|
end
|
||||||
|
|
||||||
assert_equal expected, calculated.map { |b| [ b.date.to_s, b.balance ] }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0.
|
# When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0.
|
||||||
|
|
|
@ -25,18 +25,18 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
test "balance generation respects user timezone and last generated date is current user date" do
|
test "balance generation respects user timezone and last generated date is current user date" do
|
||||||
# Simulate user in EST timezone
|
# Simulate user in EST timezone
|
||||||
Time.zone = "America/New_York"
|
Time.use_zone("America/New_York") do
|
||||||
|
# Set current time to 1am UTC on Jan 5, 2025
|
||||||
|
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for)
|
||||||
|
travel_to Time.utc(2025, 01, 05, 1, 0, 0)
|
||||||
|
|
||||||
# Set current time to 1am UTC on Jan 5, 2025
|
create_valuation(account: @account, date: "2025-01-03", amount: 17000)
|
||||||
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for)
|
|
||||||
travel_to Time.utc(2025, 01, 05, 1, 0, 0)
|
|
||||||
|
|
||||||
create_valuation(account: @account, date: "2025-01-03", amount: 17000)
|
expected = [ [ "2025-01-02", 17000 ], [ "2025-01-03", 17000 ], [ "2025-01-04", @account.balance ] ]
|
||||||
|
calculated = Balance::ReverseCalculator.new(@account).calculate
|
||||||
|
|
||||||
expected = [ [ "2025-01-02", 17000 ], [ "2025-01-03", 17000 ], [ "2025-01-04", @account.balance ] ]
|
assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.balance ] }
|
||||||
calculated = Balance::ReverseCalculator.new(@account).calculate
|
end
|
||||||
|
|
||||||
assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.balance ] }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "valuations sync" do
|
test "valuations sync" do
|
||||||
|
|
30
test/models/family/syncer_test.rb
Normal file
30
test/models/family/syncer_test.rb
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Family::SyncerTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
@family = families(:dylan_family)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "syncs plaid items and manual accounts" do
|
||||||
|
family_sync = syncs(:family)
|
||||||
|
|
||||||
|
manual_accounts_count = @family.accounts.manual.count
|
||||||
|
items_count = @family.plaid_items.count
|
||||||
|
|
||||||
|
syncer = Family::Syncer.new(@family)
|
||||||
|
|
||||||
|
Account.any_instance
|
||||||
|
.expects(:sync_later)
|
||||||
|
.with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil)
|
||||||
|
.times(manual_accounts_count)
|
||||||
|
|
||||||
|
PlaidItem.any_instance
|
||||||
|
.expects(:sync_later)
|
||||||
|
.with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil)
|
||||||
|
.times(items_count)
|
||||||
|
|
||||||
|
syncer.perform_sync(family_sync)
|
||||||
|
|
||||||
|
assert_equal "completed", family_sync.reload.status
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,25 +1,9 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
require "csv"
|
|
||||||
|
|
||||||
class FamilyTest < ActiveSupport::TestCase
|
class FamilyTest < ActiveSupport::TestCase
|
||||||
include EntriesTestHelper
|
|
||||||
include SyncableInterfaceTest
|
include SyncableInterfaceTest
|
||||||
|
|
||||||
def setup
|
def setup
|
||||||
@family = families(:empty)
|
|
||||||
@syncable = families(:dylan_family)
|
@syncable = families(:dylan_family)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "syncs plaid items and manual accounts" do
|
|
||||||
family_sync = syncs(:family)
|
|
||||||
|
|
||||||
manual_accounts_count = @syncable.accounts.manual.count
|
|
||||||
items_count = @syncable.plaid_items.count
|
|
||||||
|
|
||||||
Account.any_instance.expects(:sync_later)
|
|
||||||
.with(start_date: nil, parent_sync: family_sync)
|
|
||||||
.times(manual_accounts_count)
|
|
||||||
|
|
||||||
@syncable.sync_data(family_sync, start_date: family_sync.start_date)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -20,22 +20,22 @@ class Holding::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
test "holding generation respects user timezone and last generated date is current user date" do
|
test "holding generation respects user timezone and last generated date is current user date" do
|
||||||
# Simulate user in EST timezone
|
# Simulate user in EST timezone
|
||||||
Time.zone = "America/New_York"
|
Time.use_zone("America/New_York") do
|
||||||
|
# Set current time to 1am UTC on Jan 5, 2025
|
||||||
|
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for)
|
||||||
|
travel_to Time.utc(2025, 01, 05, 1, 0, 0)
|
||||||
|
|
||||||
# Set current time to 1am UTC on Jan 5, 2025
|
voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF")
|
||||||
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for)
|
Security::Price.create!(security: voo, date: "2025-01-02", price: 500)
|
||||||
travel_to Time.utc(2025, 01, 05, 1, 0, 0)
|
Security::Price.create!(security: voo, date: "2025-01-03", price: 500)
|
||||||
|
Security::Price.create!(security: voo, date: "2025-01-04", price: 500)
|
||||||
|
create_trade(voo, qty: 10, date: "2025-01-03", price: 500, account: @account)
|
||||||
|
|
||||||
voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF")
|
expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 5000 ], [ "2025-01-04", 5000 ] ]
|
||||||
Security::Price.create!(security: voo, date: "2025-01-02", price: 500)
|
calculated = Holding::ForwardCalculator.new(@account).calculate
|
||||||
Security::Price.create!(security: voo, date: "2025-01-03", price: 500)
|
|
||||||
Security::Price.create!(security: voo, date: "2025-01-04", price: 500)
|
|
||||||
create_trade(voo, qty: 10, date: "2025-01-03", price: 500, account: @account)
|
|
||||||
|
|
||||||
expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 5000 ], [ "2025-01-04", 5000 ] ]
|
assert_equal expected, calculated.map { |b| [ b.date.to_s, b.amount ] }
|
||||||
calculated = Holding::ForwardCalculator.new(@account).calculate
|
end
|
||||||
|
|
||||||
assert_equal expected, calculated.map { |b| [ b.date.to_s, b.amount ] }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "forward portfolio calculation" do
|
test "forward portfolio calculation" do
|
||||||
|
|
|
@ -28,37 +28,18 @@ class Holding::PortfolioCacheTest < ActiveSupport::TestCase
|
||||||
price: db_price
|
price: db_price
|
||||||
)
|
)
|
||||||
|
|
||||||
expect_provider_prices([], start_date: @account.start_date)
|
|
||||||
|
|
||||||
cache = Holding::PortfolioCache.new(@account)
|
cache = Holding::PortfolioCache.new(@account)
|
||||||
assert_equal db_price, cache.get_price(@security.id, Date.current).price
|
assert_equal db_price, cache.get_price(@security.id, Date.current).price
|
||||||
end
|
end
|
||||||
|
|
||||||
test "if no price in DB, try fetching from provider" do
|
test "if no price from db, try getting the price from trades" do
|
||||||
Security::Price.delete_all
|
|
||||||
|
|
||||||
provider_price = Security::Price.new(
|
|
||||||
security: @security,
|
|
||||||
date: Date.current,
|
|
||||||
price: 220,
|
|
||||||
currency: "USD"
|
|
||||||
)
|
|
||||||
|
|
||||||
expect_provider_prices([ provider_price ], start_date: @account.start_date)
|
|
||||||
|
|
||||||
cache = Holding::PortfolioCache.new(@account)
|
|
||||||
assert_equal provider_price.price, cache.get_price(@security.id, Date.current).price
|
|
||||||
end
|
|
||||||
|
|
||||||
test "if no price from db or provider, try getting the price from trades" do
|
|
||||||
Security::Price.destroy_all
|
Security::Price.destroy_all
|
||||||
expect_provider_prices([], start_date: @account.start_date)
|
|
||||||
|
|
||||||
cache = Holding::PortfolioCache.new(@account)
|
cache = Holding::PortfolioCache.new(@account)
|
||||||
assert_equal @trade.price, cache.get_price(@security.id, @trade.entry.date).price
|
assert_equal @trade.price, cache.get_price(@security.id, @trade.entry.date).price
|
||||||
end
|
end
|
||||||
|
|
||||||
test "if no price from db, provider, or trades, search holdings" do
|
test "if no price from db or trades, search holdings" do
|
||||||
Security::Price.delete_all
|
Security::Price.delete_all
|
||||||
Entry.delete_all
|
Entry.delete_all
|
||||||
|
|
||||||
|
@ -72,16 +53,7 @@ class Holding::PortfolioCacheTest < ActiveSupport::TestCase
|
||||||
currency: "USD"
|
currency: "USD"
|
||||||
)
|
)
|
||||||
|
|
||||||
expect_provider_prices([], start_date: @account.start_date)
|
|
||||||
|
|
||||||
cache = Holding::PortfolioCache.new(@account, use_holdings: true)
|
cache = Holding::PortfolioCache.new(@account, use_holdings: true)
|
||||||
assert_equal holding.price, cache.get_price(@security.id, holding.date).price
|
assert_equal holding.price, cache.get_price(@security.id, holding.date).price
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
def expect_provider_prices(prices, start_date:, end_date: Date.current)
|
|
||||||
@provider.expects(:fetch_security_prices)
|
|
||||||
.with(@security, start_date: start_date, end_date: end_date)
|
|
||||||
.returns(provider_success_response(prices))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -20,26 +20,26 @@ class Holding::ReverseCalculatorTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
test "holding generation respects user timezone and last generated date is current user date" do
|
test "holding generation respects user timezone and last generated date is current user date" do
|
||||||
# Simulate user in EST timezone
|
# Simulate user in EST timezone
|
||||||
Time.zone = "America/New_York"
|
Time.use_zone("America/New_York") do
|
||||||
|
# Set current time to 1am UTC on Jan 5, 2025
|
||||||
|
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for)
|
||||||
|
travel_to Time.utc(2025, 01, 05, 1, 0, 0)
|
||||||
|
|
||||||
# Set current time to 1am UTC on Jan 5, 2025
|
voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF")
|
||||||
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for)
|
Security::Price.create!(security: voo, date: "2025-01-02", price: 500)
|
||||||
travel_to Time.utc(2025, 01, 05, 1, 0, 0)
|
Security::Price.create!(security: voo, date: "2025-01-03", price: 500)
|
||||||
|
Security::Price.create!(security: voo, date: "2025-01-04", price: 500)
|
||||||
|
|
||||||
voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF")
|
# Today's holdings (provided)
|
||||||
Security::Price.create!(security: voo, date: "2025-01-02", price: 500)
|
@account.holdings.create!(security: voo, date: "2025-01-04", qty: 10, price: 500, amount: 5000, currency: "USD")
|
||||||
Security::Price.create!(security: voo, date: "2025-01-03", price: 500)
|
|
||||||
Security::Price.create!(security: voo, date: "2025-01-04", price: 500)
|
|
||||||
|
|
||||||
# Today's holdings (provided)
|
create_trade(voo, qty: 10, date: "2025-01-03", price: 500, account: @account)
|
||||||
@account.holdings.create!(security: voo, date: "2025-01-04", qty: 10, price: 500, amount: 5000, currency: "USD")
|
|
||||||
|
|
||||||
create_trade(voo, qty: 10, date: "2025-01-03", price: 500, account: @account)
|
expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 5000 ], [ "2025-01-04", 5000 ] ]
|
||||||
|
calculated = Holding::ReverseCalculator.new(@account).calculate
|
||||||
|
|
||||||
expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 5000 ], [ "2025-01-04", 5000 ] ]
|
assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.amount ] }
|
||||||
calculated = Holding::ReverseCalculator.new(@account).calculate
|
end
|
||||||
|
|
||||||
assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.amount ] }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings
|
# Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings
|
||||||
|
|
71
test/models/market_data_syncer_test.rb
Normal file
71
test/models/market_data_syncer_test.rb
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
require "test_helper"
|
||||||
|
require "ostruct"
|
||||||
|
|
||||||
|
class MarketDataSyncerTest < ActiveSupport::TestCase
|
||||||
|
include EntriesTestHelper, ProviderTestHelper
|
||||||
|
|
||||||
|
test "syncs exchange rates with upsert" do
|
||||||
|
empty_db
|
||||||
|
|
||||||
|
family1 = Family.create!(name: "Family 1", currency: "USD")
|
||||||
|
account1 = family1.accounts.create!(name: "Account 1", currency: "USD", balance: 100, accountable: Depository.new)
|
||||||
|
account2 = family1.accounts.create!(name: "Account 2", currency: "CAD", balance: 100, accountable: Depository.new)
|
||||||
|
|
||||||
|
family2 = Family.create!(name: "Family 2", currency: "EUR")
|
||||||
|
account3 = family2.accounts.create!(name: "Account 3", currency: "EUR", balance: 100, accountable: Depository.new)
|
||||||
|
account4 = family2.accounts.create!(name: "Account 4", currency: "USD", balance: 100, accountable: Depository.new)
|
||||||
|
|
||||||
|
mock_provider = mock
|
||||||
|
Provider::Registry.any_instance.expects(:get_provider).with(:synth).returns(mock_provider).at_least_once
|
||||||
|
|
||||||
|
start_date = 1.month.ago.to_date
|
||||||
|
end_date = Date.current.in_time_zone("America/New_York").to_date
|
||||||
|
|
||||||
|
# Put an existing rate in DB to test upsert
|
||||||
|
ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: start_date, rate: 2.0)
|
||||||
|
|
||||||
|
mock_provider.expects(:fetch_exchange_rates)
|
||||||
|
.with(from: "CAD", to: "USD", start_date: start_date, end_date: end_date)
|
||||||
|
.returns(provider_success_response([ OpenStruct.new(from: "CAD", to: "USD", date: start_date, rate: 1.0) ]))
|
||||||
|
|
||||||
|
mock_provider.expects(:fetch_exchange_rates)
|
||||||
|
.with(from: "USD", to: "EUR", start_date: start_date, end_date: end_date)
|
||||||
|
.returns(provider_success_response([ OpenStruct.new(from: "USD", to: "EUR", date: start_date, rate: 1.0) ]))
|
||||||
|
|
||||||
|
assert_difference "ExchangeRate.count", 1 do
|
||||||
|
MarketDataSyncer.new.sync_exchange_rates
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal 1.0, ExchangeRate.where(from_currency: "CAD", to_currency: "USD", date: start_date).first.rate
|
||||||
|
end
|
||||||
|
|
||||||
|
test "syncs security prices with upsert" do
|
||||||
|
empty_db
|
||||||
|
|
||||||
|
aapl = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS")
|
||||||
|
|
||||||
|
family = Family.create!(name: "Family 1", currency: "USD")
|
||||||
|
account = family.accounts.create!(name: "Account 1", currency: "USD", balance: 100, accountable: Investment.new)
|
||||||
|
|
||||||
|
mock_provider = mock
|
||||||
|
Provider::Registry.any_instance.expects(:get_provider).with(:synth).returns(mock_provider).at_least_once
|
||||||
|
|
||||||
|
start_date = 1.month.ago.to_date
|
||||||
|
end_date = Date.current.in_time_zone("America/New_York").to_date
|
||||||
|
|
||||||
|
mock_provider.expects(:fetch_security_prices)
|
||||||
|
.with(aapl, start_date: start_date, end_date: end_date)
|
||||||
|
.returns(provider_success_response([ OpenStruct.new(security: aapl, date: start_date, price: 100, currency: "USD") ]))
|
||||||
|
|
||||||
|
assert_difference "Security::Price.count", 1 do
|
||||||
|
MarketDataSyncer.new.sync_prices
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def empty_db
|
||||||
|
Invitation.destroy_all
|
||||||
|
Family.destroy_all
|
||||||
|
Security.destroy_all
|
||||||
|
end
|
||||||
|
end
|
|
@ -49,25 +49,6 @@ class Security::PriceTest < ActiveSupport::TestCase
|
||||||
assert_not @security.find_or_fetch_price(date: Date.current)
|
assert_not @security.find_or_fetch_price(date: Date.current)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "upserts historical prices from provider" do
|
|
||||||
Security::Price.delete_all
|
|
||||||
|
|
||||||
# Will be overwritten by upsert
|
|
||||||
Security::Price.create!(security: @security, date: 1.day.ago.to_date, price: 190, currency: "USD")
|
|
||||||
|
|
||||||
expect_provider_prices(security: @security, start_date: 2.days.ago.to_date, end_date: Date.current, prices: [
|
|
||||||
Security::Price.new(security: @security, date: Date.current, price: 215, currency: "USD"),
|
|
||||||
Security::Price.new(security: @security, date: 1.day.ago.to_date, price: 214, currency: "USD"),
|
|
||||||
Security::Price.new(security: @security, date: 2.days.ago.to_date, price: 213, currency: "USD")
|
|
||||||
])
|
|
||||||
|
|
||||||
@security.sync_provider_prices(start_date: 2.days.ago.to_date)
|
|
||||||
|
|
||||||
assert_equal 215, @security.prices.find_by(date: Date.current).price
|
|
||||||
assert_equal 214, @security.prices.find_by(date: 1.day.ago.to_date).price
|
|
||||||
assert_equal 213, @security.prices.find_by(date: 2.days.ago.to_date).price
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
def expect_provider_price(security:, price:, date:)
|
def expect_provider_price(security:, price:, date:)
|
||||||
@provider.expects(:fetch_security_price)
|
@provider.expects(:fetch_security_price)
|
||||||
|
|
|
@ -1,34 +1,170 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class SyncTest < ActiveSupport::TestCase
|
class SyncTest < ActiveSupport::TestCase
|
||||||
setup do
|
include ActiveJob::TestHelper
|
||||||
@sync = syncs(:account)
|
|
||||||
@sync.update(status: "pending")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "runs successful sync" do
|
test "runs successful sync" do
|
||||||
@sync.syncable.expects(:sync_data).with(@sync, start_date: @sync.start_date).once
|
syncable = accounts(:depository)
|
||||||
|
sync = Sync.create!(syncable: syncable)
|
||||||
|
|
||||||
assert_equal "pending", @sync.status
|
syncable.expects(:perform_sync).with(sync).once
|
||||||
|
|
||||||
previously_ran_at = @sync.last_ran_at
|
assert_equal "pending", sync.status
|
||||||
|
|
||||||
@sync.perform
|
sync.perform
|
||||||
|
|
||||||
assert @sync.last_ran_at > previously_ran_at
|
assert sync.completed_at < Time.now
|
||||||
assert_equal "completed", @sync.status
|
assert_equal "completed", sync.status
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles sync errors" do
|
test "handles sync errors" do
|
||||||
@sync.syncable.expects(:sync_data).with(@sync, start_date: @sync.start_date).raises(StandardError.new("test sync error"))
|
syncable = accounts(:depository)
|
||||||
|
sync = Sync.create!(syncable: syncable)
|
||||||
|
|
||||||
assert_equal "pending", @sync.status
|
syncable.expects(:perform_sync).with(sync).raises(StandardError.new("test sync error"))
|
||||||
previously_ran_at = @sync.last_ran_at
|
|
||||||
|
|
||||||
@sync.perform
|
assert_equal "pending", sync.status
|
||||||
|
|
||||||
assert @sync.last_ran_at > previously_ran_at
|
sync.perform
|
||||||
assert_equal "failed", @sync.status
|
|
||||||
assert_equal "test sync error", @sync.error
|
assert sync.failed_at < Time.now
|
||||||
|
assert_equal "failed", sync.status
|
||||||
|
assert_equal "test sync error", sync.error
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can run nested syncs that alert the parent when complete" do
|
||||||
|
family = families(:dylan_family)
|
||||||
|
plaid_item = plaid_items(:one)
|
||||||
|
account = accounts(:connected)
|
||||||
|
|
||||||
|
family_sync = Sync.create!(syncable: family)
|
||||||
|
plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync)
|
||||||
|
account_sync = Sync.create!(syncable: account, parent: plaid_item_sync)
|
||||||
|
|
||||||
|
assert_equal "pending", family_sync.status
|
||||||
|
assert_equal "pending", plaid_item_sync.status
|
||||||
|
assert_equal "pending", account_sync.status
|
||||||
|
|
||||||
|
family.expects(:perform_sync).with(family_sync).once
|
||||||
|
|
||||||
|
family_sync.perform
|
||||||
|
|
||||||
|
assert_equal "syncing", family_sync.reload.status
|
||||||
|
|
||||||
|
plaid_item.expects(:perform_sync).with(plaid_item_sync).once
|
||||||
|
|
||||||
|
plaid_item_sync.perform
|
||||||
|
|
||||||
|
assert_equal "syncing", family_sync.reload.status
|
||||||
|
assert_equal "syncing", plaid_item_sync.reload.status
|
||||||
|
|
||||||
|
account.expects(:perform_sync).with(account_sync).once
|
||||||
|
|
||||||
|
# Since these are accessed through `parent`, they won't necessarily be the same
|
||||||
|
# instance we configured above
|
||||||
|
Account.any_instance.expects(:perform_post_sync).once
|
||||||
|
Account.any_instance.expects(:broadcast_sync_complete).once
|
||||||
|
PlaidItem.any_instance.expects(:perform_post_sync).once
|
||||||
|
PlaidItem.any_instance.expects(:broadcast_sync_complete).once
|
||||||
|
Family.any_instance.expects(:perform_post_sync).once
|
||||||
|
Family.any_instance.expects(:broadcast_sync_complete).once
|
||||||
|
|
||||||
|
account_sync.perform
|
||||||
|
|
||||||
|
assert_equal "completed", plaid_item_sync.reload.status
|
||||||
|
assert_equal "completed", account_sync.reload.status
|
||||||
|
assert_equal "completed", family_sync.reload.status
|
||||||
|
end
|
||||||
|
|
||||||
|
test "failures propagate up the chain" do
|
||||||
|
family = families(:dylan_family)
|
||||||
|
plaid_item = plaid_items(:one)
|
||||||
|
account = accounts(:connected)
|
||||||
|
|
||||||
|
family_sync = Sync.create!(syncable: family)
|
||||||
|
plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync)
|
||||||
|
account_sync = Sync.create!(syncable: account, parent: plaid_item_sync)
|
||||||
|
|
||||||
|
assert_equal "pending", family_sync.status
|
||||||
|
assert_equal "pending", plaid_item_sync.status
|
||||||
|
assert_equal "pending", account_sync.status
|
||||||
|
|
||||||
|
family.expects(:perform_sync).with(family_sync).once
|
||||||
|
|
||||||
|
family_sync.perform
|
||||||
|
|
||||||
|
assert_equal "syncing", family_sync.reload.status
|
||||||
|
|
||||||
|
plaid_item.expects(:perform_sync).with(plaid_item_sync).once
|
||||||
|
|
||||||
|
plaid_item_sync.perform
|
||||||
|
|
||||||
|
assert_equal "syncing", family_sync.reload.status
|
||||||
|
assert_equal "syncing", plaid_item_sync.reload.status
|
||||||
|
|
||||||
|
# This error should "bubble up" to the PlaidItem and Family sync results
|
||||||
|
account.expects(:perform_sync).with(account_sync).raises(StandardError.new("test account sync error"))
|
||||||
|
|
||||||
|
# Since these are accessed through `parent`, they won't necessarily be the same
|
||||||
|
# instance we configured above
|
||||||
|
Account.any_instance.expects(:perform_post_sync).once
|
||||||
|
PlaidItem.any_instance.expects(:perform_post_sync).once
|
||||||
|
Family.any_instance.expects(:perform_post_sync).once
|
||||||
|
|
||||||
|
Account.any_instance.expects(:broadcast_sync_complete).once
|
||||||
|
PlaidItem.any_instance.expects(:broadcast_sync_complete).once
|
||||||
|
Family.any_instance.expects(:broadcast_sync_complete).once
|
||||||
|
|
||||||
|
account_sync.perform
|
||||||
|
|
||||||
|
assert_equal "failed", plaid_item_sync.reload.status
|
||||||
|
assert_equal "failed", account_sync.reload.status
|
||||||
|
assert_equal "failed", family_sync.reload.status
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parent failure should not change status if child succeeds" do
|
||||||
|
family = families(:dylan_family)
|
||||||
|
plaid_item = plaid_items(:one)
|
||||||
|
account = accounts(:connected)
|
||||||
|
|
||||||
|
family_sync = Sync.create!(syncable: family)
|
||||||
|
plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync)
|
||||||
|
account_sync = Sync.create!(syncable: account, parent: plaid_item_sync)
|
||||||
|
|
||||||
|
assert_equal "pending", family_sync.status
|
||||||
|
assert_equal "pending", plaid_item_sync.status
|
||||||
|
assert_equal "pending", account_sync.status
|
||||||
|
|
||||||
|
family.expects(:perform_sync).with(family_sync).raises(StandardError.new("test family sync error"))
|
||||||
|
|
||||||
|
family_sync.perform
|
||||||
|
|
||||||
|
assert_equal "failed", family_sync.reload.status
|
||||||
|
|
||||||
|
plaid_item.expects(:perform_sync).with(plaid_item_sync).raises(StandardError.new("test plaid item sync error"))
|
||||||
|
|
||||||
|
plaid_item_sync.perform
|
||||||
|
|
||||||
|
assert_equal "failed", family_sync.reload.status
|
||||||
|
assert_equal "failed", plaid_item_sync.reload.status
|
||||||
|
|
||||||
|
# Leaf level sync succeeds, but shouldn't change the status of the already-failed parent syncs
|
||||||
|
account.expects(:perform_sync).with(account_sync).once
|
||||||
|
|
||||||
|
# Since these are accessed through `parent`, they won't necessarily be the same
|
||||||
|
# instance we configured above
|
||||||
|
Account.any_instance.expects(:perform_post_sync).once
|
||||||
|
PlaidItem.any_instance.expects(:perform_post_sync).once
|
||||||
|
Family.any_instance.expects(:perform_post_sync).once
|
||||||
|
|
||||||
|
Account.any_instance.expects(:broadcast_sync_complete).once
|
||||||
|
PlaidItem.any_instance.expects(:broadcast_sync_complete).once
|
||||||
|
Family.any_instance.expects(:broadcast_sync_complete).once
|
||||||
|
|
||||||
|
account_sync.perform
|
||||||
|
|
||||||
|
assert_equal "failed", plaid_item_sync.reload.status
|
||||||
|
assert_equal "failed", family_sync.reload.status
|
||||||
|
assert_equal "completed", account_sync.reload.status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,6 +17,7 @@ require "rails/test_help"
|
||||||
require "minitest/mock"
|
require "minitest/mock"
|
||||||
require "minitest/autorun"
|
require "minitest/autorun"
|
||||||
require "mocha/minitest"
|
require "mocha/minitest"
|
||||||
|
require "aasm/minitest"
|
||||||
|
|
||||||
VCR.configure do |config|
|
VCR.configure do |config|
|
||||||
config.cassette_library_dir = "test/vcr_cassettes"
|
config.cassette_library_dir = "test/vcr_cassettes"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue