mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
Merge remote-tracking branch 'origin/main' into simple-fin-integration
This commit is contained in:
commit
3f11e357c3
150 changed files with 3258 additions and 2459 deletions
5
Gemfile
5
Gemfile
|
@ -29,6 +29,7 @@ gem "hotwire_combobox"
|
|||
|
||||
# Background Jobs
|
||||
gem "sidekiq"
|
||||
gem "sidekiq-cron"
|
||||
|
||||
# Monitoring
|
||||
gem "vernier"
|
||||
|
@ -63,6 +64,10 @@ gem "rotp", "~> 6.3"
|
|||
gem "rqrcode", "~> 3.0"
|
||||
gem "activerecord-import"
|
||||
|
||||
# State machines
|
||||
gem "aasm"
|
||||
gem "after_commit_everywhere", "~> 1.0"
|
||||
|
||||
# AI
|
||||
gem "ruby-openai"
|
||||
|
||||
|
|
23
Gemfile.lock
23
Gemfile.lock
|
@ -8,6 +8,8 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
aasm (5.5.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
actioncable (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
|
@ -83,6 +85,9 @@ GEM
|
|||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
after_commit_everywhere (1.6.0)
|
||||
activerecord (>= 4.2)
|
||||
activesupport
|
||||
ast (2.4.3)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1093.0)
|
||||
|
@ -139,6 +144,9 @@ GEM
|
|||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
cronex (0.15.0)
|
||||
tzinfo
|
||||
unicode (>= 0.4.4.5)
|
||||
css_parser (1.21.1)
|
||||
addressable
|
||||
csv (3.3.4)
|
||||
|
@ -160,6 +168,8 @@ GEM
|
|||
rubocop (>= 1)
|
||||
smart_properties
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
event_stream_parser (1.0.0)
|
||||
faker (3.5.1)
|
||||
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-musl)
|
||||
foreman (0.88.1)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
hashdiff (1.1.2)
|
||||
|
@ -346,6 +359,7 @@ GEM
|
|||
public_suffix (6.0.1)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.13)
|
||||
rack-mini-profiler (3.3.1)
|
||||
|
@ -486,6 +500,11 @@ GEM
|
|||
logger (>= 1.6.2)
|
||||
rack (>= 3.1.0)
|
||||
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)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
|
@ -519,6 +538,7 @@ GEM
|
|||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode (0.4.4.5)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
|
@ -561,7 +581,9 @@ PLATFORMS
|
|||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
aasm
|
||||
activerecord-import
|
||||
after_commit_everywhere (~> 1.0)
|
||||
aws-sdk-s3 (~> 1.177.0)
|
||||
bcrypt (~> 3.1)
|
||||
benchmark-ips
|
||||
|
@ -612,6 +634,7 @@ DEPENDENCIES
|
|||
sentry-ruby
|
||||
sentry-sidekiq
|
||||
sidekiq
|
||||
sidekiq-cron
|
||||
simplecov
|
||||
skylight
|
||||
stimulus-rails
|
||||
|
|
|
@ -429,5 +429,3 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -78,6 +78,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
@utility bg-divider {
|
||||
@apply bg-alpha-black-100;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply bg-alpha-white-100;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-overlay {
|
||||
background-color: --alpha(var(--color-gray-100) / 50%);
|
||||
|
||||
|
@ -85,3 +93,7 @@
|
|||
background-color: var(--color-alpha-black-900);
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-loader {
|
||||
@apply bg-surface-inset animate-pulse;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<%= tag.div data: {
|
||||
controller: "tabs",
|
||||
testid: testid,
|
||||
tabs_session_key_value: session_key,
|
||||
tabs_url_param_key_value: url_param_key,
|
||||
tabs_nav_btn_active_class: active_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
|
||||
@url_param_key = url_param_key
|
||||
@session_key = session_key
|
||||
@variant = variant.to_sym
|
||||
@active_btn_classes = active_btn_classes
|
||||
@inactive_btn_classes = inactive_btn_classes
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Controller } from "@hotwired/stimulus";
|
|||
export default class extends Controller {
|
||||
static classes = ["navBtnActive", "navBtnInactive"];
|
||||
static targets = ["panel", "navBtn"];
|
||||
static values = { urlParamKey: String };
|
||||
static values = { sessionKey: String, urlParamKey: String };
|
||||
|
||||
show(e) {
|
||||
const btn = e.target.closest("button");
|
||||
|
@ -28,11 +28,30 @@ export default class extends Controller {
|
|||
}
|
||||
});
|
||||
|
||||
// Update URL with the selected tab
|
||||
if (this.urlParamKeyValue) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(this.urlParamKeyValue, selectedTabId);
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,14 +27,6 @@ class AccountsController < ApplicationController
|
|||
render layout: false
|
||||
end
|
||||
|
||||
def sync_all
|
||||
unless family.syncing?
|
||||
family.sync_later
|
||||
end
|
||||
|
||||
redirect_back_or_to accounts_path
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
Current.family
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
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
|
||||
|
||||
before_action :detect_os
|
||||
|
|
|
@ -11,9 +11,11 @@ module AutoSync
|
|||
end
|
||||
|
||||
def family_needs_auto_sync?
|
||||
return false unless Current.family.present?
|
||||
return false unless Current.family.accounts.active.any?
|
||||
return false unless Current.family&.accounts&.active&.any?
|
||||
return false if (Current.family.last_sync_created_at&.to_date || 1.day.ago) >= Date.current
|
||||
|
||||
(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
|
||||
Rails.logger.info "Auto-syncing family #{Current.family.id}, last sync was #{Current.family.last_sync_created_at}"
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
|
|
@ -46,8 +46,6 @@ module Notifiable
|
|||
[ { partial: "shared/notifications/alert", locals: { message: data } } ]
|
||||
when "cta"
|
||||
[ resolve_cta(data) ]
|
||||
when "loading"
|
||||
[ { partial: "shared/notifications/loading", locals: { message: data } } ]
|
||||
when "notice"
|
||||
messages = Array(data)
|
||||
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]
|
||||
|
||||
def create
|
||||
Current.family.plaid_items.create_from_public_token(
|
||||
plaid_item_params[:public_token],
|
||||
Current.family.create_plaid_item!(
|
||||
public_token: plaid_item_params[:public_token],
|
||||
item_name: item_name,
|
||||
region: plaid_item_params[:region]
|
||||
)
|
||||
|
|
|
@ -4,7 +4,14 @@ class RulesController < ApplicationController
|
|||
before_action :set_rule, only: [ :edit, :update, :destroy, :apply, :confirm ]
|
||||
|
||||
def index
|
||||
@rules = Current.family.rules.order(created_at: :desc)
|
||||
@sort_by = params[:sort_by] || "name"
|
||||
@direction = params[:direction] || "asc"
|
||||
|
||||
allowed_columns = [ "name", "updated_at" ]
|
||||
@sort_by = "name" unless allowed_columns.include?(@sort_by)
|
||||
@direction = "asc" unless [ "asc", "desc" ].include?(@direction)
|
||||
|
||||
@rules = Current.family.rules.order(@sort_by => @direction)
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
|
@ -64,7 +71,7 @@ class RulesController < ApplicationController
|
|||
|
||||
def rule_params
|
||||
params.require(:rule).permit(
|
||||
:resource_type, :effective_date, :active,
|
||||
:resource_type, :effective_date, :active, :name,
|
||||
conditions_attributes: [
|
||||
:id, :condition_type, :operator, :value, :_destroy,
|
||||
sub_conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy ]
|
||||
|
|
|
@ -21,12 +21,23 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
remove(e) {
|
||||
// Find the parent rules controller before removing the condition
|
||||
const rulesEl = this.element.closest('[data-controller~="rules"]');
|
||||
|
||||
if (e.params.destroy) {
|
||||
this.destroyFieldTarget.value = true;
|
||||
this.element.classList.add("hidden");
|
||||
} else {
|
||||
this.element.remove();
|
||||
}
|
||||
|
||||
// Update the prefixes of all conditions from the parent rules controller
|
||||
if (rulesEl) {
|
||||
const rulesController = this.application.getControllerForElementAndIdentifier(rulesEl, "rules");
|
||||
if (rulesController && typeof rulesController.updateConditionPrefixes === "function") {
|
||||
rulesController.updateConditionPrefixes();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleConditionTypeChange(e) {
|
||||
|
|
|
@ -11,11 +11,17 @@ export default class extends Controller {
|
|||
"effectiveDateInput",
|
||||
];
|
||||
|
||||
connect() {
|
||||
// Update condition prefixes on first connection (form render on edit)
|
||||
this.updateConditionPrefixes();
|
||||
}
|
||||
|
||||
addConditionGroup() {
|
||||
this.#appendTemplate(
|
||||
this.conditionGroupTemplateTarget,
|
||||
this.conditionsListTarget,
|
||||
);
|
||||
this.updateConditionPrefixes();
|
||||
}
|
||||
|
||||
addCondition() {
|
||||
|
@ -23,6 +29,7 @@ export default class extends Controller {
|
|||
this.conditionTemplateTarget,
|
||||
this.conditionsListTarget,
|
||||
);
|
||||
this.updateConditionPrefixes();
|
||||
}
|
||||
|
||||
addAction() {
|
||||
|
@ -45,4 +52,27 @@ export default class extends Controller {
|
|||
#uniqueKey() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
// Updates the prefix visibility of all conditions and condition groups
|
||||
// This is also called by the rule/conditions_controller when a subcondition is removed
|
||||
updateConditionPrefixes() {
|
||||
const conditions = Array.from(this.conditionsListTarget.children);
|
||||
let conditionIndex = 0;
|
||||
|
||||
conditions.forEach((condition) => {
|
||||
// Only process visible conditions, this prevents conditions that are marked for removal and hidden
|
||||
// from being added to the index. This is important when editing a rule.
|
||||
if (!condition.classList.contains('hidden')) {
|
||||
const prefixEl = condition.querySelector('[data-condition-prefix]');
|
||||
if (prefixEl) {
|
||||
if (conditionIndex === 0) {
|
||||
prefixEl.classList.add('hidden');
|
||||
} else {
|
||||
prefixEl.classList.remove('hidden');
|
||||
}
|
||||
conditionIndex++;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
20
app/jobs/import_market_data_job.rb
Normal file
20
app/jobs/import_market_data_job.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
# This job runs daily at market close. See config/schedule.yml for details.
|
||||
#
|
||||
# The primary purpose of this job is to:
|
||||
# 1. Determine what exchange rate pairs, security prices, and other market data all of our users need to view historical account balance data
|
||||
# 2. For each needed rate/price, fetch from our data provider and upsert to our database
|
||||
#
|
||||
# Each individual account sync will still fetch any missing market data that isn't yet synced, but by running
|
||||
# this job daily, we significantly reduce overlapping account syncs that both need the same market data (e.g. common security like `AAPL`)
|
||||
#
|
||||
class ImportMarketDataJob < ApplicationJob
|
||||
queue_as :scheduled
|
||||
|
||||
def perform(opts)
|
||||
opts = opts.symbolize_keys
|
||||
mode = opts.fetch(:mode, :full)
|
||||
clear_cache = opts.fetch(:clear_cache, false)
|
||||
|
||||
MarketDataImporter.new(mode: mode, clear_cache: clear_cache).import_all
|
||||
end
|
||||
end
|
7
app/jobs/sync_cleaner_job.rb
Normal file
7
app/jobs/sync_cleaner_job.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
class SyncCleanerJob < ApplicationJob
|
||||
queue_as :scheduled
|
||||
|
||||
def perform
|
||||
Sync.clean
|
||||
end
|
||||
end
|
|
@ -1,5 +1,5 @@
|
|||
class Account < ApplicationRecord
|
||||
include Syncable, Monetizable, Chartable, Linkable, Convertible, Enrichable
|
||||
include Syncable, Monetizable, Chartable, Linkable, Enrichable
|
||||
|
||||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
|
@ -64,6 +64,18 @@ class Account < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def syncing?
|
||||
self_syncing = syncs.visible.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
|
||||
url_string = if plaid_account.present?
|
||||
plaid_account.plaid_item&.institution_url
|
||||
|
@ -92,21 +104,6 @@ class Account < ApplicationRecord
|
|||
DestroyJob.perform_later(self)
|
||||
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
|
||||
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
|
||||
end
|
||||
|
@ -187,10 +184,4 @@ class Account < ApplicationRecord
|
|||
def destroy_associated_provider_accounts
|
||||
simple_fin_account.destroy if simple_fin_account.present?
|
||||
end
|
||||
|
||||
private
|
||||
def sync_balances
|
||||
strategy = linked? ? :reverse : :forward
|
||||
Balance::Syncer.new(self, strategy: strategy).sync_balances
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
module Account::Convertible
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def sync_required_exchange_rates
|
||||
unless requires_exchange_rates?
|
||||
Rails.logger.info("No exchange rate sync needed for account #{id}")
|
||||
return
|
||||
end
|
||||
|
||||
affected_row_count = ExchangeRate.sync_provider_rates(
|
||||
from: currency,
|
||||
to: target_currency,
|
||||
start_date: start_date,
|
||||
)
|
||||
|
||||
Rails.logger.info("Synced #{affected_row_count} exchange rates for account #{id}")
|
||||
end
|
||||
|
||||
private
|
||||
def target_currency
|
||||
family.currency
|
||||
end
|
||||
|
||||
def requires_exchange_rates?
|
||||
currency != target_currency
|
||||
end
|
||||
end
|
82
app/models/account/market_data_importer.rb
Normal file
82
app/models/account/market_data_importer.rb
Normal file
|
@ -0,0 +1,82 @@
|
|||
class Account::MarketDataImporter
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def import_all
|
||||
import_exchange_rates
|
||||
import_security_prices
|
||||
end
|
||||
|
||||
def import_exchange_rates
|
||||
return unless needs_exchange_rates?
|
||||
return unless ExchangeRate.provider
|
||||
|
||||
pair_dates = {}
|
||||
|
||||
# 1. ENTRY-BASED PAIRS – currencies that differ from the account currency
|
||||
account.entries
|
||||
.where.not(currency: account.currency)
|
||||
.group(:currency)
|
||||
.minimum(:date)
|
||||
.each do |source_currency, date|
|
||||
key = [ source_currency, account.currency ]
|
||||
pair_dates[key] = [ pair_dates[key], date ].compact.min
|
||||
end
|
||||
|
||||
# 2. ACCOUNT-BASED PAIR – convert the account currency to the family currency (if different)
|
||||
if foreign_account?
|
||||
key = [ account.currency, account.family.currency ]
|
||||
pair_dates[key] = [ pair_dates[key], account.start_date ].compact.min
|
||||
end
|
||||
|
||||
pair_dates.each do |(source, target), start_date|
|
||||
ExchangeRate.import_provider_rates(
|
||||
from: source,
|
||||
to: target,
|
||||
start_date: start_date,
|
||||
end_date: Date.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def import_security_prices
|
||||
return unless Security.provider
|
||||
|
||||
account_securities = account.trades.map(&:security).uniq
|
||||
|
||||
return if account_securities.empty?
|
||||
|
||||
account_securities.each do |security|
|
||||
security.import_provider_prices(
|
||||
start_date: first_required_price_date(security),
|
||||
end_date: Date.current
|
||||
)
|
||||
|
||||
security.import_provider_details
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Calculates the first date we require a price for the given security scoped to this account
|
||||
def first_required_price_date(security)
|
||||
account.trades.with_entry
|
||||
.where(security: security)
|
||||
.where(entries: { account_id: account.id })
|
||||
.minimum("entries.date")
|
||||
end
|
||||
|
||||
def needs_exchange_rates?
|
||||
has_multi_currency_entries? || foreign_account?
|
||||
end
|
||||
|
||||
def has_multi_currency_entries?
|
||||
account.entries.where.not(currency: account.currency).exists?
|
||||
end
|
||||
|
||||
def foreign_account?
|
||||
account.currency != account.family.currency
|
||||
end
|
||||
end
|
63
app/models/account/sync_complete_event.rb
Normal file
63
app/models/account/sync_complete_event.rb
Normal file
|
@ -0,0 +1,63 @@
|
|||
class Account::SyncCompleteEvent
|
||||
attr_reader :account
|
||||
|
||||
Error = Class.new(StandardError)
|
||||
|
||||
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
|
||||
unless account_group.present?
|
||||
error = Error.new("Account #{account.id} is not part of an account group")
|
||||
Rails.logger.warn(error.message)
|
||||
Sentry.capture_exception(error, level: :warning)
|
||||
return []
|
||||
end
|
||||
|
||||
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
|
37
app/models/account/syncer.rb
Normal file
37
app/models/account/syncer.rb
Normal file
|
@ -0,0 +1,37 @@
|
|||
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'})")
|
||||
import_market_data
|
||||
materialize_balances
|
||||
end
|
||||
|
||||
def perform_post_sync
|
||||
account.family.auto_match_transfers!
|
||||
end
|
||||
|
||||
private
|
||||
def materialize_balances
|
||||
strategy = account.linked? ? :reverse : :forward
|
||||
Balance::Materializer.new(account, strategy: strategy).materialize_balances
|
||||
end
|
||||
|
||||
# Syncs all the exchange rates + security prices this account needs to display historical chart data
|
||||
#
|
||||
# This is a *supplemental* sync. The daily market data sync should have already populated
|
||||
# a majority or all of this data, so this is often a no-op.
|
||||
#
|
||||
# We rescue errors here because if this operation fails, we don't want to fail the entire sync since
|
||||
# we have reasonable fallbacks for missing market data.
|
||||
def import_market_data
|
||||
Account::MarketDataImporter.new(account).import_all
|
||||
rescue => e
|
||||
Rails.logger.error("Error syncing market data for account #{account.id}: #{e.message}")
|
||||
Sentry.capture_exception(e)
|
||||
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
|
||||
def calculate_balances
|
||||
current_cash_balance = 0
|
||||
|
@ -25,4 +37,25 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
|
|||
|
||||
@balances
|
||||
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
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
class Balance::Syncer
|
||||
class Balance::Materializer
|
||||
attr_reader :account, :strategy
|
||||
|
||||
def initialize(account, strategy:)
|
||||
|
@ -6,9 +6,9 @@ class Balance::Syncer
|
|||
@strategy = strategy
|
||||
end
|
||||
|
||||
def sync_balances
|
||||
def materialize_balances
|
||||
Balance.transaction do
|
||||
sync_holdings
|
||||
materialize_holdings
|
||||
calculate_balances
|
||||
|
||||
Rails.logger.info("Persisting #{@balances.size} balances")
|
||||
|
@ -19,14 +19,12 @@ class Balance::Syncer
|
|||
if strategy == :forward
|
||||
update_account_info
|
||||
end
|
||||
|
||||
account.sync_required_exchange_rates
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def sync_holdings
|
||||
@holdings = Holding::Syncer.new(account, strategy: strategy).sync_holdings
|
||||
def materialize_holdings
|
||||
@holdings = Holding::Materializer.new(account, strategy: strategy).materialize_holdings
|
||||
end
|
||||
|
||||
def update_account_info
|
|
@ -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
|
||||
def calculate_balances
|
||||
current_cash_balance = account.cash_balance
|
||||
|
@ -35,4 +47,25 @@ class Balance::ReverseCalculator < Balance::BaseCalculator
|
|||
|
||||
@balances
|
||||
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
|
||||
|
|
|
@ -22,20 +22,25 @@ class BalanceSheet
|
|||
end
|
||||
|
||||
def classification_groups
|
||||
asset_groups = account_groups("asset")
|
||||
liability_groups = account_groups("liability")
|
||||
|
||||
[
|
||||
ClassificationGroup.new(
|
||||
key: "asset",
|
||||
display_name: "Assets",
|
||||
icon: "plus",
|
||||
total_money: total_assets_money,
|
||||
account_groups: account_groups("asset")
|
||||
account_groups: asset_groups,
|
||||
syncing?: asset_groups.any?(&:syncing?)
|
||||
),
|
||||
ClassificationGroup.new(
|
||||
key: "liability",
|
||||
display_name: "Debts",
|
||||
icon: "minus",
|
||||
total_money: total_liabilities_money,
|
||||
account_groups: account_groups("liability")
|
||||
account_groups: liability_groups,
|
||||
syncing?: liability_groups.any?(&:syncing?)
|
||||
)
|
||||
]
|
||||
end
|
||||
|
@ -43,13 +48,17 @@ class BalanceSheet
|
|||
def account_groups(classification = nil)
|
||||
classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query
|
||||
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)
|
||||
|
||||
key = accountable.model_name.param_key
|
||||
|
||||
AccountGroup.new(
|
||||
key: accountable.model_name.param_key,
|
||||
id: classification ? "#{classification}_#{key}_group" : "#{key}_group",
|
||||
key: key,
|
||||
name: accountable.display_name,
|
||||
classification: accountable.classification,
|
||||
total: group_total,
|
||||
|
@ -57,6 +66,7 @@ class BalanceSheet
|
|||
weight: classification_total.zero? ? 0 : group_total / classification_total.to_d * 100,
|
||||
missing_rates?: accounts.any? { |a| a.missing_rates? },
|
||||
color: accountable.color,
|
||||
syncing?: accounts.any?(&:is_syncing),
|
||||
accounts: accounts.map do |account|
|
||||
account.define_singleton_method(:weight) do
|
||||
classification_total.zero? ? 0 : account.converted_balance / classification_total.to_d * 100
|
||||
|
@ -65,7 +75,13 @@ class BalanceSheet
|
|||
account
|
||||
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
|
||||
|
||||
def net_worth_series(period: Period.last_30_days)
|
||||
|
@ -76,9 +92,13 @@ class BalanceSheet
|
|||
family.currency
|
||||
end
|
||||
|
||||
def syncing?
|
||||
classification_groups.any? { |group| group.syncing? }
|
||||
end
|
||||
|
||||
private
|
||||
ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, keyword_init: true)
|
||||
AccountGroup = Struct.new(:key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, keyword_init: true)
|
||||
ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, :syncing?, 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
|
||||
family.accounts.active.with_attached_logo
|
||||
|
@ -87,9 +107,15 @@ class BalanceSheet
|
|||
def totals_query
|
||||
@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 syncs ON syncs.syncable_id = accounts.id AND syncs.syncable_type = 'Account' AND syncs.status IN (?) AND syncs.created_at > ?",
|
||||
%w[pending syncing],
|
||||
Sync::VISIBLE_FOR.ago
|
||||
]))
|
||||
.select(
|
||||
"accounts.*",
|
||||
"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 ])
|
||||
)
|
||||
.group(:classification, :accountable_type, :id)
|
||||
|
|
|
@ -68,15 +68,6 @@ module Accountable
|
|||
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
|
||||
self.class.display_name
|
||||
end
|
||||
|
|
|
@ -6,24 +6,35 @@ module Syncable
|
|||
end
|
||||
|
||||
def syncing?
|
||||
syncs.where(status: [ :syncing, :pending ]).any?
|
||||
raise NotImplementedError, "Subclasses must implement the syncing? method"
|
||||
end
|
||||
|
||||
def sync_later(start_date: nil, parent_sync: nil)
|
||||
new_sync = syncs.create!(start_date: start_date, parent: parent_sync)
|
||||
def sync_later(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
||||
Sync.transaction do
|
||||
# Expire any previous in-flight syncs for this record that exceeded the
|
||||
# global staleness window.
|
||||
syncs.stale_candidates.find_each(&:mark_stale!)
|
||||
|
||||
new_sync = syncs.create!(
|
||||
parent: parent_sync,
|
||||
window_start_date: window_start_date,
|
||||
window_end_date: window_end_date
|
||||
)
|
||||
|
||||
SyncJob.perform_later(new_sync)
|
||||
end
|
||||
|
||||
def sync(start_date: nil)
|
||||
syncs.create!(start_date: start_date).perform
|
||||
end
|
||||
|
||||
def sync_data(sync, start_date: nil)
|
||||
raise NotImplementedError, "Subclasses must implement the `sync_data` method"
|
||||
def perform_sync(sync)
|
||||
syncer.perform_sync(sync)
|
||||
end
|
||||
|
||||
def post_sync(sync)
|
||||
# no-op, syncable can optionally provide implementation
|
||||
def perform_post_sync
|
||||
syncer.perform_post_sync
|
||||
end
|
||||
|
||||
def broadcast_sync_complete
|
||||
sync_broadcaster.broadcast
|
||||
end
|
||||
|
||||
def sync_error
|
||||
|
@ -31,11 +42,23 @@ module Syncable
|
|||
end
|
||||
|
||||
def last_synced_at
|
||||
latest_sync&.last_ran_at
|
||||
latest_sync&.completed_at
|
||||
end
|
||||
|
||||
def last_sync_created_at
|
||||
latest_sync&.created_at
|
||||
end
|
||||
|
||||
private
|
||||
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
|
||||
|
|
|
@ -45,7 +45,7 @@ class Entry < ApplicationRecord
|
|||
|
||||
def sync_account_later
|
||||
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
|
||||
|
||||
def entryable_name_short
|
||||
|
|
156
app/models/exchange_rate/importer.rb
Normal file
156
app/models/exchange_rate/importer.rb
Normal file
|
@ -0,0 +1,156 @@
|
|||
class ExchangeRate::Importer
|
||||
MissingExchangeRateError = Class.new(StandardError)
|
||||
MissingStartRateError = Class.new(StandardError)
|
||||
|
||||
def initialize(exchange_rate_provider:, from:, to:, start_date:, end_date:, clear_cache: false)
|
||||
@exchange_rate_provider = exchange_rate_provider
|
||||
@from = from
|
||||
@to = to
|
||||
@start_date = start_date
|
||||
@end_date = normalize_end_date(end_date)
|
||||
@clear_cache = clear_cache
|
||||
end
|
||||
|
||||
# Constructs a daily series of rates for the given currency pair for date range
|
||||
def import_provider_rates
|
||||
if !clear_cache && all_rates_exist?
|
||||
Rails.logger.info("No new rates to sync for #{from} to #{to} between #{start_date} and #{end_date}, skipping")
|
||||
return
|
||||
end
|
||||
|
||||
if clear_cache && provider_rates.empty?
|
||||
Rails.logger.warn("Could not clear cache for #{from} to #{to} between #{start_date} and #{end_date} because provider returned no rates")
|
||||
return
|
||||
end
|
||||
|
||||
prev_rate_value = start_rate_value
|
||||
|
||||
unless prev_rate_value.present?
|
||||
error = MissingStartRateError.new("Could not find a start rate for #{from} to #{to} between #{start_date} and #{end_date}")
|
||||
Rails.logger.error(error.message)
|
||||
Sentry.capture_exception(error)
|
||||
return
|
||||
end
|
||||
|
||||
gapfilled_rates = effective_start_date.upto(end_date).map do |date|
|
||||
db_rate_value = db_rates[date]&.rate
|
||||
provider_rate_value = provider_rates[date]&.rate
|
||||
|
||||
chosen_rate = if clear_cache
|
||||
provider_rate_value || db_rate_value # overwrite when possible
|
||||
else
|
||||
db_rate_value || provider_rate_value # fill gaps
|
||||
end
|
||||
|
||||
# Gapfill with LOCF strategy (last observation carried forward)
|
||||
if chosen_rate.nil?
|
||||
chosen_rate = prev_rate_value
|
||||
end
|
||||
|
||||
prev_rate_value = chosen_rate
|
||||
|
||||
{
|
||||
from_currency: from,
|
||||
to_currency: to,
|
||||
date: date,
|
||||
rate: chosen_rate
|
||||
}
|
||||
end
|
||||
|
||||
upsert_rows(gapfilled_rates)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :exchange_rate_provider, :from, :to, :start_date, :end_date, :clear_cache
|
||||
|
||||
def upsert_rows(rows)
|
||||
batch_size = 200
|
||||
|
||||
total_upsert_count = 0
|
||||
|
||||
rows.each_slice(batch_size) do |batch|
|
||||
upserted_ids = ExchangeRate.upsert_all(
|
||||
batch,
|
||||
unique_by: %i[from_currency to_currency date],
|
||||
returning: [ "id" ]
|
||||
)
|
||||
|
||||
total_upsert_count += upserted_ids.count
|
||||
end
|
||||
|
||||
total_upsert_count
|
||||
end
|
||||
|
||||
# Since provider may not return values on weekends and holidays, we grab the first rate from the provider that is on or before the start date
|
||||
def start_rate_value
|
||||
provider_rate_value = provider_rates.select { |date, _| date <= start_date }.max_by { |date, _| date }&.last
|
||||
db_rate_value = db_rates[start_date]&.rate
|
||||
provider_rate_value || db_rate_value
|
||||
end
|
||||
|
||||
# No need to fetch/upsert rates for dates that we already have in the DB
|
||||
def effective_start_date
|
||||
return start_date if clear_cache
|
||||
|
||||
first_missing_date = nil
|
||||
|
||||
start_date.upto(end_date) do |date|
|
||||
unless db_rates.key?(date)
|
||||
first_missing_date = date
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
first_missing_date || end_date
|
||||
end
|
||||
|
||||
def provider_rates
|
||||
@provider_rates ||= begin
|
||||
# Always fetch with a 5 day buffer to ensure we have a starting rate (for weekends and holidays)
|
||||
provider_fetch_start_date = effective_start_date - 5.days
|
||||
|
||||
provider_response = exchange_rate_provider.fetch_exchange_rates(
|
||||
from: from,
|
||||
to: to,
|
||||
start_date: provider_fetch_start_date,
|
||||
end_date: end_date
|
||||
)
|
||||
|
||||
if provider_response.success?
|
||||
provider_response.data.index_by(&:date)
|
||||
else
|
||||
message = "#{exchange_rate_provider.class.name} could not fetch exchange rate pair from: #{from} to: #{to} between: #{effective_start_date} and: #{Date.current}. Provider error: #{provider_response.error.message}"
|
||||
Rails.logger.warn(message)
|
||||
Sentry.capture_exception(MissingExchangeRateError.new(message), level: :warning)
|
||||
{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def all_rates_exist?
|
||||
db_count == expected_count
|
||||
end
|
||||
|
||||
def expected_count
|
||||
(start_date..end_date).count
|
||||
end
|
||||
|
||||
def db_count
|
||||
db_rates.count
|
||||
end
|
||||
|
||||
def db_rates
|
||||
@db_rates ||= ExchangeRate.where(from_currency: from, to_currency: to, date: start_date..end_date)
|
||||
.order(:date)
|
||||
.to_a
|
||||
.index_by(&:date)
|
||||
end
|
||||
|
||||
# Normalizes an end date so that it never exceeds today's date in the
|
||||
# America/New_York timezone. If the caller passes a future date we clamp
|
||||
# it to today so that upstream provider calls remain valid and predictable.
|
||||
def normalize_end_date(requested_end_date)
|
||||
today_est = Date.current.in_time_zone("America/New_York").to_date
|
||||
[ requested_end_date, today_est ].min
|
||||
end
|
||||
end
|
|
@ -27,29 +27,21 @@ module ExchangeRate::Provided
|
|||
rate
|
||||
end
|
||||
|
||||
def sync_provider_rates(from:, to:, start_date:, end_date: Date.current)
|
||||
# @return [Integer] The number of exchange rates synced
|
||||
def import_provider_rates(from:, to:, start_date:, end_date:, clear_cache: false)
|
||||
unless provider.present?
|
||||
Rails.logger.warn("No provider configured for ExchangeRate.sync_provider_rates")
|
||||
Rails.logger.warn("No provider configured for ExchangeRate.import_provider_rates")
|
||||
return 0
|
||||
end
|
||||
|
||||
fetched_rates = provider.fetch_exchange_rates(from: from, to: to, start_date: start_date, end_date: end_date)
|
||||
|
||||
unless fetched_rates.success?
|
||||
Rails.logger.error("Provider error for ExchangeRate.sync_provider_rates: #{fetched_rates.error}")
|
||||
return 0
|
||||
end
|
||||
|
||||
rates_data = fetched_rates.data.map do |rate|
|
||||
{
|
||||
from_currency: rate.from,
|
||||
to_currency: rate.to,
|
||||
date: rate.date,
|
||||
rate: rate.rate
|
||||
}
|
||||
end
|
||||
|
||||
ExchangeRate.upsert_all(rates_data, unique_by: %i[from_currency to_currency date])
|
||||
ExchangeRate::Importer.new(
|
||||
exchange_rate_provider: provider,
|
||||
from: from,
|
||||
to: to,
|
||||
start_date: start_date,
|
||||
end_date: end_date,
|
||||
clear_cache: clear_cache
|
||||
).import_provider_rates
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class Family < ApplicationRecord
|
||||
include Syncable, AutoTransferMatchable, Subscribeable
|
||||
include PlaidConnectable, Syncable, AutoTransferMatchable, Subscribeable
|
||||
|
||||
DATE_FORMATS = [
|
||||
[ "MM-DD-YYYY", "%m-%d-%Y" ],
|
||||
|
@ -15,8 +15,6 @@ class Family < ApplicationRecord
|
|||
|
||||
has_many :users, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :plaid_items, dependent: :destroy
|
||||
has_many :simple_fin_items, dependent: :destroy
|
||||
has_many :invitations, dependent: :destroy
|
||||
|
||||
has_many :imports, dependent: :destroy
|
||||
|
@ -37,6 +35,15 @@ class Family < ApplicationRecord
|
|||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||
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)
|
||||
.visible
|
||||
.exists?
|
||||
end
|
||||
|
||||
def assigned_merchants
|
||||
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
|
||||
Merchant.where(id: merchant_ids)
|
||||
|
@ -66,76 +73,10 @@ class Family < ApplicationRecord
|
|||
@income_statement ||= IncomeStatement.new(self)
|
||||
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("Syncing simple_fin items for family #{id}")
|
||||
simple_fin_items.each do |simple_fin_item|
|
||||
simple_fin_item.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 = ?)) OR
|
||||
(syncable_type = 'SimpleFinItem' AND syncable_id IN (SELECT id FROM simple_fin_items WHERE family_id = ?))",
|
||||
id, id, id, id
|
||||
).where(status: [ "pending", "syncing" ], created_at: 10.minutes.ago..).exists?
|
||||
end
|
||||
|
||||
def eu?
|
||||
country != "US" && country != "CA"
|
||||
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 get_simple_fin_available(accountable_type: nil)
|
||||
provider = Provider::Registry.get_provider(:simple_fin)
|
||||
|
||||
provider.is_available(id, accountable_type)
|
||||
end
|
||||
|
||||
def requires_data_provider?
|
||||
# If family has any trades, they need a provider for historical prices
|
||||
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,7 +72,6 @@ module Family::Subscribeable
|
|||
(1 - days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100
|
||||
end
|
||||
|
||||
private
|
||||
def sync_trial_status!
|
||||
if subscription&.status == "trialing" && days_left_in_trial < 0
|
||||
subscription.update!(status: "paused")
|
||||
|
|
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
|
||||
private
|
||||
def portfolio_cache
|
||||
@portfolio_cache ||= Holding::PortfolioCache.new(account)
|
||||
class Holding::ForwardCalculator
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def calculate_holdings
|
||||
def calculate
|
||||
Rails.logger.tagged("Holding::ForwardCalculator") do
|
||||
current_portfolio = generate_starting_portfolio
|
||||
next_portfolio = {}
|
||||
holdings = []
|
||||
|
@ -16,6 +18,55 @@ class Holding::ForwardCalculator < Holding::BaseCalculator
|
|||
current_portfolio = next_portfolio
|
||||
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
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
class Holding::Syncer
|
||||
# "Materializes" holdings (similar to a DB materialized view, but done at the app level)
|
||||
# into a series of records we can easily query and join with other data.
|
||||
class Holding::Materializer
|
||||
def initialize(account, strategy:)
|
||||
@account = account
|
||||
@strategy = strategy
|
||||
end
|
||||
|
||||
def sync_holdings
|
||||
def materialize_holdings
|
||||
calculate_holdings
|
||||
|
||||
Rails.logger.info("Persisting #{@holdings.size} holdings")
|
|
@ -83,9 +83,6 @@ class Holding::PortfolioCache
|
|||
securities.each do |security|
|
||||
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)
|
||||
db_prices = security.prices.where(date: account.start_date..Date.current).map do |price|
|
||||
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
|
||||
# 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
|
||||
|
@ -25,6 +38,11 @@ class Holding::ReverseCalculator < Holding::BaseCalculator
|
|||
holdings
|
||||
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
|
||||
def generate_starting_portfolio
|
||||
holding_quantities = empty_portfolio
|
||||
|
@ -37,4 +55,38 @@ class Holding::ReverseCalculator < Holding::BaseCalculator
|
|||
|
||||
holding_quantities
|
||||
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
|
||||
|
|
|
@ -62,7 +62,7 @@ class Import < ApplicationRecord
|
|||
def publish
|
||||
import!
|
||||
|
||||
family.sync
|
||||
family.sync_later
|
||||
|
||||
update! status: :complete
|
||||
rescue => error
|
||||
|
|
132
app/models/market_data_importer.rb
Normal file
132
app/models/market_data_importer.rb
Normal file
|
@ -0,0 +1,132 @@
|
|||
class MarketDataImporter
|
||||
# By default, our graphs show 1M as the view, so by fetching 31 days,
|
||||
# we ensure we can always show an accurate default graph
|
||||
SNAPSHOT_DAYS = 31
|
||||
|
||||
InvalidModeError = Class.new(StandardError)
|
||||
|
||||
def initialize(mode: :full, clear_cache: false)
|
||||
@mode = set_mode!(mode)
|
||||
@clear_cache = clear_cache
|
||||
end
|
||||
|
||||
def import_all
|
||||
import_security_prices
|
||||
import_exchange_rates
|
||||
end
|
||||
|
||||
# Syncs historical security prices (and details)
|
||||
def import_security_prices
|
||||
unless Security.provider
|
||||
Rails.logger.warn("No provider configured for MarketDataImporter.import_security_prices, skipping sync")
|
||||
return
|
||||
end
|
||||
|
||||
Security.where.not(exchange_operating_mic: nil).find_each do |security|
|
||||
security.import_provider_prices(
|
||||
start_date: get_first_required_price_date(security),
|
||||
end_date: end_date,
|
||||
clear_cache: clear_cache
|
||||
)
|
||||
|
||||
security.import_provider_details(clear_cache: clear_cache)
|
||||
end
|
||||
end
|
||||
|
||||
def import_exchange_rates
|
||||
unless ExchangeRate.provider
|
||||
Rails.logger.warn("No provider configured for MarketDataImporter.import_exchange_rates, skipping sync")
|
||||
return
|
||||
end
|
||||
|
||||
required_exchange_rate_pairs.each do |pair|
|
||||
# pair is a Hash with keys :source, :target, and :start_date
|
||||
start_date = snapshot? ? default_start_date : pair[:start_date]
|
||||
|
||||
ExchangeRate.import_provider_rates(
|
||||
from: pair[:source],
|
||||
to: pair[:target],
|
||||
start_date: start_date,
|
||||
end_date: end_date,
|
||||
clear_cache: clear_cache
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :mode, :clear_cache
|
||||
|
||||
def snapshot?
|
||||
mode.to_sym == :snapshot
|
||||
end
|
||||
|
||||
# Builds a unique list of currency pairs with the earliest date we need
|
||||
# exchange rates for.
|
||||
#
|
||||
# Returns: Array of Hashes – [{ source:, target:, start_date: }, ...]
|
||||
def required_exchange_rate_pairs
|
||||
pair_dates = {} # { [source, target] => earliest_date }
|
||||
|
||||
# 1. ENTRY-BASED PAIRS – we need rates from the first entry date
|
||||
Entry.joins(:account)
|
||||
.where.not("entries.currency = accounts.currency")
|
||||
.group("entries.currency", "accounts.currency")
|
||||
.minimum("entries.date")
|
||||
.each do |(source, target), date|
|
||||
key = [ source, target ]
|
||||
pair_dates[key] = [ pair_dates[key], date ].compact.min
|
||||
end
|
||||
|
||||
# 2. ACCOUNT-BASED PAIRS – use the account's oldest entry date
|
||||
account_first_entry_dates = Entry.group(:account_id).minimum(:date)
|
||||
|
||||
Account.joins(:family)
|
||||
.where.not("families.currency = accounts.currency")
|
||||
.select("accounts.id, accounts.currency AS source, families.currency AS target")
|
||||
.find_each do |account|
|
||||
earliest_entry_date = account_first_entry_dates[account.id]
|
||||
|
||||
chosen_date = [ earliest_entry_date, default_start_date ].compact.min
|
||||
|
||||
key = [ account.source, account.target ]
|
||||
pair_dates[key] = [ pair_dates[key], chosen_date ].compact.min
|
||||
end
|
||||
|
||||
# Convert to array of hashes for ease of use
|
||||
pair_dates.map do |(source, target), date|
|
||||
{ source: source, target: target, start_date: date }
|
||||
end
|
||||
end
|
||||
|
||||
def get_first_required_price_date(security)
|
||||
return default_start_date if snapshot?
|
||||
|
||||
Trade.with_entry.where(security: security).minimum(:date) || default_start_date
|
||||
end
|
||||
|
||||
# An approximation that grabs more than we likely need, but simplifies the logic
|
||||
def get_first_required_exchange_rate_date(from_currency:)
|
||||
return default_start_date if snapshot?
|
||||
|
||||
Entry.where(currency: from_currency).minimum(:date) || default_start_date
|
||||
end
|
||||
|
||||
def default_start_date
|
||||
SNAPSHOT_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
|
||||
|
||||
def set_mode!(mode)
|
||||
valid_modes = [ :full, :snapshot ]
|
||||
|
||||
unless valid_modes.include?(mode.to_sym)
|
||||
raise InvalidModeError, "Invalid mode for MarketDataImporter, can only be :full or :snapshot, but was #{mode}"
|
||||
end
|
||||
|
||||
mode.to_sym
|
||||
end
|
||||
end
|
|
@ -20,7 +20,10 @@ class PlaidAccount < ApplicationRecord
|
|||
|
||||
internal_account = family.accounts.find_or_initialize_by(plaid_account_id: plaid_account.id)
|
||||
|
||||
# Only set the name for new records or if the name is not locked
|
||||
if internal_account.new_record? || internal_account.enrichable?(:name)
|
||||
internal_account.name = plaid_data.name
|
||||
end
|
||||
internal_account.balance = plaid_data.balances.current || plaid_data.balances.available
|
||||
internal_account.currency = plaid_data.balances.iso_currency_code
|
||||
internal_account.accountable = TYPE_MAPPING[plaid_data.type].new
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class PlaidItem < ApplicationRecord
|
||||
include Provided, Syncable
|
||||
include Syncable
|
||||
|
||||
enum :plaid_region, { us: "us", eu: "eu" }
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
@ -22,39 +22,6 @@ class PlaidItem < ApplicationRecord
|
|||
scope :ordered, -> { order(created_at: :desc) }
|
||||
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:)
|
||||
begin
|
||||
family.get_link_token(
|
||||
|
@ -76,9 +43,8 @@ class PlaidItem < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def post_sync(sync)
|
||||
auto_match_categories!
|
||||
family.broadcast_refresh
|
||||
def build_category_alias_matcher(user_categories)
|
||||
Provider::Plaid::CategoryAliasMatcher.new(user_categories)
|
||||
end
|
||||
|
||||
def destroy_later
|
||||
|
@ -86,6 +52,14 @@ class PlaidItem < ApplicationRecord
|
|||
DestroyJob.perform_later(self)
|
||||
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)
|
||||
.visible
|
||||
.exists?
|
||||
end
|
||||
|
||||
def auto_match_categories!
|
||||
if family.categories.none?
|
||||
family.categories.bootstrap!
|
||||
|
@ -117,123 +91,11 @@ class PlaidItem < ApplicationRecord
|
|||
end
|
||||
|
||||
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
|
||||
plaid_provider.remove_item(access_token)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("Failed to remove Plaid item #{id}: #{e.message}")
|
||||
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
|
||||
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)
|
||||
end
|
||||
|
||||
Sentry.capture_exception(transformed_error)
|
||||
|
||||
Response.new(
|
||||
success?: false,
|
||||
data: nil,
|
||||
|
|
|
@ -2,22 +2,22 @@ module Provider::SecurityConcept
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic)
|
||||
SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind)
|
||||
Price = Data.define(:security, :date, :price, :currency)
|
||||
SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind, :exchange_operating_mic)
|
||||
Price = Data.define(:symbol, :date, :price, :currency, :exchange_operating_mic)
|
||||
|
||||
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
|
||||
raise NotImplementedError, "Subclasses must implement #search_securities"
|
||||
end
|
||||
|
||||
def fetch_security_info(security)
|
||||
def fetch_security_info(symbol:, exchange_operating_mic:)
|
||||
raise NotImplementedError, "Subclasses must implement #fetch_security_info"
|
||||
end
|
||||
|
||||
def fetch_security_price(security, date:)
|
||||
def fetch_security_price(symbol:, exchange_operating_mic:, date:)
|
||||
raise NotImplementedError, "Subclasses must implement #fetch_security_price"
|
||||
end
|
||||
|
||||
def fetch_security_prices(security, start_date:, end_date:)
|
||||
def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:)
|
||||
raise NotImplementedError, "Subclasses must implement #fetch_security_prices"
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,6 +3,8 @@ class Provider::Synth < Provider
|
|||
|
||||
# Subclass so errors caught in this provider are raised as Provider::Synth::Error
|
||||
Error = Class.new(Provider::Error)
|
||||
InvalidExchangeRateError = Class.new(Error)
|
||||
InvalidSecurityPriceError = Class.new(Error)
|
||||
|
||||
def initialize(api_key)
|
||||
@api_key = api_key
|
||||
|
@ -48,7 +50,7 @@ class Provider::Synth < Provider
|
|||
|
||||
rates = JSON.parse(response.body).dig("data", "rates")
|
||||
|
||||
Rate.new(date:, from:, to:, rate: rates.dig(to))
|
||||
Rate.new(date: date.to_date, from:, to:, rate: rates.dig(to))
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -65,8 +67,18 @@ class Provider::Synth < Provider
|
|||
end
|
||||
|
||||
data.paginated.map do |rate|
|
||||
Rate.new(date: rate.dig("date"), from:, to:, rate: rate.dig("rates", to))
|
||||
date = rate.dig("date")
|
||||
rate = rate.dig("rates", to)
|
||||
|
||||
if date.nil? || rate.nil?
|
||||
message = "#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} on: #{date}. Rate data: #{rate.inspect}"
|
||||
Rails.logger.warn(message)
|
||||
Sentry.capture_exception(InvalidExchangeRateError.new(message), level: :warning)
|
||||
next
|
||||
end
|
||||
|
||||
Rate.new(date: date.to_date, from:, to:, rate:)
|
||||
end.compact
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -97,65 +109,73 @@ class Provider::Synth < Provider
|
|||
end
|
||||
end
|
||||
|
||||
def fetch_security_info(security)
|
||||
def fetch_security_info(symbol:, exchange_operating_mic:)
|
||||
with_provider_response do
|
||||
response = client.get("#{base_url}/tickers/#{security.ticker}") do |req|
|
||||
req.params["mic_code"] = security.exchange_mic if security.exchange_mic.present?
|
||||
req.params["operating_mic"] = security.exchange_operating_mic if security.exchange_operating_mic.present?
|
||||
response = client.get("#{base_url}/tickers/#{symbol}") do |req|
|
||||
req.params["operating_mic"] = exchange_operating_mic
|
||||
end
|
||||
|
||||
data = JSON.parse(response.body).dig("data")
|
||||
|
||||
SecurityInfo.new(
|
||||
symbol: data.dig("ticker"),
|
||||
symbol: symbol,
|
||||
name: data.dig("name"),
|
||||
links: data.dig("links"),
|
||||
logo_url: data.dig("logo_url"),
|
||||
description: data.dig("description"),
|
||||
kind: data.dig("kind")
|
||||
kind: data.dig("kind"),
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_security_price(security, date:)
|
||||
def fetch_security_price(symbol:, exchange_operating_mic:, date:)
|
||||
with_provider_response do
|
||||
historical_data = fetch_security_prices(security, start_date: date, end_date: date)
|
||||
historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date)
|
||||
|
||||
raise ProviderError, "No prices found for security #{security.ticker} on date #{date}" if historical_data.data.empty?
|
||||
raise ProviderError, "No prices found for security #{symbol} on date #{date}" if historical_data.data.empty?
|
||||
|
||||
historical_data.data.first
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_security_prices(security, start_date:, end_date:)
|
||||
def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:)
|
||||
with_provider_response do
|
||||
params = {
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
end_date: end_date,
|
||||
operating_mic_code: exchange_operating_mic
|
||||
}
|
||||
|
||||
params[:operating_mic_code] = security.exchange_operating_mic if security.exchange_operating_mic.present?
|
||||
|
||||
data = paginate(
|
||||
"#{base_url}/tickers/#{security.ticker}/open-close",
|
||||
"#{base_url}/tickers/#{symbol}/open-close",
|
||||
params
|
||||
) do |body|
|
||||
body.dig("prices")
|
||||
end
|
||||
|
||||
currency = data.first_page.dig("currency")
|
||||
country_code = data.first_page.dig("exchange", "country_code")
|
||||
exchange_mic = data.first_page.dig("exchange", "mic_code")
|
||||
exchange_operating_mic = data.first_page.dig("exchange", "operating_mic_code")
|
||||
|
||||
data.paginated.map do |price|
|
||||
Price.new(
|
||||
security: security,
|
||||
date: price.dig("date"),
|
||||
price: price.dig("close") || price.dig("open"),
|
||||
currency: currency
|
||||
)
|
||||
date = price.dig("date")
|
||||
price = price.dig("close") || price.dig("open")
|
||||
|
||||
if date.nil? || price.nil?
|
||||
message = "#{self.class.name} returned invalid price data for security #{symbol} on: #{date}. Price data: #{price.inspect}"
|
||||
Rails.logger.warn(message)
|
||||
Sentry.capture_exception(InvalidSecurityPriceError.new(message), level: :warning)
|
||||
next
|
||||
end
|
||||
|
||||
Price.new(
|
||||
symbol: symbol,
|
||||
date: date.to_date,
|
||||
price: price,
|
||||
currency: currency,
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
)
|
||||
end.compact
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -8,7 +8,10 @@ class Rule < ApplicationRecord
|
|||
accepts_nested_attributes_for :conditions, allow_destroy: true
|
||||
accepts_nested_attributes_for :actions, allow_destroy: true
|
||||
|
||||
before_validation :normalize_name
|
||||
|
||||
validates :resource_type, presence: true
|
||||
validates :name, length: { minimum: 1 }, allow_nil: true
|
||||
validate :no_nested_compound_conditions
|
||||
|
||||
# Every rule must have at least 1 action
|
||||
|
@ -46,6 +49,18 @@ class Rule < ApplicationRecord
|
|||
RuleJob.perform_later(self, ignore_attribute_locks: ignore_attribute_locks)
|
||||
end
|
||||
|
||||
def primary_condition_title
|
||||
return "No conditions" if conditions.none?
|
||||
|
||||
first_condition = conditions.first
|
||||
if first_condition.compound? && first_condition.sub_conditions.any?
|
||||
first_sub_condition = first_condition.sub_conditions.first
|
||||
"If #{first_sub_condition.filter.label.downcase} #{first_sub_condition.operator} #{first_sub_condition.value_display}"
|
||||
else
|
||||
"If #{first_condition.filter.label.downcase} #{first_condition.operator} #{first_condition.value_display}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def matching_resources_scope
|
||||
scope = registry.resource_scope
|
||||
|
@ -87,4 +102,8 @@ class Rule < ApplicationRecord
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_name
|
||||
self.name = nil if name.is_a?(String) && name.strip.empty?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class Rule::Action < ApplicationRecord
|
||||
belongs_to :rule
|
||||
belongs_to :rule, touch: true
|
||||
|
||||
validates :action_type, presence: true
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class Rule::Condition < ApplicationRecord
|
||||
belongs_to :rule, optional: -> { where.not(parent_id: nil) }
|
||||
belongs_to :rule, touch: true, optional: -> { where.not(parent_id: nil) }
|
||||
belongs_to :parent, class_name: "Rule::Condition", optional: true, inverse_of: :sub_conditions
|
||||
|
||||
has_many :sub_conditions, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent
|
||||
|
|
145
app/models/security/price/importer.rb
Normal file
145
app/models/security/price/importer.rb
Normal file
|
@ -0,0 +1,145 @@
|
|||
class Security::Price::Importer
|
||||
MissingSecurityPriceError = Class.new(StandardError)
|
||||
MissingStartPriceError = Class.new(StandardError)
|
||||
|
||||
def initialize(security:, security_provider:, start_date:, end_date:, clear_cache: false)
|
||||
@security = security
|
||||
@security_provider = security_provider
|
||||
@start_date = start_date
|
||||
@end_date = normalize_end_date(end_date)
|
||||
@clear_cache = clear_cache
|
||||
end
|
||||
|
||||
# Constructs a daily series of prices for a single security over the date range.
|
||||
# Returns the number of rows upserted.
|
||||
def import_provider_prices
|
||||
if !clear_cache && all_prices_exist?
|
||||
Rails.logger.info("No new prices to sync for #{security.ticker} between #{start_date} and #{end_date}, skipping")
|
||||
return 0
|
||||
end
|
||||
|
||||
if clear_cache && provider_prices.empty?
|
||||
Rails.logger.warn("Could not clear cache for #{security.ticker} between #{start_date} and #{end_date} because provider returned no prices")
|
||||
return 0
|
||||
end
|
||||
|
||||
prev_price_value = start_price_value
|
||||
|
||||
unless prev_price_value.present?
|
||||
error = MissingStartPriceError.new("Could not find a start price for #{security.ticker} on or before #{start_date}")
|
||||
Rails.logger.error(error.message)
|
||||
Sentry.capture_exception(error)
|
||||
return 0
|
||||
end
|
||||
|
||||
gapfilled_prices = effective_start_date.upto(end_date).map do |date|
|
||||
db_price_value = db_prices[date]&.price
|
||||
provider_price_value = provider_prices[date]&.price
|
||||
provider_currency = provider_prices[date]&.currency
|
||||
|
||||
chosen_price = if clear_cache
|
||||
provider_price_value || db_price_value # overwrite when possible
|
||||
else
|
||||
db_price_value || provider_price_value # fill gaps
|
||||
end
|
||||
|
||||
# Gap-fill using LOCF (last observation carried forward)
|
||||
chosen_price ||= prev_price_value
|
||||
prev_price_value = chosen_price
|
||||
|
||||
{
|
||||
security_id: security.id,
|
||||
date: date,
|
||||
price: chosen_price,
|
||||
currency: provider_currency || prev_price_currency || db_price_currency || "USD"
|
||||
}
|
||||
end
|
||||
|
||||
upsert_rows(gapfilled_prices)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :security, :security_provider, :start_date, :end_date, :clear_cache
|
||||
|
||||
def provider_prices
|
||||
@provider_prices ||= begin
|
||||
provider_fetch_start_date = effective_start_date - 5.days
|
||||
|
||||
response = security_provider.fetch_security_prices(
|
||||
symbol: security.ticker,
|
||||
exchange_operating_mic: security.exchange_operating_mic,
|
||||
start_date: provider_fetch_start_date,
|
||||
end_date: end_date
|
||||
)
|
||||
|
||||
if response.success?
|
||||
response.data.index_by(&:date)
|
||||
else
|
||||
msg = "#{security_provider.class.name} could not fetch prices for #{security.ticker} between #{provider_fetch_start_date} and #{end_date}. Provider error: #{response.error.message}"
|
||||
Rails.logger.warn(msg)
|
||||
Sentry.capture_exception(MissingSecurityPriceError.new(msg), level: :warning)
|
||||
{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def db_prices
|
||||
@db_prices ||= Security::Price.where(security_id: security.id, date: start_date..end_date)
|
||||
.order(:date)
|
||||
.to_a
|
||||
.index_by(&:date)
|
||||
end
|
||||
|
||||
def all_prices_exist?
|
||||
db_prices.count == expected_count
|
||||
end
|
||||
|
||||
def expected_count
|
||||
(start_date..end_date).count
|
||||
end
|
||||
|
||||
# Skip over ranges that already exist unless clearing cache
|
||||
def effective_start_date
|
||||
return start_date if clear_cache
|
||||
|
||||
(start_date..end_date).detect { |d| !db_prices.key?(d) } || end_date
|
||||
end
|
||||
|
||||
def start_price_value
|
||||
provider_price_value = provider_prices.select { |date, _| date <= start_date }
|
||||
.max_by { |date, _| date }
|
||||
&.last&.price
|
||||
db_price_value = db_prices[start_date]&.price
|
||||
provider_price_value || db_price_value
|
||||
end
|
||||
|
||||
def upsert_rows(rows)
|
||||
batch_size = 200
|
||||
total_upsert_count = 0
|
||||
|
||||
rows.each_slice(batch_size) do |batch|
|
||||
ids = Security::Price.upsert_all(
|
||||
batch,
|
||||
unique_by: %i[security_id date currency],
|
||||
returning: [ "id" ]
|
||||
)
|
||||
total_upsert_count += ids.count
|
||||
end
|
||||
|
||||
total_upsert_count
|
||||
end
|
||||
|
||||
def db_price_currency
|
||||
db_prices.values.first&.currency
|
||||
end
|
||||
|
||||
def prev_price_currency
|
||||
@prev_price_currency ||= provider_prices.values.first&.currency
|
||||
end
|
||||
|
||||
# Clamp to today (EST) so we never call our price API for a future date (our API is in EST/EDT timezone)
|
||||
def normalize_end_date(requested_end_date)
|
||||
today_est = Date.current.in_time_zone("America/New_York").to_date
|
||||
[ requested_end_date, today_est ].min
|
||||
end
|
||||
end
|
|
@ -28,44 +28,6 @@ module Security::Provided
|
|||
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)
|
||||
price = prices.find_by(date: date)
|
||||
|
||||
|
@ -87,6 +49,48 @@ module Security::Provided
|
|||
price
|
||||
end
|
||||
|
||||
def import_provider_details(clear_cache: false)
|
||||
unless provider.present?
|
||||
Rails.logger.warn("No provider configured for Security.import_provider_details")
|
||||
return
|
||||
end
|
||||
|
||||
if self.name.present? && self.logo_url.present? && !clear_cache
|
||||
return
|
||||
end
|
||||
|
||||
response = provider.fetch_security_info(
|
||||
symbol: ticker,
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
)
|
||||
|
||||
if response.success?
|
||||
update(
|
||||
name: response.data.name,
|
||||
logo_url: response.data.logo_url,
|
||||
)
|
||||
else
|
||||
err = StandardError.new("Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}")
|
||||
Rails.logger.warn(err.message)
|
||||
Sentry.capture_exception(err, level: :warning)
|
||||
end
|
||||
end
|
||||
|
||||
def import_provider_prices(start_date:, end_date:, clear_cache: false)
|
||||
unless provider.present?
|
||||
Rails.logger.warn("No provider configured for Security.import_provider_prices")
|
||||
return 0
|
||||
end
|
||||
|
||||
Security::Price::Importer.new(
|
||||
security: self,
|
||||
security_provider: provider,
|
||||
start_date: start_date,
|
||||
end_date: end_date,
|
||||
clear_cache: clear_cache
|
||||
).import_provider_prices
|
||||
end
|
||||
|
||||
private
|
||||
def provider
|
||||
self.class.provider
|
||||
|
|
|
@ -9,4 +9,14 @@ class Session < ApplicationRecord
|
|||
self.user_agent = Current.user_agent
|
||||
self.ip_address = Current.ip_address
|
||||
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
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
class StockExchange < ApplicationRecord
|
||||
scope :in_country, ->(country_code) { where(country_code: country_code) }
|
||||
end
|
|
@ -17,6 +17,7 @@ class Subscription < ApplicationRecord
|
|||
|
||||
validates :stripe_id, presence: true, if: :active?
|
||||
validates :trial_ends_at, presence: true, if: :trialing?
|
||||
validates :family_id, uniqueness: true
|
||||
|
||||
class << self
|
||||
def new_trial_ends_at
|
||||
|
|
|
@ -1,4 +1,13 @@
|
|||
class Sync < ApplicationRecord
|
||||
# We run a cron that marks any syncs that have been running > 2 hours as "stale"
|
||||
# Syncs often become stale when new code is deployed and the worker restarts
|
||||
STALE_AFTER = 2.hours
|
||||
|
||||
# The max time that a sync will show in the UI (after 5 minutes)
|
||||
VISIBLE_FOR = 5.minutes
|
||||
|
||||
include AASM
|
||||
|
||||
Error = Class.new(StandardError)
|
||||
|
||||
belongs_to :syncable, polymorphic: true
|
||||
|
@ -6,12 +15,47 @@ class Sync < ApplicationRecord
|
|||
belongs_to :parent, class_name: "Sync", optional: true
|
||||
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 :incomplete, -> { where("syncs.status IN (?)", %w[pending syncing]) }
|
||||
scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago) }
|
||||
|
||||
def child?
|
||||
parent_id.present?
|
||||
# In-flight records that have exceeded the allowed runtime
|
||||
scope :stale_candidates, -> { incomplete.where("syncs.created_at < ?", STALE_AFTER.ago) }
|
||||
|
||||
validate :window_valid
|
||||
|
||||
# Sync state machine
|
||||
aasm column: :status, timestamps: true do
|
||||
state :pending, initial: true
|
||||
state :syncing
|
||||
state :completed
|
||||
state :failed
|
||||
state :stale
|
||||
|
||||
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
|
||||
|
||||
# Marks a sync that never completed within the expected time window
|
||||
event :mark_stale do
|
||||
transitions from: %i[pending syncing], to: :stale
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def clean
|
||||
stale_candidates.find_each(&:mark_stale!)
|
||||
end
|
||||
end
|
||||
|
||||
def perform
|
||||
|
@ -19,43 +63,83 @@ class Sync < ApplicationRecord
|
|||
start!
|
||||
|
||||
begin
|
||||
syncable.sync_data(self, start_date: start_date)
|
||||
syncable.perform_sync(self)
|
||||
rescue => e
|
||||
fail!
|
||||
update(error: e.message)
|
||||
report_error(e)
|
||||
ensure
|
||||
finalize_if_all_children_finalized
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Finalizes the current sync AND parent (if it exists)
|
||||
def finalize_if_all_children_finalized
|
||||
Sync.transaction do
|
||||
lock!
|
||||
|
||||
# If this is the "parent" and there are still children running, don't finalize.
|
||||
return unless all_children_finalized?
|
||||
|
||||
if syncing?
|
||||
if has_failed_children?
|
||||
fail!
|
||||
else
|
||||
complete!
|
||||
Rails.logger.info("Sync completed, starting post-sync")
|
||||
syncable.post_sync(self)
|
||||
Rails.logger.info("Post-sync completed")
|
||||
rescue StandardError => error
|
||||
fail! error, report_error: true
|
||||
end
|
||||
end
|
||||
|
||||
# If we make it here, the sync is finalized. Run post-sync, regardless of failure/success.
|
||||
perform_post_sync
|
||||
end
|
||||
|
||||
# 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 start!
|
||||
Rails.logger.info("Starting sync")
|
||||
update! status: :syncing
|
||||
def log_status_change
|
||||
Rails.logger.info("changing from #{aasm.from_state} to #{aasm.to_state} (event: #{aasm.current_event})")
|
||||
end
|
||||
|
||||
def complete!
|
||||
Rails.logger.info("Sync completed")
|
||||
update! status: :completed, last_ran_at: Time.current
|
||||
def has_failed_children?
|
||||
children.failed.any?
|
||||
end
|
||||
|
||||
def fail!(error, report_error: false)
|
||||
Rails.logger.error("Sync failed: #{error.message}")
|
||||
def all_children_finalized?
|
||||
children.incomplete.empty?
|
||||
end
|
||||
|
||||
if report_error
|
||||
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_context("sync", { id: id, syncable_type: syncable_type, syncable_id: syncable_id })
|
||||
scope.set_tags(sync_id: id)
|
||||
end
|
||||
end
|
||||
|
||||
update!(
|
||||
status: :failed,
|
||||
error: error.message,
|
||||
last_ran_at: Time.current
|
||||
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
|
||||
|
|
|
@ -129,8 +129,9 @@ class TradeBuilder
|
|||
def security
|
||||
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
|
||||
|
||||
Security.find_or_create_by(ticker: ticker_symbol, exchange_operating_mic: exchange_operating_mic) do |s|
|
||||
FetchSecurityInfoJob.perform_later(s.id)
|
||||
end
|
||||
Security.find_or_create_by!(
|
||||
ticker: ticker_symbol,
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -32,9 +32,13 @@
|
|||
<div class="flex items-center gap-8">
|
||||
<%= render "accounts/account_error", account: account, given_title: "Account has an error", link_to_path: account_path(account, return_to: return_to) %>
|
||||
|
||||
<% if account.syncing? %>
|
||||
<div class="w-16 h-6 bg-loader rounded-full animate-pulse"></div>
|
||||
<% 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? %>
|
||||
<%= 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? %>
|
||||
<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">
|
||||
|
@ -21,15 +21,14 @@
|
|||
</details>
|
||||
<% end %>
|
||||
|
||||
<div data-controller="sidebar-tabs">
|
||||
<%= render TabsComponent.new(active_tab: active_account_group_tab, url_param_key: "account_group_tab", testid: "account-sidebar-tabs") do |tabs| %>
|
||||
<%= render TabsComponent.new(active_tab: active_tab, session_key: "account_sidebar_tab", testid: "account-sidebar-tabs") do |tabs| %>
|
||||
<% tabs.with_nav do |nav| %>
|
||||
<% nav.with_btn(id: "assets", label: "Assets") %>
|
||||
<% nav.with_btn(id: "debts", label: "Debts") %>
|
||||
<% nav.with_btn(id: "asset", label: "Assets") %>
|
||||
<% nav.with_btn(id: "liability", label: "Debts") %>
|
||||
<% nav.with_btn(id: "all", label: "All") %>
|
||||
<% end %>
|
||||
|
||||
<% tabs.with_panel(tab_id: "assets") do %>
|
||||
<% tabs.with_panel(tab_id: "asset") do %>
|
||||
<div class="space-y-2">
|
||||
<%= render LinkComponent.new(
|
||||
text: "New asset",
|
||||
|
@ -43,13 +42,13 @@
|
|||
|
||||
<div>
|
||||
<% 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 %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% tabs.with_panel(tab_id: "debts") do %>
|
||||
<% tabs.with_panel(tab_id: "liability") do %>
|
||||
<div class="space-y-2">
|
||||
<%= render LinkComponent.new(
|
||||
text: "New debt",
|
||||
|
@ -63,7 +62,7 @@
|
|||
|
||||
<div>
|
||||
<% family.balance_sheet.account_groups("liability").each do |group| %>
|
||||
<%= render "accounts/accountable_group", account_group: group %>
|
||||
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -83,11 +82,10 @@
|
|||
|
||||
<div>
|
||||
<% family.balance_sheet.account_groups.each do |group| %>
|
||||
<%= render "accounts/accountable_group", account_group: group %>
|
||||
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,53 +1,64 @@
|
|||
<%# 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 %>">
|
||||
<% is_open = open.nil? ? account_group.accounts.any? { |account| page_active?(account_path(account)) } : open %>
|
||||
<%= render DisclosureComponent.new(title: account_group.name, align: :left, open: is_open) do |disclosure| %>
|
||||
<% 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-surface-inset"></div>
|
||||
<div class="w-6 h-px bg-loader"></div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-1">
|
||||
<% account_group.accounts.each do |account| %>
|
||||
|
||||
<div style="width: 100%;"
|
||||
class="<%= class_names(
|
||||
<%= link_to account_path(account),
|
||||
class: class_names(
|
||||
"block flex items-center gap-2 px-3 py-2 rounded-lg",
|
||||
page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover"
|
||||
) %>"
|
||||
data-sidebar-tabs-target="account">
|
||||
|
||||
<%= link_to account_path(account),
|
||||
style: "overflow: hidden;",
|
||||
class: "flex items-center gap-2 grow",
|
||||
title: account.name,
|
||||
data: { action: "click->sidebar-tabs#select" } do %>
|
||||
),
|
||||
title: account.name do %>
|
||||
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
|
||||
|
||||
<div style="max-width: 80%;">
|
||||
<div class="min-w-0 grow">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<%# Render account error warnings %>
|
||||
<%= render "accounts/account_error", account: account, given_title: "Account has an error", link_to_path: account_path(account) %>
|
||||
<% end %>
|
||||
|
||||
<div class="ml-auto text-right h-10 shrink-0">
|
||||
<% if account.syncing? %>
|
||||
<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 %>
|
||||
<div class="flex items-center w-8 h-5 ml-auto">
|
||||
<div class="w-6 h-px bg-surface-inset"></div>
|
||||
<div class="flex items-center w-8 h-4 ml-auto">
|
||||
<div class="w-6 h-px bg-loader"></div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
@ -63,3 +74,4 @@
|
|||
) %>
|
||||
</div>
|
||||
<% 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 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>
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
<% trend = series.trend %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
|
||||
<% if @account.syncing? %>
|
||||
<%= render "accounts/chart_loader" %>
|
||||
<% else %>
|
||||
<div class="px-4">
|
||||
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %>
|
||||
</div>
|
||||
|
@ -20,3 +23,4 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
|
@ -2,17 +2,6 @@
|
|||
<h1 class="text-xl"><%= t(".accounts") %></h1>
|
||||
<div class="flex items-center gap-5">
|
||||
<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(
|
||||
text: "New account",
|
||||
href: new_account_path(return_to: accounts_path),
|
||||
|
|
|
@ -6,9 +6,12 @@
|
|||
<p><%= Accountable.from_type(group).display_name %></p>
|
||||
<span class="text-subdued mx-2">·</span>
|
||||
<p><%= accounts.count %></p>
|
||||
|
||||
<% unless accounts.any?(&:syncing?) %>
|
||||
<p class="ml-auto"><%= totals_by_currency(collection: accounts, money_method: :balance_money) %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="bg-container">
|
||||
<div class="bg-container rounded-md">
|
||||
<% accounts.each do |account| %>
|
||||
<%= render account %>
|
||||
<% 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 %>
|
||||
<% 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="space-y-2 w-full">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= tag.p title || default_value_title, class: "text-sm font-medium text-secondary" %>
|
||||
<%= tooltip %>
|
||||
<%= tag.p account.investment? ? "Total value" : default_value_title, class: "text-sm font-medium text-secondary" %>
|
||||
|
||||
<% 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>
|
||||
|
||||
<% 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>
|
||||
|
||||
<%= styled_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_frame_tag dom_id(account) do %>
|
||||
<%= turbo_frame_tag dom_id(account, :container) do %>
|
||||
<%= tag.div class: "space-y-4 pb-32" do %>
|
||||
<% if header.present? %>
|
||||
<%= header %>
|
||||
|
@ -13,7 +13,7 @@
|
|||
<% if chart.present? %>
|
||||
<%= chart %>
|
||||
<% else %>
|
||||
<%= render "accounts/show/chart", account: account %>
|
||||
<%= render "accounts/show/chart", account: account, chart_view: chart_view %>
|
||||
<% end %>
|
||||
|
||||
<div class="min-h-[800px]" data-testid="account-details">
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
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"
|
||||
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") %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -19,8 +19,13 @@
|
|||
|
||||
<div class="col-span-2 flex justify-end items-center gap-2">
|
||||
<% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %>
|
||||
|
||||
<% 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 class="col-span-2 text-right">
|
||||
|
@ -28,7 +33,13 @@
|
|||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<% 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 class="col-span-2 text-right">
|
||||
|
|
|
@ -17,7 +17,9 @@
|
|||
</div>
|
||||
|
||||
<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 %>
|
||||
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
|
||||
<% else %>
|
||||
|
@ -26,21 +28,39 @@
|
|||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<% if holding.account.syncing? %>
|
||||
<div class="flex justify-end">
|
||||
<div class="w-16 h-6 bg-loader rounded-full"></div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= tag.p format_money holding.avg_cost %>
|
||||
<%= tag.p t(".per_share"), 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>
|
||||
<% 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.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>
|
||||
<% elsif holding.trend %>
|
||||
<%= 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};" %>
|
||||
<% else %>
|
||||
|
|
|
@ -1,25 +1,7 @@
|
|||
<%= turbo_stream_from @account %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(@account) do %>
|
||||
<%= tag.div class: "space-y-4" do %>
|
||||
<%= render "accounts/show/header", account: @account %>
|
||||
|
||||
<%= render "accounts/show/chart",
|
||||
<%= render "accounts/show/template",
|
||||
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: [
|
||||
tabs: 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(
|
||||
"accounts/account_sidebar_tabs",
|
||||
family: Current.family,
|
||||
active_account_group_tab: params[:account_group_tab] || "assets"
|
||||
active_tab: @account_group_tab,
|
||||
mobile: true
|
||||
) %>
|
||||
</div>
|
||||
|
||||
|
@ -80,8 +81,8 @@
|
|||
<%= yield :sidebar %>
|
||||
<% else %>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="overflow-y-auto grow" id="account-sidebar-tabs">
|
||||
<%= render "accounts/account_sidebar_tabs", family: Current.family, active_account_group_tab: params[:account_group_tab] || "assets" %>
|
||||
<div class="overflow-y-auto grow">
|
||||
<%= render "accounts/account_sidebar_tabs", family: Current.family, active_tab: @account_group_tab %>
|
||||
</div>
|
||||
|
||||
<% if Current.family.trialing? && !self_hosted? %>
|
||||
|
|
|
@ -23,10 +23,6 @@
|
|||
<%= render_flash_notifications %>
|
||||
|
||||
<div id="cta"></div>
|
||||
|
||||
<% if Current.family&.syncing? %>
|
||||
<%= render "shared/notifications/loading", id: "syncing-notice", message: "Syncing accounts data..." %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
<%= summary_card title: t(".interest_rate") do %>
|
||||
<% if account.loan.interest_rate.present? %>
|
||||
<%= number_to_percentage(account.loan.interest_rate, precision: 2) %>
|
||||
<%= number_to_percentage(account.loan.interest_rate, precision: 3) %>
|
||||
<% else %>
|
||||
<%= t(".unknown") %>
|
||||
<% end %>
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<div class="space-y-3">
|
||||
<% [
|
||||
{ icon: "layers", label: "See all my accounts in one piece", value: "unified_accounts" },
|
||||
{ icon: "layers", label: "See all my accounts in one place", value: "unified_accounts" },
|
||||
{ icon: "banknote", label: "Understand cashflow and expenses", value: "cashflow" },
|
||||
{ icon: "pie-chart", label: "Manage financial plans and budgeting", value: "budgeting" },
|
||||
{ icon: "users", label: "Manage finances with a partner", value: "partner" },
|
||||
|
|
|
@ -25,11 +25,14 @@
|
|||
|
||||
<div class="w-full space-y-6 pb-24">
|
||||
<% if Current.family.accounts.any? %>
|
||||
<section class="bg-container py-4 rounded-xl shadow-border-xs px-0.5">
|
||||
<%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @balance_sheet.net_worth_series(period: @period), period: @period } %>
|
||||
<section class="bg-container py-4 rounded-xl shadow-border-xs">
|
||||
<%= render partial: "pages/dashboard/net_worth_chart", locals: {
|
||||
balance_sheet: @balance_sheet,
|
||||
period: @period
|
||||
} %>
|
||||
</section>
|
||||
<% else %>
|
||||
<section class="p-0.5">
|
||||
<section>
|
||||
<%= render "pages/dashboard/no_accounts_graph_placeholder" %>
|
||||
</section>
|
||||
<% 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| %>
|
||||
<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">
|
||||
|
@ -11,17 +11,28 @@
|
|||
<% if classification_group.account_groups.any? %>
|
||||
<span class="text-secondary">·</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 %>
|
||||
</h2>
|
||||
|
||||
<% if classification_group.account_groups.any? %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-1">
|
||||
<% 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>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if classification_group.syncing? %>
|
||||
<p class="text-xs text-subdued animate-pulse">Calculating latest balance data...</p>
|
||||
<% else %>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<% classification_group.account_groups.each do |account_group| %>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
|
@ -31,6 +42,7 @@
|
|||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface rounded-xl p-1 space-y-1 overflow-x-auto">
|
||||
|
@ -56,6 +68,17 @@
|
|||
<p><%= account_group.name %></p>
|
||||
</div>
|
||||
|
||||
<% if account_group.syncing? %>
|
||||
<div class="flex items-center justify-between text-right gap-6">
|
||||
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
||||
<div class="bg-loader rounded-md h-4 w-12"></div>
|
||||
</div>
|
||||
|
||||
<div class="w-40 shrink-0 flex justify-end">
|
||||
<div class="bg-loader rounded-md h-4 w-12"></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 %>
|
||||
|
@ -65,6 +88,7 @@
|
|||
<p><%= format_money(account_group.total_money) %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</summary>
|
||||
|
||||
<div>
|
||||
|
@ -76,6 +100,17 @@
|
|||
<%= link_to account.name, account_path(account) %>
|
||||
</div>
|
||||
|
||||
<% if account.syncing? %>
|
||||
<div class="ml-auto flex items-center text-right gap-6">
|
||||
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
|
||||
<div class="bg-loader rounded-md h-4 w-12"></div>
|
||||
</div>
|
||||
|
||||
<div class="w-40 shrink-0 flex justify-end">
|
||||
<div class="bg-loader rounded-md h-4 w-12"></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 %>
|
||||
|
@ -85,6 +120,7 @@
|
|||
<p><%= format_money(account.balance_money) %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if idx < account_group.accounts.size - 1 %>
|
||||
|
|
|
@ -1,17 +1,28 @@
|
|||
<%# locals: (series:, period:) %>
|
||||
<%# locals: (balance_sheet:, period:, **args) %>
|
||||
|
||||
<div id="net-worth-chart">
|
||||
<% 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>
|
||||
|
||||
<% if balance_sheet.syncing? %>
|
||||
<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>
|
||||
|
||||
|
@ -24,6 +35,11 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if balance_sheet.syncing? %>
|
||||
<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"
|
||||
|
@ -35,3 +51,5 @@
|
|||
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
<%# Initial rendering based on rule.action_executors.first from the rule form. %>
|
||||
<%# This is currently always SetTransactionCategory from transaction_resource.rb, which is a select type. %>
|
||||
<%# Subsequent renders are injected by the Stimulus controller, which uses the templates from below. %>
|
||||
<span class="font-medium uppercase text-xs">to</span>
|
||||
<span class="font-medium text-primary uppercase text-xs">to</span>
|
||||
<%= form.select :value, action.options || [], {} %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
@ -29,12 +29,12 @@
|
|||
|
||||
<%# Templates for different input types - these will be cloned and used by the Stimulus controller %>
|
||||
<template data-rule--actions-target="selectTemplate">
|
||||
<span class="font-medium uppercase text-xs">to</span>
|
||||
<span class="font-medium text-primary uppercase text-xs">to</span>
|
||||
<%= form.select :value, [], {} %>
|
||||
</template>
|
||||
|
||||
<template data-rule--actions-target="textTemplate">
|
||||
<span class="font-medium uppercase text-xs">to</span>
|
||||
<span class="font-medium text-primary uppercase text-xs">to</span>
|
||||
<%= form.text_field :value, placeholder: "Enter a value" %>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -4,8 +4,11 @@
|
|||
<% rule = condition.rule %>
|
||||
|
||||
<li data-controller="rule--conditions" data-rule--conditions-condition-filters-value="<%= rule.condition_filters.to_json %>" class="flex items-center gap-3">
|
||||
<% if form.index.to_i > 0 && show_prefix %>
|
||||
<div class="pl-4">
|
||||
|
||||
<%# Conditionally render the prefix %>
|
||||
<%# Condition groups pass in show_prefix: false for subconditions since the ANY/ALL selector makes that clear %>
|
||||
<% if show_prefix %>
|
||||
<div class="pl-2" data-condition-prefix>
|
||||
<span class="font-medium uppercase text-xs">and</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -3,17 +3,16 @@
|
|||
<% condition = form.object %>
|
||||
<% rule = condition.rule %>
|
||||
|
||||
<li data-controller="rule--conditions element-removal" class="border border-secondary rounded-md p-4 space-y-3">
|
||||
<li data-controller="rule--conditions" class="border border-secondary rounded-md p-4 space-y-3">
|
||||
|
||||
<%= form.hidden_field :condition_type, value: "compound" %>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<% unless form.index == 0 %>
|
||||
<div class="pl-2">
|
||||
<%# Show prefix on condition groups, except the first one %>
|
||||
<div class="pl-2" data-condition-prefix>
|
||||
<span class="font-medium uppercase text-xs">and</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class="text-sm text-secondary">match</p>
|
||||
<%= form.select :operator, [["all", "and"], ["any", "or"]], { container_class: "w-fit" }, data: { rules_target: "operatorField" } %>
|
||||
<p class="text-sm text-secondary">of the following conditions</p>
|
||||
|
@ -21,16 +20,16 @@
|
|||
|
||||
<%= icon(
|
||||
"trash-2",
|
||||
size: "sm",
|
||||
as_button: true,
|
||||
data: { action: "element-removal#remove" }
|
||||
size: "sm",
|
||||
data: { action: "rule--conditions#remove" }
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<%# Sub-condition template, used by Stimulus controller to add new sub-conditions dynamically %>
|
||||
<template data-rule--conditions-target="subConditionTemplate">
|
||||
<%= form.fields_for :sub_conditions, Rule::Condition.new(parent: condition, condition_type: rule.condition_filters.first.key), child_index: "IDX_PLACEHOLDER" do |scf| %>
|
||||
<%= render "rule/conditions/condition", form: scf %>
|
||||
<%= render "rule/conditions/condition", form: scf, show_prefix: false %>
|
||||
<% end %>
|
||||
</template>
|
||||
|
||||
|
@ -44,6 +43,7 @@
|
|||
text: "Add condition",
|
||||
leading_icon: "plus",
|
||||
variant: "ghost",
|
||||
type: "button",
|
||||
data: { action: "rule--conditions#addSubCondition" }
|
||||
) %>
|
||||
</li>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<%# locals: (rule:) %>
|
||||
|
||||
<%= styled_form_with model: rule, class: "space-y-4",
|
||||
<%= styled_form_with model: rule, class: "space-y-6",
|
||||
data: { controller: "rules", rule_registry_value: rule.registry.to_json } do |f| %>
|
||||
|
||||
<%= f.hidden_field :resource_type, value: rule.resource_type %>
|
||||
|
@ -10,9 +10,21 @@
|
|||
<% end %>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-primary">If <%= rule.resource_type %></h3>
|
||||
<div class="flex items-center gap-1 text-secondary">
|
||||
<%= icon "tag", size: "sm" %>
|
||||
<h3 class="text-sm font-medium text-primary">Rule name (optional)</h3>
|
||||
</div>
|
||||
<div class="ml-6">
|
||||
<%= f.text_field :name, placeholder: "Enter a name for this rule", class: "form-field__input" %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%# Condition template, used by Stimulus controller to add new conditions dynamically %>
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center gap-1 text-secondary">
|
||||
<h3 class="text-sm font-medium text-primary">IF</h3>
|
||||
</div>
|
||||
|
||||
<%# Condition Group template, used by Stimulus controller to add new conditions dynamically %>
|
||||
<template data-rules-target="conditionGroupTemplate">
|
||||
<%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: "compound", operator: "and"), child_index: "IDX_PLACEHOLDER" do |cf| %>
|
||||
<%= render "rule/conditions/condition_group", form: cf %>
|
||||
|
@ -26,7 +38,8 @@
|
|||
<% end %>
|
||||
</template>
|
||||
|
||||
<ul data-rules-target="conditionsList" class="space-y-3 mb-4">
|
||||
<div class="ml-6 space-y-4">
|
||||
<ul data-rules-target="conditionsList" class="space-y-3">
|
||||
<%= f.fields_for :conditions do |cf| %>
|
||||
<% if cf.object.compound? %>
|
||||
<%= render "rule/conditions/condition_group", form: cf %>
|
||||
|
@ -38,12 +51,14 @@
|
|||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= render ButtonComponent.new(text: "Add condition", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addCondition" }) %>
|
||||
<%= render ButtonComponent.new(text: "Add condition group", icon: "boxes", variant: "ghost", type: "button", data: { action: "rules#addConditionGroup" }) %>
|
||||
<%= render ButtonComponent.new(text: "Add condition group", icon: "copy-plus", variant: "ghost", type: "button", data: { action: "rules#addConditionGroup" }) %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-primary">Then</h3>
|
||||
<div class="flex items-center gap-1 text-secondary">
|
||||
<h3 class="text-sm font-medium text-primary">THEN</h3>
|
||||
</div>
|
||||
|
||||
<%# Action template, used by Stimulus controller to add new actions dynamically %>
|
||||
<template data-rules-target="actionTemplate">
|
||||
|
@ -52,6 +67,7 @@
|
|||
<% end %>
|
||||
</template>
|
||||
|
||||
<div class="ml-6 space-y-4">
|
||||
<ul data-rules-target="actionsList" class="space-y-3">
|
||||
<%= f.fields_for :actions do |af| %>
|
||||
<%= render "rule/actions/action", form: af %>
|
||||
|
@ -62,12 +78,14 @@
|
|||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-primary">Apply this</h3>
|
||||
<div class="flex items-center gap-1 text-secondary">
|
||||
<h3 class="text-sm font-medium text-primary">FOR</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="ml-6 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= f.radio_button :effective_date_enabled, false, checked: rule.effective_date.nil?, data: { action: "rules#clearEffectiveDate" } %>
|
||||
<%= f.label :effective_date_enabled_false, "To all past and future #{rule.resource_type}s", class: "text-sm text-primary" %>
|
||||
<%= f.label :effective_date_enabled_false, "All past and future #{rule.resource_type}s", class: "text-sm text-primary" %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
|
@ -1,51 +1,64 @@
|
|||
<%# locals: (rule:) %>
|
||||
|
||||
<div class="flex justify-between items-center gap-4 bg-white shadow-border-xs rounded-md p-4">
|
||||
<div class="flex justify-between items-center p-4 <%= rule.active? ? "text-primary" : "text-secondary" %>">
|
||||
<div class="text-sm space-y-1.5">
|
||||
<% if rule.name.present? %>
|
||||
<h3 class="font-medium text-md"><%= rule.name %></h3>
|
||||
<% end %>
|
||||
<% if rule.conditions.any? %>
|
||||
<p class="flex items-center flex-wrap gap-1.5">
|
||||
<span class="px-2 py-1 border border-alpha-black-200 rounded-full">
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<div class="flex items-center gap-1 text-secondary w-16 shrink-0">
|
||||
<span class="font-mono text-xs">IF</span>
|
||||
</div>
|
||||
<p class="flex items-center flex-wrap gap-1.5 m-0">
|
||||
<span class="px-2 py-1 border border-secondary rounded-full">
|
||||
<% if rule.conditions.first.compound? %>
|
||||
If <%= rule.conditions.first.sub_conditions.first.filter.label %> <%= rule.conditions.first.sub_conditions.first.operator %> <%= rule.conditions.first.sub_conditions.first.value_display %>
|
||||
<%= rule.conditions.first.sub_conditions.first.filter.label %> <%= rule.conditions.first.sub_conditions.first.operator %> <%= rule.conditions.first.sub_conditions.first.value_display %>
|
||||
<% else %>
|
||||
If <%= rule.conditions.first.filter.label %> <%= rule.conditions.first.operator %> <%= rule.conditions.first.value_display %>
|
||||
<%= rule.conditions.first.filter.label %> <%= rule.conditions.first.operator %> <%= rule.conditions.first.value_display %>
|
||||
<% end %>
|
||||
</span>
|
||||
|
||||
<% if rule.conditions.count > 1 %>
|
||||
and <%= rule.conditions.count - 1 %> more <%= rule.conditions.count - 1 == 1 ? "condition" : "conditions" %>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p class="flex items-center flex-wrap gap-1.5">
|
||||
<span class="px-2 py-1 border border-alpha-black-200 rounded-full">
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<div class="flex items-center gap-1 text-secondary w-16 shrink-0">
|
||||
<span class="font-mono text-xs">THEN</span>
|
||||
</div>
|
||||
<p class="flex items-center flex-wrap gap-1.5 m-0">
|
||||
<span class="px-2 py-1 border border-secondary rounded-full">
|
||||
<% if rule.actions.first.value && rule.actions.first.options %>
|
||||
<%= rule.actions.first.executor.label %> to <%= rule.actions.first.value_display %>
|
||||
<% else %>
|
||||
<%= rule.actions.first.executor.label %>
|
||||
<% end %>
|
||||
</span>
|
||||
|
||||
<% if rule.actions.count > 1 %>
|
||||
and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %>
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<p class="flex items-center flex-wrap gap-1.5">
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<div class="flex items-center gap-1 text-secondary w-16 shrink-0">
|
||||
<span class="font-mono text-xs">FOR</span>
|
||||
</div>
|
||||
<p class="flex items-center flex-wrap gap-1.5 m-0">
|
||||
<span class="px-2 py-1 border border-secondary rounded-full">
|
||||
<% if rule.effective_date.nil? %>
|
||||
To all past and future <%= rule.resource_type.pluralize %>
|
||||
All past and future <%= rule.resource_type.pluralize %>
|
||||
<% else %>
|
||||
To all <%= rule.resource_type.pluralize %> on or after <%= rule.effective_date %>
|
||||
<%= rule.resource_type.pluralize %> on or after <%= rule.effective_date.strftime("%b %-d, %Y") %>
|
||||
<% end %>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<%= styled_form_with model: rule, data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %>
|
||||
<% end %>
|
||||
|
||||
<%= render MenuComponent.new do |menu| %>
|
||||
<% menu.with_item(variant: "link", text: "Edit", href: edit_rule_path(rule), icon: "pencil", data: { turbo_frame: "modal" }) %>
|
||||
<% menu.with_item(variant: "link", text: "Re-apply rule", href: confirm_rule_path(rule), icon: "refresh-cw", data: { turbo_frame: "modal" }) %>
|
||||
|
|
|
@ -1,5 +1,12 @@
|
|||
<%= render DialogComponent.new(reload_on_close: true) do |dialog| %>
|
||||
<% dialog.with_header(title: "Confirm changes") %>
|
||||
<%
|
||||
title = if @rule.name.present?
|
||||
"Confirm changes to \"#{@rule.name}\""
|
||||
else
|
||||
"Confirm changes"
|
||||
end
|
||||
%>
|
||||
<% dialog.with_header(title: title) %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<p class="text-secondary text-sm mb-4">
|
||||
|
|
|
@ -1,7 +1,15 @@
|
|||
<%= link_to "Back to rules", rules_path %>
|
||||
|
||||
<%= render DialogComponent.new do |dialog| %>
|
||||
<% dialog.with_header(title: "Edit #{@rule.resource_type} rule") %>
|
||||
<%
|
||||
title = if @rule.name.present?
|
||||
"Edit #{@rule.resource_type} rule \"#{@rule.name}\""
|
||||
else
|
||||
"Edit #{@rule.resource_type} rule"
|
||||
end
|
||||
%>
|
||||
<% dialog.with_header(title: title) %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<%= render "rules/form", rule: @rule %>
|
||||
<% end %>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<header class="flex items-center justify-between">
|
||||
<h1 class="text-primary text-xl font-medium">Rules</h1>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<% if @rules.any? %>
|
||||
<%= render MenuComponent.new do |menu| %>
|
||||
|
@ -13,7 +12,6 @@
|
|||
confirm: CustomConfirm.for_resource_deletion("all rules", high_severity: true)) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= render LinkComponent.new(
|
||||
text: "New rule",
|
||||
variant: "primary",
|
||||
|
@ -23,7 +21,6 @@
|
|||
) %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<% if self_hosted? %>
|
||||
<div class="flex items-center gap-2 mb-2 py-4">
|
||||
<%= icon("circle-alert", size: "sm") %>
|
||||
|
@ -32,19 +29,43 @@
|
|||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="bg-container shadow-border-xs rounded-xl p-4">
|
||||
|
||||
<% if @rules.any? %>
|
||||
<div class="rounded-xl bg-gray-25 space-y-1">
|
||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
|
||||
<div class="bg-container-inset rounded-xl">
|
||||
<div class="flex justify-between px-4 py-2 text-xs uppercase">
|
||||
<div class="flex items-center gap-1.5 font-medium text-secondary">
|
||||
<p>Rules</p>
|
||||
<span class="text-subdued">·</span>
|
||||
<p><%= @rules.count %></p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 p-1">
|
||||
<%= render @rules %>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary">Sort by:</span>
|
||||
<%= form_with url: rules_path, method: :get, local: true, class: "flex items-center", data: { controller: "auto-submit-form" } do |form| %>
|
||||
<%= form.select :sort_by,
|
||||
options_for_select([["Name", "name"], ["Updated At", "updated_at"]], @sort_by),
|
||||
{},
|
||||
class: "min-w-[120px] bg-transparent rounded border-none cursor-pointer text-primary uppercase text-xs w-auto",
|
||||
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %>
|
||||
<%= form.hidden_field :direction, value: @direction %>
|
||||
<% end %>
|
||||
<%= render LinkComponent.new(
|
||||
href: rules_path(direction: @direction == "asc" ? "desc" : "asc", sort_by: @sort_by),
|
||||
variant: "icon",
|
||||
icon: "arrow-up-down",
|
||||
size: :sm,
|
||||
title: "Toggle sort direction"
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<% @rules.each_with_index do |rule, idx| %>
|
||||
<%= render "rule", rule: rule %>
|
||||
<% unless idx == @rules.size - 1 %>
|
||||
<div class="h-px bg-divider ml-4 mr-6"></div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
|
|
|
@ -1,31 +1,31 @@
|
|||
<%
|
||||
nav_sections = [
|
||||
{
|
||||
header: t('.general_section_title'),
|
||||
header: t(".general_section_title"),
|
||||
items: [
|
||||
{ label: t('.profile_label'), path: settings_profile_path, icon: 'circle-user' },
|
||||
{ label: t('.preferences_label'), path: settings_preferences_path, icon: 'bolt' },
|
||||
{ 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('.billing_label'), path: settings_billing_path, icon: 'circle-dollar-sign', if: !self_hosted? },
|
||||
{ label: t('.accounts_label'), path: accounts_path, icon: 'layers' },
|
||||
{ label: t('.imports_label'), path: imports_path, icon: 'download' }
|
||||
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
|
||||
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
|
||||
{ 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(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? },
|
||||
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },
|
||||
{ label: t(".imports_label"), path: imports_path, icon: "download" }
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('.transactions_section_title'),
|
||||
header: t(".transactions_section_title"),
|
||||
items: [
|
||||
{ label: t('.tags_label'), path: tags_path, icon: 'tags' },
|
||||
{ label: t('.categories_label'), path: categories_path, icon: 'shapes' },
|
||||
{ label: t('.rules_label'), path: rules_path, icon: 'git-branch' },
|
||||
{ label: t('.merchants_label'), path: family_merchants_path, icon: 'store' }
|
||||
{ label: t(".tags_label"), path: tags_path, icon: "tags" },
|
||||
{ label: t(".categories_label"), path: categories_path, icon: "shapes" },
|
||||
{ label: t(".rules_label"), path: rules_path, icon: "git-branch" },
|
||||
{ label: t(".merchants_label"), path: family_merchants_path, icon: "store" }
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('.other_section_title'),
|
||||
header: t(".other_section_title"),
|
||||
items: [
|
||||
{ label: t('.whats_new_label'), path: changelog_path, icon: 'box' },
|
||||
{ label: t('.feedback_label'), path: feedback_path, icon: 'megaphone' }
|
||||
{ label: t(".whats_new_label"), path: changelog_path, icon: "box" },
|
||||
{ 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-2">
|
||||
<%= 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: "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 }) %>
|
||||
|
|
1020
config/exchanges.yml
1020
config/exchanges.yml
File diff suppressed because it is too large
Load diff
|
@ -1,3 +1,4 @@
|
|||
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
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
require "sidekiq/web"
|
||||
|
||||
if Rails.env.production?
|
||||
Sidekiq::Web.use(Rack::Auth::Basic) do |username, password|
|
||||
configured_username = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_USERNAME", "maybe"))
|
||||
configured_password = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_PASSWORD", "maybe"))
|
||||
|
@ -7,3 +8,9 @@ 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(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
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue