diff --git a/Gemfile b/Gemfile index 26b29fc9..3ad4f0a8 100644 --- a/Gemfile +++ b/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" diff --git a/Gemfile.lock b/Gemfile.lock index 224f8f9c..a8c86724 100644 --- a/Gemfile.lock +++ b/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 diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index 8bf9c6c8..f9dc6039 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -240,7 +240,7 @@ 100% { stroke-dashoffset: 0; } - } + } } /* Specific override for strong tags in prose under dark mode */ @@ -429,5 +429,3 @@ } } - - diff --git a/app/assets/tailwind/maybe-design-system/background-utils.css b/app/assets/tailwind/maybe-design-system/background-utils.css index 1c7bc56a..f7244692 100644 --- a/app/assets/tailwind/maybe-design-system/background-utils.css +++ b/app/assets/tailwind/maybe-design-system/background-utils.css @@ -78,10 +78,22 @@ } } +@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%); @variant theme-dark { background-color: var(--color-alpha-black-900); } -} \ No newline at end of file +} + +@utility bg-loader { + @apply bg-surface-inset animate-pulse; +} diff --git a/app/components/tabs_component.html.erb b/app/components/tabs_component.html.erb index 4ec901fa..bfceddad 100644 --- a/app/components/tabs_component.html.erb +++ b/app/components/tabs_component.html.erb @@ -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 diff --git a/app/components/tabs_component.rb b/app/components/tabs_component.rb index 4017b308..747a9420 100644 --- a/app/components/tabs_component.rb +++ b/app/components/tabs_component.rb @@ -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 diff --git a/app/components/tabs_controller.js b/app/components/tabs_controller.js index 43a4b192..259765aa 100644 --- a/app/components/tabs_controller.js +++ b/app/components/tabs_controller.js @@ -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(), + }); + } } diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 47b6a258..a74087a2 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a54dc088..260579d1 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/concerns/auto_sync.rb b/app/controllers/concerns/auto_sync.rb index 4e375359..e6ced672 100644 --- a/app/controllers/concerns/auto_sync.rb +++ b/app/controllers/concerns/auto_sync.rb @@ -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 diff --git a/app/controllers/concerns/notifiable.rb b/app/controllers/concerns/notifiable.rb index 0d8ea384..b1689f67 100644 --- a/app/controllers/concerns/notifiable.rb +++ b/app/controllers/concerns/notifiable.rb @@ -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 } } } diff --git a/app/controllers/concerns/restore_layout_preferences.rb b/app/controllers/concerns/restore_layout_preferences.rb new file mode 100644 index 00000000..284df4cc --- /dev/null +++ b/app/controllers/concerns/restore_layout_preferences.rb @@ -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 diff --git a/app/controllers/cookie_sessions_controller.rb b/app/controllers/cookie_sessions_controller.rb new file mode 100644 index 00000000..7e76636f --- /dev/null +++ b/app/controllers/cookie_sessions_controller.rb @@ -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 diff --git a/app/controllers/current_sessions_controller.rb b/app/controllers/current_sessions_controller.rb new file mode 100644 index 00000000..303b51f0 --- /dev/null +++ b/app/controllers/current_sessions_controller.rb @@ -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 diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index 37efd5e3..8812cf0f 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -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] ) diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 318ed60d..a63f30ee 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -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 ] diff --git a/app/javascript/controllers/rule/conditions_controller.js b/app/javascript/controllers/rule/conditions_controller.js index 1cffa119..d0c12941 100644 --- a/app/javascript/controllers/rule/conditions_controller.js +++ b/app/javascript/controllers/rule/conditions_controller.js @@ -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) { diff --git a/app/javascript/controllers/rules_controller.js b/app/javascript/controllers/rules_controller.js index 0db0e67a..3618acc6 100644 --- a/app/javascript/controllers/rules_controller.js +++ b/app/javascript/controllers/rules_controller.js @@ -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++; + } + } + }); + } } diff --git a/app/javascript/controllers/sidebar_tabs_controller.js b/app/javascript/controllers/sidebar_tabs_controller.js deleted file mode 100644 index f88a70f7..00000000 --- a/app/javascript/controllers/sidebar_tabs_controller.js +++ /dev/null @@ -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"); - } - }); - } -} diff --git a/app/jobs/import_market_data_job.rb b/app/jobs/import_market_data_job.rb new file mode 100644 index 00000000..294d5836 --- /dev/null +++ b/app/jobs/import_market_data_job.rb @@ -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 diff --git a/app/jobs/sync_cleaner_job.rb b/app/jobs/sync_cleaner_job.rb new file mode 100644 index 00000000..f5e42551 --- /dev/null +++ b/app/jobs/sync_cleaner_job.rb @@ -0,0 +1,7 @@ +class SyncCleanerJob < ApplicationJob + queue_as :scheduled + + def perform + Sync.clean + end +end diff --git a/app/models/account.rb b/app/models/account.rb index acd013a8..6e2695de 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/models/account/convertible.rb b/app/models/account/convertible.rb deleted file mode 100644 index fde6fa10..00000000 --- a/app/models/account/convertible.rb +++ /dev/null @@ -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 diff --git a/app/models/account/market_data_importer.rb b/app/models/account/market_data_importer.rb new file mode 100644 index 00000000..af538314 --- /dev/null +++ b/app/models/account/market_data_importer.rb @@ -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 diff --git a/app/models/account/sync_complete_event.rb b/app/models/account/sync_complete_event.rb new file mode 100644 index 00000000..32315375 --- /dev/null +++ b/app/models/account/sync_complete_event.rb @@ -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 diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb new file mode 100644 index 00000000..ab198a95 --- /dev/null +++ b/app/models/account/syncer.rb @@ -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 diff --git a/app/models/balance/base_calculator.rb b/app/models/balance/base_calculator.rb deleted file mode 100644 index 2d01dfe7..00000000 --- a/app/models/balance/base_calculator.rb +++ /dev/null @@ -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 diff --git a/app/models/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb index d024d2c6..4e6f2d5c 100644 --- a/app/models/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -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 diff --git a/app/models/balance/syncer.rb b/app/models/balance/materializer.rb similarity index 88% rename from app/models/balance/syncer.rb rename to app/models/balance/materializer.rb index 362b87aa..75a98ffd 100644 --- a/app/models/balance/syncer.rb +++ b/app/models/balance/materializer.rb @@ -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 diff --git a/app/models/balance/reverse_calculator.rb b/app/models/balance/reverse_calculator.rb index 6a2ba70b..52a05608 100644 --- a/app/models/balance/reverse_calculator.rb +++ b/app/models/balance/reverse_calculator.rb @@ -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 diff --git a/app/models/balance_sheet.rb b/app/models/balance_sheet.rb index c289f86f..a89a9859 100644 --- a/app/models/balance_sheet.rb +++ b/app/models/balance_sheet.rb @@ -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) diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index 9d0ecb34..12ee9888 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -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 diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb index d804b992..9b5e09e4 100644 --- a/app/models/concerns/syncable.rb +++ b/app/models/concerns/syncable.rb @@ -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) - SyncJob.perform_later(new_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 end - def sync(start_date: nil) - syncs.create!(start_date: start_date).perform + def perform_sync(sync) + syncer.perform_sync(sync) end - def sync_data(sync, start_date: nil) - raise NotImplementedError, "Subclasses must implement the `sync_data` method" + def perform_post_sync + syncer.perform_post_sync end - def post_sync(sync) - # no-op, syncable can optionally provide implementation + 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 diff --git a/app/models/entry.rb b/app/models/entry.rb index c07f27cf..36f61c29 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -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 diff --git a/app/models/exchange_rate/importer.rb b/app/models/exchange_rate/importer.rb new file mode 100644 index 00000000..133106bc --- /dev/null +++ b/app/models/exchange_rate/importer.rb @@ -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 diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index dbe87133..defee421 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -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 diff --git a/app/models/family.rb b/app/models/family.rb index 68487f40..cd068cae 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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? diff --git a/app/models/family/plaid_connectable.rb b/app/models/family/plaid_connectable.rb new file mode 100644 index 00000000..f2a997c8 --- /dev/null +++ b/app/models/family/plaid_connectable.rb @@ -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 diff --git a/app/models/family/subscribeable.rb b/app/models/family/subscribeable.rb index 4c8624b7..94ee0971 100644 --- a/app/models/family/subscribeable.rb +++ b/app/models/family/subscribeable.rb @@ -72,10 +72,9 @@ 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") - end + def sync_trial_status! + if subscription&.status == "trialing" && days_left_in_trial < 0 + subscription.update!(status: "paused") end + end end diff --git a/app/models/family/sync_complete_event.rb b/app/models/family/sync_complete_event.rb new file mode 100644 index 00000000..628841d0 --- /dev/null +++ b/app/models/family/sync_complete_event.rb @@ -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 diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb new file mode 100644 index 00000000..30ce2ad5 --- /dev/null +++ b/app/models/family/syncer.rb @@ -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 diff --git a/app/models/holding/base_calculator.rb b/app/models/holding/base_calculator.rb deleted file mode 100644 index d9b85d03..00000000 --- a/app/models/holding/base_calculator.rb +++ /dev/null @@ -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 diff --git a/app/models/holding/forward_calculator.rb b/app/models/holding/forward_calculator.rb index d2f2e8d7..43f91f7a 100644 --- a/app/models/holding/forward_calculator.rb +++ b/app/models/holding/forward_calculator.rb @@ -1,10 +1,12 @@ -class Holding::ForwardCalculator < Holding::BaseCalculator - private - def portfolio_cache - @portfolio_cache ||= Holding::PortfolioCache.new(account) - end +class Holding::ForwardCalculator + attr_reader :account - def calculate_holdings + def initialize(account) + @account = account + end + + 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 diff --git a/app/models/holding/syncer.rb b/app/models/holding/materializer.rb similarity index 87% rename from app/models/holding/syncer.rb rename to app/models/holding/materializer.rb index 345f2a3f..e4931128 100644 --- a/app/models/holding/syncer.rb +++ b/app/models/holding/materializer.rb @@ -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") diff --git a/app/models/holding/portfolio_cache.rb b/app/models/holding/portfolio_cache.rb index 2d67a1d8..9ffed15b 100644 --- a/app/models/holding/portfolio_cache.rb +++ b/app/models/holding/portfolio_cache.rb @@ -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( diff --git a/app/models/holding/reverse_calculator.rb b/app/models/holding/reverse_calculator.rb index 62e2dc95..f52184d7 100644 --- a/app/models/holding/reverse_calculator.rb +++ b/app/models/holding/reverse_calculator.rb @@ -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 diff --git a/app/models/import.rb b/app/models/import.rb index 3ea68015..e96d1fc1 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -62,7 +62,7 @@ class Import < ApplicationRecord def publish import! - family.sync + family.sync_later update! status: :complete rescue => error diff --git a/app/models/market_data_importer.rb b/app/models/market_data_importer.rb new file mode 100644 index 00000000..9eaf5964 --- /dev/null +++ b/app/models/market_data_importer.rb @@ -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 diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 4f60013e..4730985d 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -20,7 +20,10 @@ class PlaidAccount < ApplicationRecord internal_account = family.accounts.find_or_initialize_by(plaid_account_id: plaid_account.id) - internal_account.name = plaid_data.name + # 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 diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 2226f12f..2ba10599 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -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 diff --git a/app/models/plaid_item/provided.rb b/app/models/plaid_item/provided.rb deleted file mode 100644 index f2e8ee8f..00000000 --- a/app/models/plaid_item/provided.rb +++ /dev/null @@ -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 diff --git a/app/models/plaid_item/sync_complete_event.rb b/app/models/plaid_item/sync_complete_event.rb new file mode 100644 index 00000000..ca008a0a --- /dev/null +++ b/app/models/plaid_item/sync_complete_event.rb @@ -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 diff --git a/app/models/plaid_item/syncer.rb b/app/models/plaid_item/syncer.rb new file mode 100644 index 00000000..e32b9ed4 --- /dev/null +++ b/app/models/plaid_item/syncer.rb @@ -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 diff --git a/app/models/provider.rb b/app/models/provider.rb index c90866e7..e9702349 100644 --- a/app/models/provider.rb +++ b/app/models/provider.rb @@ -36,8 +36,6 @@ class Provider default_error_transformer(error) end - Sentry.capture_exception(transformed_error) - Response.new( success?: false, data: nil, diff --git a/app/models/provider/security_concept.rb b/app/models/provider/security_concept.rb index 1fc915e7..d54b2011 100644 --- a/app/models/provider/security_concept.rb +++ b/app/models/provider/security_concept.rb @@ -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 diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index ff75ff49..fee3a236 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -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)) - end + 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| + 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( - security: security, - date: price.dig("date"), - price: price.dig("close") || price.dig("open"), - currency: currency + symbol: symbol, + date: date.to_date, + price: price, + currency: currency, + exchange_operating_mic: exchange_operating_mic ) - end + end.compact end end diff --git a/app/models/rule.rb b/app/models/rule.rb index db8a99ae..b0d405c2 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -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 diff --git a/app/models/rule/action.rb b/app/models/rule/action.rb index 3316c415..5945503e 100644 --- a/app/models/rule/action.rb +++ b/app/models/rule/action.rb @@ -1,5 +1,5 @@ class Rule::Action < ApplicationRecord - belongs_to :rule + belongs_to :rule, touch: true validates :action_type, presence: true diff --git a/app/models/rule/condition.rb b/app/models/rule/condition.rb index 13b622b5..b10d30ca 100644 --- a/app/models/rule/condition.rb +++ b/app/models/rule/condition.rb @@ -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 diff --git a/app/models/security/price/importer.rb b/app/models/security/price/importer.rb new file mode 100644 index 00000000..4e6bee2f --- /dev/null +++ b/app/models/security/price/importer.rb @@ -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 diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index b342c9e5..2fdcc607 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -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 diff --git a/app/models/session.rb b/app/models/session.rb index ce26938a..66faa0f5 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -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 diff --git a/app/models/stock_exchange.rb b/app/models/stock_exchange.rb deleted file mode 100644 index 93631426..00000000 --- a/app/models/stock_exchange.rb +++ /dev/null @@ -1,3 +0,0 @@ -class StockExchange < ApplicationRecord - scope :in_country, ->(country_code) { where(country_code: country_code) } -end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 5f96361e..90b5303d 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -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 diff --git a/app/models/sync.rb b/app/models/sync.rb index 4d923f12..456a4b01 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -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) - - 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 + syncable.perform_sync(self) + rescue => e + fail! + update(error: e.message) + report_error(e) + ensure + finalize_if_all_children_finalized end end end - private - def start! - Rails.logger.info("Starting sync") - update! status: :syncing - end + # Finalizes the current sync AND parent (if it exists) + def finalize_if_all_children_finalized + Sync.transaction do + lock! - def complete! - Rails.logger.info("Sync completed") - update! status: :completed, last_ran_at: Time.current - end + # If this is the "parent" and there are still children running, don't finalize. + return unless all_children_finalized? - def fail!(error, report_error: false) - Rails.logger.error("Sync failed: #{error.message}") - - if report_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) + if syncing? + if has_failed_children? + fail! + else + complete! end end - update!( - status: :failed, - error: error.message, - last_ran_at: Time.current - ) + # 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 log_status_change + Rails.logger.info("changing from #{aasm.from_state} to #{aasm.to_state} (event: #{aasm.current_event})") + end + + def has_failed_children? + children.failed.any? + end + + def all_children_finalized? + children.incomplete.empty? + end + + def perform_post_sync + Rails.logger.info("Performing post-sync for #{syncable_type} (#{syncable.id})") + syncable.perform_post_sync + syncable.broadcast_sync_complete + rescue => e + Rails.logger.error("Error performing post-sync for #{syncable_type} (#{syncable.id}): #{e.message}") + report_error(e) + end + + def report_error(error) + Sentry.capture_exception(error) do |scope| + scope.set_tags(sync_id: id) + end + end + + def report_warnings + todays_sync_count = syncable.syncs.where(created_at: Date.current.all_day).count + + if todays_sync_count > 10 + Sentry.capture_exception( + Error.new("#{syncable_type} (#{syncable.id}) has exceeded 10 syncs today (count: #{todays_sync_count})"), + level: :warning + ) + end + end + + def window_valid + if window_start_date && window_end_date && window_start_date > window_end_date + errors.add(:window_end_date, "must be greater than window_start_date") + end end end diff --git a/app/models/trade_builder.rb b/app/models/trade_builder.rb index 4f0019b9..cf9800e5 100644 --- a/app/models/trade_builder.rb +++ b/app/models/trade_builder.rb @@ -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 diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index b5a65fa0..e4fc3c2d 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -32,9 +32,13 @@
<%= render "accounts/account_error", account: account, given_title: "Account has an error", link_to_path: account_path(account, return_to: return_to) %> -

"> - <%= format_money account.balance_money %> -

+ <% if account.syncing? %> +
+ <% else %> +

"> + <%= format_money account.balance_money %> +

+ <% end %> <% unless account.scheduled_for_deletion? %> <%= styled_form_with model: account, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %> diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb index 9ca2781c..056e7be1 100644 --- a/app/views/accounts/_account_sidebar_tabs.html.erb +++ b/app/views/accounts/_account_sidebar_tabs.html.erb @@ -1,6 +1,6 @@ -<%# locals: (family:, active_account_group_tab:) %> +<%# locals: (family:, active_tab:, mobile: false) %> -
+
<% if family.missing_data_provider? %>
@@ -21,73 +21,71 @@
<% end %> -
- <%= render TabsComponent.new(active_tab: active_account_group_tab, url_param_key: "account_group_tab", testid: "account-sidebar-tabs") do |tabs| %> - <% tabs.with_nav do |nav| %> - <% nav.with_btn(id: "assets", label: "Assets") %> - <% nav.with_btn(id: "debts", label: "Debts") %> - <% nav.with_btn(id: "all", label: "All") %> - <% end %> + <%= 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: "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 %> -
- <%= render LinkComponent.new( - text: "New asset", - variant: "ghost", - href: new_account_path(step: "method_select", classification: "asset"), - icon: "plus", - frame: :modal, - full_width: true, - class: "justify-start" - ) %> - -
- <% family.balance_sheet.account_groups("asset").each do |group| %> - <%= render "accounts/accountable_group", account_group: group %> - <% end %> -
-
- <% end %> - - <% tabs.with_panel(tab_id: "debts") do %> -
- <%= render LinkComponent.new( - text: "New debt", + <% tabs.with_panel(tab_id: "asset") do %> +
+ <%= render LinkComponent.new( + text: "New asset", variant: "ghost", - href: new_account_path(step: "method_select", classification: "liability"), + href: new_account_path(step: "method_select", classification: "asset"), icon: "plus", frame: :modal, - full_width: true, - class: "justify-start" - ) %> - -
- <% family.balance_sheet.account_groups("liability").each do |group| %> - <%= render "accounts/accountable_group", account_group: group %> - <% end %> -
-
- <% end %> - - <% tabs.with_panel(tab_id: "all") do %> -
- <%= render LinkComponent.new( - text: "New account", - variant: "ghost", full_width: true, - href: new_account_path(step: "method_select"), - icon: "plus", - frame: :modal, - class: "justify-start" - ) %> + class: "justify-start" + ) %> -
- <% family.balance_sheet.account_groups.each do |group| %> - <%= render "accounts/accountable_group", account_group: group %> - <% end %> -
+
+ <% family.balance_sheet.account_groups("asset").each do |group| %> + <%= render "accounts/accountable_group", account_group: group, mobile: mobile %> + <% end %>
- <% end %> +
<% end %> -
+ + <% tabs.with_panel(tab_id: "liability") do %> +
+ <%= render LinkComponent.new( + text: "New debt", + variant: "ghost", + href: new_account_path(step: "method_select", classification: "liability"), + icon: "plus", + frame: :modal, + full_width: true, + class: "justify-start" + ) %> + +
+ <% family.balance_sheet.account_groups("liability").each do |group| %> + <%= render "accounts/accountable_group", account_group: group, mobile: mobile %> + <% end %> +
+
+ <% end %> + + <% tabs.with_panel(tab_id: "all") do %> +
+ <%= render LinkComponent.new( + text: "New account", + variant: "ghost", + full_width: true, + href: new_account_path(step: "method_select"), + icon: "plus", + frame: :modal, + class: "justify-start" + ) %> + +
+ <% family.balance_sheet.account_groups.each do |group| %> + <%= render "accounts/accountable_group", account_group: group, mobile: mobile %> + <% end %> +
+
+ <% end %> + <% end %>
diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index 48808245..26633918 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -1,58 +1,69 @@ -<%# locals: (account_group:) %> +<%# locals: (account_group:, mobile: false, open: nil, **args) %> -<%= render DisclosureComponent.new(title: account_group.name, align: :left, open: account_group.accounts.any? { |account| page_active?(account_path(account)) }) do |disclosure| %> - <% disclosure.with_summary_content do %> -
- <%= 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 %> -
-
-
- <% end %> -
- <% end %> - -
- <% account_group.accounts.each do |account| %> - -
" - data-sidebar-tabs-target="account"> +
"> + <% 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 %> +
+ <% if account_group.syncing? %> +
+
+
+
+
+
+ <% 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 %> +
+
+
+ <% end %> + <% end %> +
+ <% end %> +
+ <% account_group.accounts.each do |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 %> + 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" + ), + title: account.name do %> <%= render "accounts/logo", account: account, size: "sm", color: account_group.color %> -
+
<%= 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" %>
- <%# Render account error warnings %> - <%= render "accounts/account_error", account: account, given_title: "Account has an error", link_to_path: account_path(account) %> - <% end %> -
- <%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %> + <% if account.syncing? %> +
+
+
+
+
+
+
+
+ <% else %> +
+ <%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %> - <%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %> -
-
+ <%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %> +
+
+
+ <% end %>
<% end %> -
-
- <% end %> -
+ <% end %> + <% end %> +
-
- <%= render LinkComponent.new( +
+ <%= render LinkComponent.new( href: new_polymorphic_path(account_group.key, step: "method_select"), text: "New #{account_group.name.downcase.singularize}", icon: "plus", @@ -61,5 +72,6 @@ frame: :modal, class: "justify-start" ) %> -
-<% end %> +
+ <% end %> +
diff --git a/app/views/accounts/_chart_loader.html.erb b/app/views/accounts/_chart_loader.html.erb index f6a9c852..b080329f 100644 --- a/app/views/accounts/_chart_loader.html.erb +++ b/app/views/accounts/_chart_loader.html.erb @@ -1,5 +1,7 @@ -
+
+
-
-

Loading...

+ +
+
diff --git a/app/views/accounts/chart.html.erb b/app/views/accounts/chart.html.erb index 11dcbaac..b181ef20 100644 --- a/app/views/accounts/chart.html.erb +++ b/app/views/accounts/chart.html.erb @@ -2,21 +2,25 @@ <% trend = series.trend %> <%= turbo_frame_tag dom_id(@account, :chart_details) do %> -
- <%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %> -
+ <% if @account.syncing? %> + <%= render "accounts/chart_loader" %> + <% else %> +
+ <%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %> +
-
- <% if series.any? %> -
+ <% if series.any? %> +
- <% else %> -
-

<%= t(".data_not_available") %>

-
- <% end %> -
+ <% else %> +
+

<%= t(".data_not_available") %>

+
+ <% end %> +
+ <% end %> <% end %> diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index c105ef08..7a442fc8 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -2,17 +2,6 @@

<%= t(".accounts") %>

- <% 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), diff --git a/app/views/accounts/index/_account_groups.erb b/app/views/accounts/index/_account_groups.erb index 6347339c..51ba4d8b 100644 --- a/app/views/accounts/index/_account_groups.erb +++ b/app/views/accounts/index/_account_groups.erb @@ -6,9 +6,12 @@

<%= Accountable.from_type(group).display_name %>

·

<%= accounts.count %>

-

<%= totals_by_currency(collection: accounts, money_method: :balance_money) %>

+ + <% unless accounts.any?(&:syncing?) %> +

<%= totals_by_currency(collection: accounts, money_method: :balance_money) %>

+ <% end %>
-
+
<% accounts.each do |account| %> <%= render account %> <% end %> diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index 356ba8ca..95c1b734 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -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") %> -
+
- <%= 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 %>
- <%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %> + <% if account.syncing? %> +
+ <% else %> + <%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %> + <% end %>
<%= styled_form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %> diff --git a/app/views/accounts/show/_template.html.erb b/app/views/accounts/show/_template.html.erb index cfac9402..efc7ea5a 100644 --- a/app/views/accounts/show/_template.html.erb +++ b/app/views/accounts/show/_template.html.erb @@ -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 %>
diff --git a/app/views/category/dropdowns/show.html.erb b/app/views/category/dropdowns/show.html.erb index 65b5c9be..3b25083b 100644 --- a/app/views/category/dropdowns/show.html.erb +++ b/app/views/category/dropdowns/show.html.erb @@ -2,13 +2,13 @@
- " - autocomplete="nope" - 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" /> + " + autocomplete="nope" + 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"> <%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
diff --git a/app/views/holdings/_cash.html.erb b/app/views/holdings/_cash.html.erb index a279d9ab..702f40d0 100644 --- a/app/views/holdings/_cash.html.erb +++ b/app/views/holdings/_cash.html.erb @@ -19,8 +19,13 @@
<% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %> - <%= render "shared/progress_circle", progress: cash_weight %> - <%= tag.p number_to_percentage(cash_weight, precision: 1) %> + + <% if account.syncing? %> +
+ <% else %> + <%= render "shared/progress_circle", progress: cash_weight %> + <%= tag.p number_to_percentage(cash_weight, precision: 1) %> + <% end %>
@@ -28,7 +33,13 @@
- <%= tag.p format_money account.cash_balance_money %> + <% if account.syncing? %> +
+
+
+ <% else %> + <%= tag.p format_money account.cash_balance_money %> + <% end %>
diff --git a/app/views/holdings/_holding.html.erb b/app/views/holdings/_holding.html.erb index 5fe0e4c9..c8a2ac59 100644 --- a/app/views/holdings/_holding.html.erb +++ b/app/views/holdings/_holding.html.erb @@ -17,7 +17,9 @@
- <% if holding.weight %> + <% if holding.account.syncing? %> +
+ <% elsif holding.weight %> <%= render "shared/progress_circle", progress: holding.weight %> <%= tag.p number_to_percentage(holding.weight, precision: 1) %> <% else %> @@ -26,21 +28,39 @@
- <%= tag.p format_money holding.avg_cost %> - <%= tag.p t(".per_share"), class: "font-normal text-secondary" %> -
- -
- <% if holding.amount_money %> - <%= tag.p format_money holding.amount_money %> + <% if holding.account.syncing? %> +
+
+
<% else %> - <%= tag.p "--", class: "text-secondary" %> + <%= tag.p format_money holding.avg_cost %> + <%= tag.p t(".per_share"), class: "font-normal text-secondary" %> <% end %> - <%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-secondary" %>
- <% if holding.trend %> + <% if holding.account.syncing? %> +
+
+
+
+ <% 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 %> +
+ +
+ <% if holding.account.syncing? %> +
+
+
+
+ <% 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 %> diff --git a/app/views/investments/show.html.erb b/app/views/investments/show.html.erb index 7bd7da3b..84be8629 100644 --- a/app/views/investments/show.html.erb +++ b/app/views/investments/show.html.erb @@ -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", - 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 - ) %> - -
- <%= 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) }, - ] %> -
- <% end %> -<% end %> +<%= render "accounts/show/template", + account: @account, + chart_view: @chart_view, + 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) }, + ]) %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index b7df7020..e8fc202e 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -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 ) %>
@@ -80,8 +81,8 @@ <%= yield :sidebar %> <% else %>
-
- <%= render "accounts/account_sidebar_tabs", family: Current.family, active_account_group_tab: params[:account_group_tab] || "assets" %> +
+ <%= render "accounts/account_sidebar_tabs", family: Current.family, active_tab: @account_group_tab %>
<% if Current.family.trialing? && !self_hosted? %> diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index fadcd396..30887f7b 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -23,10 +23,6 @@ <%= render_flash_notifications %>
- - <% if Current.family&.syncing? %> - <%= render "shared/notifications/loading", id: "syncing-notice", message: "Syncing accounts data..." %> - <% end %>
diff --git a/app/views/loans/_overview.html.erb b/app/views/loans/_overview.html.erb index f04ccc49..e1649583 100644 --- a/app/views/loans/_overview.html.erb +++ b/app/views/loans/_overview.html.erb @@ -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 %> diff --git a/app/views/onboardings/goals.html.erb b/app/views/onboardings/goals.html.erb index f08f51cb..9ea04b07 100644 --- a/app/views/onboardings/goals.html.erb +++ b/app/views/onboardings/goals.html.erb @@ -26,7 +26,7 @@
<% [ - { 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" }, diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 569caaa2..1f2f347e 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -25,11 +25,14 @@
<% if Current.family.accounts.any? %> -
- <%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @balance_sheet.net_worth_series(period: @period), period: @period } %> +
+ <%= render partial: "pages/dashboard/net_worth_chart", locals: { + balance_sheet: @balance_sheet, + period: @period + } %>
<% else %> -
+
<%= render "pages/dashboard/no_accounts_graph_placeholder" %>
<% end %> diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index 302ae103..6b4ea525 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -1,6 +1,6 @@ -<%# locals: (balance_sheet:) %> +<%# locals: (balance_sheet:, **args) %> -
+
<% balance_sheet.classification_groups.each do |classification_group| %>

@@ -11,26 +11,38 @@ <% if classification_group.account_groups.any? %> · - <%= classification_group.total_money.format(precision: 0) %> + <% if classification_group.syncing? %> +
+
+
+ <% else %> + <%= classification_group.total_money.format(precision: 0) %> + <% end %> <% end %>

<% if classification_group.account_groups.any? %> +
<% classification_group.account_groups.each do |account_group| %>
<% end %>
-
- <% classification_group.account_groups.each do |account_group| %> -
-
-

<%= account_group.name %>

-

<%= number_to_percentage(account_group.weight, precision: 0) %>

-
- <% end %> -
+ + <% if classification_group.syncing? %> +

Calculating latest balance data...

+ <% else %> +
+ <% classification_group.account_groups.each do |account_group| %> +
+
+

<%= account_group.name %>

+

<%= number_to_percentage(account_group.weight, precision: 0) %>

+
+ <% end %> +
+ <% end %>
@@ -56,15 +68,27 @@

<%= account_group.name %>

-
-
- <%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %> -
+ <% if account_group.syncing? %> +
+
+
+
-
-

<%= format_money(account_group.total_money) %>

+
+
+
-
+ <% else %> +
+
+ <%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %> +
+ +
+

<%= format_money(account_group.total_money) %>

+
+
+ <% end %>
@@ -76,15 +100,27 @@ <%= link_to account.name, account_path(account) %>
-
-
- <%= render "pages/dashboard/group_weight", weight: account.weight, color: account_group.color %> -
+ <% if account.syncing? %> +
+
+
+
-
-

<%= format_money(account.balance_money) %>

+
+
+
-
+ <% else %> +
+
+ <%= render "pages/dashboard/group_weight", weight: account.weight, color: account_group.color %> +
+ +
+

<%= format_money(account.balance_money) %>

+
+
+ <% end %>
<% if idx < account_group.accounts.size - 1 %> diff --git a/app/views/pages/dashboard/_net_worth_chart.html.erb b/app/views/pages/dashboard/_net_worth_chart.html.erb index a8870cac..a6b65852 100644 --- a/app/views/pages/dashboard/_net_worth_chart.html.erb +++ b/app/views/pages/dashboard/_net_worth_chart.html.erb @@ -1,37 +1,55 @@ -<%# locals: (series:, period:) %> +<%# locals: (balance_sheet:, period:, **args) %> -
-
+
+ <% series = balance_sheet.net_worth_series(period: period) %> +
-

<%= t(".title") %>

-

- <%= series.current.format %> -

- <% if series.trend.nil? %> -

<%= t(".data_not_available") %>

- <% else %> - <%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %> - <% end %> -
-
+
+

<%= t(".title") %>

- <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form" } do |form| %> - <%= form.select :period, + <% if balance_sheet.syncing? %> +
+
+
+
+ <% else %> +

+ <%= series.current.format %> +

+ + <% if series.trend.nil? %> +

<%= t(".data_not_available") %>

+ <% else %> + <%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %> + <% end %> + <% end %> +
+
+ + <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form" } do |form| %> + <%= form.select :period, Period.as_options, { selected: period.key }, data: { "auto-submit-form-target": "auto" }, class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %> - <% end %> -
+ <% end %> +
-<% if series.any? %> -
+
+
+
+ <% else %> + <% if series.any? %> +
-<% else %> -
-

<%= t(".data_not_available") %>

-
-<% end %> + <% else %> +
+

<%= t(".data_not_available") %>

+
+ <% end %> + <% end %> +
diff --git a/app/views/rule/actions/_action.html.erb b/app/views/rule/actions/_action.html.erb index f81eff1b..0f3ebeac 100644 --- a/app/views/rule/actions/_action.html.erb +++ b/app/views/rule/actions/_action.html.erb @@ -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. %> - to + to <%= form.select :value, action.options || [], {} %> <% end %>
@@ -29,12 +29,12 @@ <%# Templates for different input types - these will be cloned and used by the Stimulus controller %> diff --git a/app/views/rule/conditions/_condition.html.erb b/app/views/rule/conditions/_condition.html.erb index b79978a1..60c38eab 100644 --- a/app/views/rule/conditions/_condition.html.erb +++ b/app/views/rule/conditions/_condition.html.erb @@ -4,8 +4,11 @@ <% rule = condition.rule %>
  • - <% if form.index.to_i > 0 && show_prefix %> -
    + + <%# Conditionally render the prefix %> + <%# Condition groups pass in show_prefix: false for subconditions since the ANY/ALL selector makes that clear %> + <% if show_prefix %> +
    and
    <% end %> diff --git a/app/views/rule/conditions/_condition_group.html.erb b/app/views/rule/conditions/_condition_group.html.erb index 67b3eb0f..e04a09f7 100644 --- a/app/views/rule/conditions/_condition_group.html.erb +++ b/app/views/rule/conditions/_condition_group.html.erb @@ -3,17 +3,16 @@ <% condition = form.object %> <% rule = condition.rule %> -
  • +
  • <%= form.hidden_field :condition_type, value: "compound" %>
    - <% unless form.index == 0 %> -
    - and -
    - <% end %> + <%# Show prefix on condition groups, except the first one %> +
    + and +

    match

    <%= form.select :operator, [["all", "and"], ["any", "or"]], { container_class: "w-fit" }, data: { rules_target: "operatorField" } %>

    of the following conditions

    @@ -21,16 +20,16 @@ <%= icon( "trash-2", - size: "sm", as_button: true, - data: { action: "element-removal#remove" } + size: "sm", + data: { action: "rule--conditions#remove" } ) %>
    <%# Sub-condition template, used by Stimulus controller to add new sub-conditions dynamically %> @@ -44,6 +43,7 @@ text: "Add condition", leading_icon: "plus", variant: "ghost", + type: "button", data: { action: "rule--conditions#addSubCondition" } ) %>
  • diff --git a/app/views/rules/_form.html.erb b/app/views/rules/_form.html.erb index 79c0b45e..387423f2 100644 --- a/app/views/rules/_form.html.erb +++ b/app/views/rules/_form.html.erb @@ -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 %>
    -

    If <%= rule.resource_type %>

    +
    + <%= icon "tag", size: "sm" %> +

    Rule name (optional)

    +
    +
    + <%= f.text_field :name, placeholder: "Enter a name for this rule", class: "form-field__input" %> +
    +
    - <%# Condition template, used by Stimulus controller to add new conditions dynamically %> +
    +
    +

    IF

    +
    + + <%# Condition Group template, used by Stimulus controller to add new conditions dynamically %> -
      - <%= f.fields_for :conditions do |cf| %> - <% if cf.object.compound? %> - <%= render "rule/conditions/condition_group", form: cf %> - <% else %> - <%= render "rule/conditions/condition", form: cf %> +
      +
        + <%= f.fields_for :conditions do |cf| %> + <% if cf.object.compound? %> + <%= render "rule/conditions/condition_group", form: cf %> + <% else %> + <%= render "rule/conditions/condition", form: cf %> + <% end %> <% end %> - <% end %> -
      +
    <%= 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" }) %>
    -

    Then

    +
    +

    THEN

    +
    <%# Action template, used by Stimulus controller to add new actions dynamically %> -
      - <%= f.fields_for :actions do |af| %> - <%= render "rule/actions/action", form: af %> - <% end %> -
    +
    +
      + <%= f.fields_for :actions do |af| %> + <%= render "rule/actions/action", form: af %> + <% end %> +
    <%= render ButtonComponent.new(text: "Add action", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %>
    -

    Apply this

    +
    +

    FOR

    +
    -
    +
    <%= 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" %>
    diff --git a/app/views/rules/_rule.html.erb b/app/views/rules/_rule.html.erb index e2b01b74..1dbf9641 100644 --- a/app/views/rules/_rule.html.erb +++ b/app/views/rules/_rule.html.erb @@ -1,51 +1,64 @@ <%# locals: (rule:) %> - -
    +
    ">
    + <% if rule.name.present? %> +

    <%= rule.name %>

    + <% end %> <% if rule.conditions.any? %> -

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

    +
    + IF +
    +

    + + <% if rule.conditions.first.compound? %> + <%= rule.conditions.first.sub_conditions.first.filter.label %> <%= rule.conditions.first.sub_conditions.first.operator %> <%= rule.conditions.first.sub_conditions.first.value_display %> + <% else %> + <%= rule.conditions.first.filter.label %> <%= rule.conditions.first.operator %> <%= rule.conditions.first.value_display %> + <% end %> + + <% if rule.conditions.count > 1 %> + and <%= rule.conditions.count - 1 %> more <%= rule.conditions.count - 1 == 1 ? "condition" : "conditions" %> + <% end %> +

    +
    + <% end %> +
    +
    + THEN +
    +

    + + <% if rule.actions.first.value && rule.actions.first.options %> + <%= rule.actions.first.executor.label %> to <%= rule.actions.first.value_display %> <% else %> - If <%= rule.conditions.first.filter.label %> <%= rule.conditions.first.operator %> <%= rule.conditions.first.value_display %> + <%= rule.actions.first.executor.label %> <% end %> - - <% if rule.conditions.count > 1 %> - and <%= rule.conditions.count - 1 %> more <%= rule.conditions.count - 1 == 1 ? "condition" : "conditions" %> + <% if rule.actions.count > 1 %> + and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %> <% end %>

    - <% end %> - -

    - - <% 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 %> - - - <% if rule.actions.count > 1 %> - and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %> - <% end %> -

    - -

    - <% if rule.effective_date.nil? %> - To all past and future <%= rule.resource_type.pluralize %> - <% else %> - To all <%= rule.resource_type.pluralize %> on or after <%= rule.effective_date %> - <% end %> -

    +
    +
    +
    + FOR +
    +

    + + <% if rule.effective_date.nil? %> + All past and future <%= rule.resource_type.pluralize %> + <% else %> + <%= rule.resource_type.pluralize %> on or after <%= rule.effective_date.strftime("%b %-d, %Y") %> + <% end %> + +

    +
    -
    <%= 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" }) %> diff --git a/app/views/rules/confirm.html.erb b/app/views/rules/confirm.html.erb index 987a7fa9..03311791 100644 --- a/app/views/rules/confirm.html.erb +++ b/app/views/rules/confirm.html.erb @@ -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 %>

    diff --git a/app/views/rules/edit.html.erb b/app/views/rules/edit.html.erb index 6693ac5e..91dea816 100644 --- a/app/views/rules/edit.html.erb +++ b/app/views/rules/edit.html.erb @@ -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 %> diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index 2c3040a1..c6ca513f 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -1,6 +1,5 @@

    Rules

    -
    <% 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 @@ ) %>
    - <% if self_hosted? %>
    <%= icon("circle-alert", size: "sm") %> @@ -32,19 +29,43 @@

    <% end %> -
    - <% if @rules.any? %> -
    -
    -

    Rules

    - · -

    <%= @rules.count %>

    +
    +
    +
    +

    Rules

    + · +

    <%= @rules.count %>

    +
    +
    + Sort by: + <%= 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" + ) %> +
    - -
    - <%= render @rules %> +
    +
    + <% @rules.each_with_index do |rule, idx| %> + <%= render "rule", rule: rule %> + <% unless idx == @rules.size - 1 %> +
    + <% end %> + <% end %> +
    <% else %> diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index 6a80bb87..5aa2c258 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -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" } ] } ] diff --git a/app/views/shared/notifications/_loading.html.erb b/app/views/shared/notifications/_loading.html.erb deleted file mode 100644 index 18eabd89..00000000 --- a/app/views/shared/notifications/_loading.html.erb +++ /dev/null @@ -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 %> -
    - <%= icon "loader", class: "animate-pulse" %> -
    - - <%= tag.p message, class: "text-primary text-sm font-medium" %> -<% end %> diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 9781672f..52bb9c3d 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -4,9 +4,6 @@
    <%= 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 }) %> diff --git a/config/exchanges.yml b/config/exchanges.yml deleted file mode 100644 index 9b429d14..00000000 --- a/config/exchanges.yml +++ /dev/null @@ -1,1020 +0,0 @@ -- name: NASDAQ Stock Exchange - acronym: NASDAQ - mic: XNAS - country: USA - country_code: US - city: New York - website: www.nasdaq.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: New York Stock Exchange - acronym: NYSE - mic: XNYS - country: USA - country_code: US - city: New York - website: www.nyse.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: NYSE ARCA - acronym: NYSEARCA - mic: ARCX - country: USA - country_code: US - city: New York - website: www.nyse.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTC Markets - acronym: - mic: OTCM - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: Buenos Aires Stock Exchange - acronym: BCBA - mic: XBUE - country: Argentina - country_code: AR - city: Buenos Aires - website: www.bcba.sba.com.ar - timezone: - timezone: America/Argentina/Buenos_Aires - abbr: -03 - abbr_dst: -03 - currency: - code: ARS - symbol: AR$ - name: Argentine Peso -- name: Bahrein Bourse - acronym: BSE - mic: XBAH - country: Bahrain - country_code: BH - city: Manama - website: www.bahrainbourse.com.bh - timezone: - timezone: Asia/Bahrain - abbr: +03 - abbr_dst: +03 - currency: - code: BHD - symbol: BD - name: Bahraini Dinar -- name: Euronext Brussels - acronym: Euronext - mic: XBRU - country: Belgium - country_code: BE - city: Brussels - website: www.euronext.com - timezone: - timezone: Europe/Brussels - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: B3 - Brasil Bolsa Balcão S.A - acronym: Bovespa - mic: BVMF - country: Brazil - country_code: BR - city: Sao Paulo - website: www.bmfbovespa.com.br - timezone: - timezone: America/Sao_Paulo - abbr: -03 - abbr_dst: -03 - currency: - code: BRL - symbol: R$ - name: Brazilian Real -- name: Toronto Stock Exchange - acronym: TSX - mic: XTSE - country: Canada - country_code: CA - city: Toronto - website: www.tse.com - timezone: - timezone: America/Toronto - abbr: EST - abbr_dst: EDT - currency: - code: CAD - symbol: CA$ - name: Canadian Dollar -- name: Canadian Securities Exchange - acronym: CNSX - mic: XCNQ - country: Canada - country_code: CA - city: Toronto - website: www.cnsx.ca - timezone: - timezone: America/Toronto - abbr: EST - abbr_dst: EDT - currency: - code: CAD - symbol: CA$ - name: Canadian Dollar -- name: Santiago Stock Exchange - acronym: BVS - mic: XSGO - country: Chile - country_code: CL - city: Santiago - website: www.bolsadesantiago.com - timezone: - timezone: America/Santiago - abbr: -03 - abbr_dst: -04 - currency: - code: CLP - symbol: CL$ - name: Chilean Peso -- name: Shanghai Stock Exchange - acronym: SSE - mic: XSHG - country: China - country_code: CN - city: Shanghai - website: www.sse.com.cn - timezone: - timezone: Asia/Shanghai - abbr: CST - abbr_dst: CST - currency: - code: CNY - symbol: CN¥ - name: Chinese Yuan -- name: Shenzhen Stock Exchange - acronym: SZSE - mic: XSHE - country: China - country_code: CN - city: Shenzhen - website: www.szse.cn - timezone: - timezone: Asia/Shanghai - abbr: CST - abbr_dst: CST - currency: - code: CNY - symbol: CN¥ - name: Chinese Yuan -- name: Bolsa de Valores de Colombia - acronym: BVC - mic: XBOG - country: Colombia - country_code: CO - city: Bogota - website: www.bvc.com.co - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: COP - symbol: CO$ - name: Colombian Peso -- name: Copenhagen Stock Exchange - acronym: OMXC - mic: XCSE - country: Denmark - country_code: DK - city: Copenhagen - website: www.nasdaqomxnordic.com - timezone: - timezone: Europe/Copenhagen - abbr: CET - abbr_dst: CEST - currency: - code: DKK - symbol: Dkr - name: Danish Krone -- name: Eqyptian Exchange - acronym: EGX - mic: XCAI - country: Egypt - country_code: EG - city: Cairo - website: www.egyptse.com - timezone: - timezone: Africa/Cairo - abbr: EET - abbr_dst: EET - currency: - code: EGP - symbol: EGP - name: Egyptian Pound -- name: Tallinn Stock Exchange - acronym: OMXT - mic: XTAL - country: Estonia - country_code: EE - city: Tallinn - website: www.nasdaqbaltic.com - timezone: - timezone: Europe/Tallinn - abbr: EET - abbr_dst: EEST - currency: - code: EUR - symbol: € - name: Euro -- name: Helsinki Stock Exchange - acronym: OMXH - mic: XHEL - country: Finland - country_code: FI - city: Helsinki - website: www.nasdaqomxnordic.com - timezone: - timezone: Europe/Helsinki - abbr: EET - abbr_dst: EEST - currency: - code: EUR - symbol: € - name: Euro -- name: Euronext Paris - acronym: Euronext - mic: XPAR - country: France - country_code: FR - city: Paris - website: www.euronext.com - timezone: - timezone: Europe/Paris - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Deutsche Börse - acronym: FSX - mic: XFRA - country: Germany - country_code: DE - city: Frankfurt - website: www.deutsche-boerse.com - timezone: - timezone: Europe/Berlin - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Börse Stuttgart - acronym: XSTU - mic: XSTU - country: Germany - country_code: DE - city: Stuttgart - website: www.boerse-stuttgart.de - timezone: - timezone: Europe/Berlin - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Deutsche Börse Xetra - acronym: XETR - mic: XETRA - country: Germany - country_code: DE - city: Frankfurt - website: - timezone: - timezone: Europe/Berlin - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Hong Kong Stock Exchange - acronym: HKEX - mic: XHKG - country: Hong Kong - country_code: HK - city: Hong Kong - website: www.hkex.com.hk - timezone: - timezone: Asia/Hong_Kong - abbr: HKT - abbr_dst: HKT - currency: - code: HKD - symbol: HK$ - name: Hong Kong Dollar -- name: Nasdaq Island - acronym: XICE - mic: XICE - country: Iceland - country_code: IS - city: Reykjavík - website: www.nasdaqomxnordic.com - timezone: - timezone: Atlantic/Reykjavik - abbr: GMT - abbr_dst: GMT - currency: - code: ISK - symbol: Ikr - name: Icelandic Króna -- name: Bombay Stock Exchange - acronym: MSE - mic: XBOM - country: India - country_code: IN - city: Mumbai - website: www.bseindia.com - timezone: - timezone: Asia/Kolkata - abbr: IST - abbr_dst: IST - currency: - code: INR - symbol: Rs - name: Indian Rupee -- name: National Stock Exchange India - acronym: NSE - mic: XNSE - country: India - country_code: IN - city: Mumbai - website: www.nseindia.com - timezone: - timezone: Asia/Kolkata - abbr: IST - abbr_dst: IST - currency: - code: INR - symbol: Rs - name: Indian Rupee -- name: Jakarta Stock Exchange - acronym: IDX - mic: XIDX - country: Indonesia - country_code: ID - city: Jakarta - website: www.idx.co.id - timezone: - timezone: Asia/Jakarta - abbr: WIB - abbr_dst: WIB - currency: - code: IDR - symbol: Rp - name: Indonesian Rupiah -- name: Tel Aviv Stock Exchange - acronym: TASE - mic: XTAE - country: Israel - country_code: IL - city: Tel Aviv - website: www.tase.co.il - timezone: - timezone: Asia/Jerusalem - abbr: IST - abbr_dst: IDT - currency: - code: ILS - symbol: ₪ - name: Israeli New Sheqel -- name: Borsa Italiana - acronym: MIL - mic: XMIL - country: Italy - country_code: IT - city: Milano - website: www.borsaitaliana.it - timezone: - timezone: Europe/Rome - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Nagoya Stock Exchange - acronym: NSE - mic: XNGO - country: Japan - country_code: JP - city: Nagoya - website: www.nse.or.jp - timezone: - timezone: Asia/Tokyo - abbr: JST - abbr_dst: JST - currency: - code: JPY - symbol: ¥ - name: Japanese Yen -- name: Fukuoka Stock Exchange - acronym: XFKA - mic: XFKA - country: Japan - country_code: JP - city: Fukuoka - website: www.fse.or.jp - timezone: - timezone: Asia/Tokyo - abbr: JST - abbr_dst: JST - currency: - code: JPY - symbol: ¥ - name: Japanese Yen -- name: Sapporo Stock Exchange - acronym: XSAP - mic: XSAP - country: Japan - country_code: JP - city: Sapporo - website: www.sse.or.jp - timezone: - timezone: Asia/Tokyo - abbr: JST - abbr_dst: JST - currency: - code: JPY - symbol: ¥ - name: Japanese Yen -- name: Nasdaq Riga - acronym: OMXR - mic: XRIS - country: Latvia - country_code: LV - city: Riga - website: www.nasdaqbaltic.com - timezone: - timezone: Europe/Riga - abbr: EET - abbr_dst: EEST - currency: - code: EUR - symbol: € - name: Euro -- name: Nasdaq Vilnius - acronym: OMXV - mic: XLIT - country: Lithuania - country_code: LT - city: Vilnius - website: www.nasdaqbaltic.com - timezone: - timezone: Europe/Vilnius - abbr: EET - abbr_dst: EEST - currency: - code: EUR - symbol: € - name: Euro -- name: Malaysia Stock Exchange - acronym: MYX - mic: XKLS - country: Malaysia - country_code: MY - city: Kuala Lumpur - website: www.bursamalaysia.com - timezone: - timezone: Asia/Kuala_Lumpur - abbr: +08 - abbr_dst: +08 - currency: - code: MYR - symbol: RM - name: Malaysian Ringgit -- name: Mexican Stock Exchange - acronym: BMV - mic: XMEX - country: Mexico - country_code: MX - city: Mexico City - website: www.bmv.com.mx - timezone: - timezone: America/Mexico_City - abbr: CST - abbr_dst: CDT - currency: - code: MXN - symbol: MX$ - name: Mexican Peso -- name: Euronext Amsterdam - acronym: Euronext - mic: XAMS - country: Netherlands - country_code: NL - city: Amsterdam - website: www.euronext.com - timezone: - timezone: Europe/Amsterdam - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: New Zealand Stock Exchange - acronym: NZX - mic: XNZE - country: New Zealand - country_code: NZ - city: Wellington - website: www.nzx.com - timezone: - timezone: Pacific/Auckland - abbr: NZDT - abbr_dst: NZST - currency: - code: NZD - symbol: NZ$ - name: New Zealand Dollar -- name: Nigerian Stock Exchange - acronym: NSE - mic: XNSA - country: Nigeria - country_code: NG - city: Lagos - website: www.nse.com.ng - timezone: - timezone: Africa/Lagos - abbr: WAT - abbr_dst: WAT - currency: - code: NGN - symbol: ₦ - name: Nigerian Naira -- name: Oslo Stock Exchange - acronym: OSE - mic: XOSL - country: Norway - country_code: NO - city: Oslo - website: www.oslobors.no - timezone: - timezone: Europe/Oslo - abbr: CET - abbr_dst: CEST - currency: - code: NOK - symbol: Nkr - name: Norwegian Krone -- name: Bolsa de Valores de Lima - acronym: BVL - mic: XLIM - country: Peru - country_code: PE - city: Lima - website: www.bvl.com.pe - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: PEN - symbol: S/. - name: Peruvian Nuevo Sol -- name: Warsaw Stock Exchange - acronym: GPW - mic: XWAR - country: Poland - country_code: PL - city: Warsaw - website: www.gpw.pl - timezone: - timezone: Europe/Warsaw - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Euronext Lisbon - acronym: Euronext - mic: XLIS - country: Portugal - country_code: PT - city: Lisboa - website: www.euronext.com - timezone: - timezone: Europe/Lisbon - abbr: WET - abbr_dst: WEST - currency: - code: EUR - symbol: € - name: Euro -- name: Qatar Stock Exchange - acronym: QE - mic: DSMD - country: Qatar - country_code: QA - city: Doha - website: www.qatarexchange.qa - timezone: - timezone: Asia/Qatar - abbr: +03 - abbr_dst: +03 - currency: - code: QAR - symbol: QR - name: Qatari Rial -- name: Moscow Stock Exchange - acronym: MOEX - mic: MISX - country: Russia - country_code: RU - city: Moscow - website: www.moex.com - timezone: - timezone: Europe/Moscow - abbr: MSK - abbr_dst: MSK - currency: - code: RUB - symbol: RUB - name: Russian Ruble -- name: Saudi Stock Exchange - acronym: TADAWUL - mic: XSAU - country: Saudi Arabia - country_code: SA - city: Riyadh - website: www.tadawul.com.sa - timezone: - timezone: Asia/Riyadh - abbr: +03 - abbr_dst: +03 - currency: - code: SAR - symbol: SR - name: Saudi Riyal -- name: Belgrade Stock Exchange - acronym: BELEX - mic: XBEL - country: Serbia - country_code: RS - city: Belgrade - website: www.belex.rs - timezone: - timezone: Europe/Belgrade - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Singapore Stock Exchange - acronym: SGX - mic: XSES - country: Singapore - country_code: SG - city: Singapore - website: www.sgx.com - timezone: - timezone: Asia/Singapore - abbr: +08 - abbr_dst: +08 - currency: - code: SGD - symbol: S$ - name: Singapore Dollar -- name: Johannesburg Stock Exchange - acronym: JSE - mic: XJSE - country: South Africa - country_code: ZA - city: Johannesburg - website: www.jse.co.za - timezone: - timezone: Africa/Johannesburg - abbr: SAST - abbr_dst: SAST - currency: - code: ZAR - symbol: R - name: South African Rand -- name: Korean Stock Exchange - acronym: KRX - mic: XKRX - country: South Korea - country_code: KR - city: Seoul - website: http://eng.krx.co.kr - timezone: - timezone: Asia/Seoul - abbr: KST - abbr_dst: KST - currency: - code: KRW - symbol: ₩ - name: South Korean Won -- name: Bolsas y Mercados Españoles - acronym: BME - mic: BMEX - country: Spain - country_code: ES - city: Madrid - website: www.bolsasymercados.es - timezone: - timezone: Europe/Madrid - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: Stockholm Stock Exchange - acronym: OMX - mic: XSTO - country: Sweden - country_code: SE - city: Stockholm - website: www.nasdaqomxnordic.com - timezone: - timezone: Europe/Stockholm - abbr: CET - abbr_dst: CEST - currency: - code: EUR - symbol: € - name: Euro -- name: SIX Swiss Exchange - acronym: SIX - mic: XSWX - country: Switzerland - country_code: CH - city: Zurich - website: www.six-swiss-exchange.com - timezone: - timezone: Europe/Zurich - abbr: CET - abbr_dst: CEST - currency: - code: CHF - symbol: CHF - name: Swiss Franc -- name: Taiwan Stock Exchange - acronym: TWSE - mic: XTAI - country: Taiwan - country_code: TW - city: Taipei - website: www.twse.com.tw/en/ - timezone: - timezone: Asia/Taipei - abbr: CST - abbr_dst: CST - currency: - code: TWD - symbol: NT$ - name: New Taiwan Dollar -- name: Stock Exchange of Thailand - acronym: SET - mic: XBKK - country: Thailand - country_code: TH - city: Bangkok - website: www.set.or.th - timezone: - timezone: Asia/Bangkok - abbr: +07 - abbr_dst: +07 - currency: - code: THB - symbol: ฿ - name: Thai Baht -- name: Istanbul Stock Exchange - acronym: BIST - mic: XIST - country: Turkey - country_code: TR - city: Istanbul - website: www.borsaistanbul.com - timezone: - timezone: Europe/Istanbul - abbr: +03 - abbr_dst: +03 - currency: - code: TRY - symbol: TL - name: Turkish Lira -- name: Dubai Financial Market - acronym: DFM - mic: XDFM - country: United Arab Emirates - country_code: AE - city: Dubai - website: www.dfm.co.ae - timezone: - timezone: Asia/Dubai - abbr: +04 - abbr_dst: +04 - currency: - code: AED - symbol: AED - name: United Arab Emirates Dirham -- name: London Stock Exchange - acronym: LSE - mic: XLON - country: United Kingdom - country_code: GB - city: London - website: www.londonstockexchange.com - timezone: - timezone: Europe/London - abbr: GMT - abbr_dst: BST - currency: - code: GBP - symbol: £ - name: British Pound Sterling -- name: Ho Chi Minh Stock Exchange - acronym: HOSE - mic: XSTC - country: Vietnam - country_code: VN - city: Ho Chi Minh City - website: www.hsx.vn - timezone: - timezone: Asia/Ho_Chi_Minh - abbr: +07 - abbr_dst: +07 - currency: - code: VND - symbol: ₫ - name: Vietnamese Dong -- name: American Stock Exchange - acronym: AMEX - mic: XASE - country: USA - country_code: US - city: New York - website: www.nyse.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: Cboe BZX U.S. Equities Exchang - acronym: BATS - mic: XCBO - country: USA - country_code: US - city: Chicago - website: markets.cboe.com - timezone: - timezone: America/Chicago - abbr: CDT - abbr_dst: CDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: US Mutual Funds - acronym: NMFQS - mic: NMFQS - country: USA - country_code: US - city: New York - website: - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTC Bulletin Board - acronym: OTCBB - mic: OOTC - country: USA - country_code: US - city: Washington - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTC Grey Market - acronym: OTCGREY - mic: PSGM - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTCQB Marketplace - acronym: OTCQB - mic: OTCB - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTCQX Marketplace - acronym: OTCQX - mic: OTCQ - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: OTC PINK current - acronym: PINK - mic: PINC - country: USA - country_code: US - city: New York - website: www.otcmarkets.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar -- name: Investors Exchange - acronym: IEX - mic: IEXG - country: USA - country_code: US - city: New York - website: www.iextrading.com - timezone: - timezone: America/New_York - abbr: EST - abbr_dst: EDT - currency: - code: USD - symbol: $ - name: US Dollar \ No newline at end of file diff --git a/config/initializers/mini_profiler.rb b/config/initializers/mini_profiler.rb index d304a50e..6a79f19e 100644 --- a/config/initializers/mini_profiler.rb +++ b/config/initializers/mini_profiler.rb @@ -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 diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 1209a4fa..70a6e476 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,9 +1,16 @@ require "sidekiq/web" -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")) +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")) - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) && - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password) + ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(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 diff --git a/config/routes.rb b/config/routes.rb index 4c84973a..aadf5ebb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,5 @@ require "sidekiq/web" +require "sidekiq/cron/web" Rails.application.routes.draw do # MFA routes @@ -25,6 +26,8 @@ Rails.application.routes.draw do get "changelog", to: "pages#changelog" get "feedback", to: "pages#feedback" + resource :current_session, only: %i[update] + resource :registration, only: %i[new create] resources :sessions, only: %i[new create destroy] resource :password_reset, only: %i[new create edit update] @@ -104,10 +107,6 @@ Rails.application.routes.draw do end resources :accounts, only: %i[index new], shallow: true do - collection do - post :sync_all - end - member do post :sync get :chart diff --git a/config/schedule.yml b/config/schedule.yml new file mode 100644 index 00000000..75b709a6 --- /dev/null +++ b/config/schedule.yml @@ -0,0 +1,14 @@ +import_market_data: + cron: "0 22 * * 1-5" # 5:00 PM EST / 6:00 PM EDT (NY time) + class: "ImportMarketDataJob" + queue: "scheduled" + description: "Imports market data daily at 5:00 PM EST (1 hour after market close)" + args: + mode: "full" + clear_cache: false + +clean_syncs: + cron: "0 * * * *" # every hour + class: "SyncCleanerJob" + queue: "scheduled" + description: "Cleans up stale syncs" diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 20343e8c..4fce6f00 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -1,6 +1,7 @@ concurrency: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %> queues: - - [high_priority, 6] + - [scheduled, 10] # For cron-like jobs (e.g. "daily market data sync") + - [high_priority, 4] - [medium_priority, 2] - [low_priority, 1] - [default, 1] diff --git a/db/migrate/20250429021255_add_name_to_rules.rb b/db/migrate/20250429021255_add_name_to_rules.rb new file mode 100644 index 00000000..f69e1140 --- /dev/null +++ b/db/migrate/20250429021255_add_name_to_rules.rb @@ -0,0 +1,5 @@ +class AddNameToRules < ActiveRecord::Migration[7.2] + def change + add_column :rules, :name, :string + end +end diff --git a/db/migrate/20250512171654_update_sync_timestamps.rb b/db/migrate/20250512171654_update_sync_timestamps.rb new file mode 100644 index 00000000..ac0830b6 --- /dev/null +++ b/db/migrate/20250512171654_update_sync_timestamps.rb @@ -0,0 +1,65 @@ +class UpdateSyncTimestamps < ActiveRecord::Migration[7.2] + def change + # Timestamps, managed by aasm + add_column :syncs, :pending_at, :datetime + add_column :syncs, :syncing_at, :datetime + add_column :syncs, :completed_at, :datetime + add_column :syncs, :failed_at, :datetime + + add_column :syncs, :window_start_date, :date + add_column :syncs, :window_end_date, :date + + reversible do |dir| + dir.up do + execute <<-SQL + UPDATE syncs + SET + completed_at = CASE + WHEN status = 'completed' THEN last_ran_at + ELSE NULL + END, + failed_at = CASE + WHEN status = 'failed' THEN last_ran_at + ELSE NULL + END + SQL + + execute <<-SQL + UPDATE syncs + SET window_start_date = start_date + SQL + + # Due to some recent bugs, some self hosters have syncs that are stuck. + # This manually fails those syncs so they stop seeing syncing UI notices. + if Rails.application.config.app_mode.self_hosted? + puts "Self hosted: Fail syncs older than 2 hours" + execute <<-SQL + UPDATE syncs + SET status = 'failed' + WHERE ( + status = 'syncing' AND + created_at < NOW() - INTERVAL '2 hours' + ) + SQL + end + end + + dir.down do + execute <<-SQL + UPDATE syncs + SET + last_ran_at = COALESCE(completed_at, failed_at) + SQL + + execute <<-SQL + UPDATE syncs + SET start_date = window_start_date + SQL + end + end + + remove_column :syncs, :start_date, :date + remove_column :syncs, :last_ran_at, :datetime + remove_column :syncs, :error_backtrace, :text, array: true + end +end diff --git a/db/migrate/20250513122703_add_uniqueness_to_subscriptions.rb b/db/migrate/20250513122703_add_uniqueness_to_subscriptions.rb new file mode 100644 index 00000000..f97b67f9 --- /dev/null +++ b/db/migrate/20250513122703_add_uniqueness_to_subscriptions.rb @@ -0,0 +1,6 @@ +class AddUniquenessToSubscriptions < ActiveRecord::Migration[7.2] + def change + remove_index :subscriptions, :family_id + add_index :subscriptions, :family_id, unique: true + end +end diff --git a/db/migrate/20250514214242_add_metadata_to_session.rb b/db/migrate/20250514214242_add_metadata_to_session.rb new file mode 100644 index 00000000..849cdccf --- /dev/null +++ b/db/migrate/20250514214242_add_metadata_to_session.rb @@ -0,0 +1,5 @@ +class AddMetadataToSession < ActiveRecord::Migration[7.2] + def change + add_column :sessions, :data, :jsonb, default: {} + end +end diff --git a/db/migrate/20250516180846_remove_stock_exchanges.rb b/db/migrate/20250516180846_remove_stock_exchanges.rb new file mode 100644 index 00000000..19c4a529 --- /dev/null +++ b/db/migrate/20250516180846_remove_stock_exchanges.rb @@ -0,0 +1,11 @@ +class RemoveStockExchanges < ActiveRecord::Migration[7.2] + def change + drop_table :stock_exchanges do |t| + t.string :name, null: false + t.string :acronym + t.string :mic, null: false + t.string :country, null: false + t.string :country_code, null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index f4a5013e..580cf764 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -506,6 +506,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_17_134646) do t.boolean "active", default: false, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "name" t.index ["family_id"], name: "index_rules_on_family_id" end @@ -544,6 +545,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_17_134646) do t.uuid "active_impersonator_session_id" t.datetime "subscribed_at" t.jsonb "prev_transaction_page_params", default: {} + t.jsonb "data", default: {} t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id" t.index ["user_id"], name: "index_sessions_on_user_id" end @@ -622,21 +624,24 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_17_134646) do t.datetime "trial_ends_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_subscriptions_on_family_id" + t.index ["family_id"], name: "index_subscriptions_on_family_id", unique: true end create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "syncable_type", null: false t.uuid "syncable_id", null: false - t.datetime "last_ran_at" - t.date "start_date" t.string "status", default: "pending" t.string "error" t.jsonb "data" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.text "error_backtrace", array: true t.uuid "parent_id" + t.datetime "pending_at" + t.datetime "syncing_at" + t.datetime "completed_at" + t.datetime "failed_at" + t.date "window_start_date" + t.date "window_end_date" t.index ["parent_id"], name: "index_syncs_on_parent_id" t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" end diff --git a/lib/tasks/stripe.rake b/lib/tasks/stripe.rake new file mode 100644 index 00000000..7e2124de --- /dev/null +++ b/lib/tasks/stripe.rake @@ -0,0 +1,28 @@ +namespace :stripe do + desc "Sync legacy Stripe subscriptions" + task sync_legacy_subscriptions: :environment do + cli = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"]) + + subs = cli.v1.subscriptions.list + + subs.auto_paging_each do |sub| + details = sub.items.data.first + + family = Family.find_by(stripe_customer_id: sub.customer) + + if family.nil? + puts "Family not found for Stripe customer ID: #{sub.customer}, skipping" + next + end + + family.subscription.update!( + stripe_id: sub.id, + status: sub.status, + interval: details.plan.interval, + amount: details.plan.amount / 100.0, + currency: details.plan.currency.upcase, + current_period_ends_at: Time.at(details.current_period_end) + ) + end + end +end diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index d85a5ffa..a3d827e8 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -15,9 +15,4 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest post sync_account_path(@account) assert_redirected_to account_path(@account) end - - test "can sync all accounts" do - post sync_all_accounts_path - assert_redirected_to accounts_path - end end diff --git a/test/controllers/concerns/auto_sync_test.rb b/test/controllers/concerns/auto_sync_test.rb new file mode 100644 index 00000000..a388456b --- /dev/null +++ b/test/controllers/concerns/auto_sync_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class AutoSyncTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @family = @user.family + + # Start fresh + Sync.destroy_all + end + + test "auto-syncs family if hasn't synced" do + assert_difference "Sync.count", 1 do + get root_path + end + end + + test "auto-syncs family if hasn't synced in last 24 hours" do + # If request comes in at beginning of day, but last sync was 1 hour ago ("yesterday"), we still sync + travel_to Time.current.beginning_of_day + last_sync_datetime = 1.hour.ago + + Sync.create!(syncable: @family, created_at: last_sync_datetime) + + assert_difference "Sync.count", 1 do + get root_path + end + end + + test "does not auto-sync if family has synced today already" do + travel_to Time.current.end_of_day + + last_created_sync_at = 23.hours.ago + + Sync.create!(syncable: @family, created_at: last_created_sync_at) + + assert_no_difference "Sync.count" do + get root_path + end + end +end diff --git a/test/controllers/current_sessions_controller_test.rb b/test/controllers/current_sessions_controller_test.rb new file mode 100644 index 00000000..9d498fb3 --- /dev/null +++ b/test/controllers/current_sessions_controller_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class CurrentSessionsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + sign_in @user + end + + test "can update the preferred tab for any namespace" do + put current_session_url, params: { current_session: { tab_key: "accounts_sidebar_tab", tab_value: "asset" } } + assert_response :success + session = Session.order(updated_at: :desc).first + assert_equal "asset", session.get_preferred_tab("accounts_sidebar_tab") + end +end diff --git a/test/controllers/plaid_items_controller_test.rb b/test/controllers/plaid_items_controller_test.rb index 7aede85c..4cf8e10a 100644 --- a/test/controllers/plaid_items_controller_test.rb +++ b/test/controllers/plaid_items_controller_test.rb @@ -8,7 +8,7 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest test "create" do @plaid_provider = mock - PlaidItem.expects(:plaid_provider_for_region).with("us").returns(@plaid_provider) + Provider::Registry.expects(:get_provider).with(:plaid_us).returns(@plaid_provider) public_token = "public-sandbox-1234" diff --git a/test/controllers/subscriptions_controller_test.rb b/test/controllers/subscriptions_controller_test.rb index 1e791632..0b406dca 100644 --- a/test/controllers/subscriptions_controller_test.rb +++ b/test/controllers/subscriptions_controller_test.rb @@ -32,8 +32,6 @@ class SubscriptionsControllerTest < ActionDispatch::IntegrationTest end test "users who have already trialed cannot create a new subscription" do - @family.start_trial_subscription! - assert_no_difference "Subscription.count" do post subscription_path end diff --git a/test/fixtures/stock_exchanges.yml b/test/fixtures/stock_exchanges.yml deleted file mode 100644 index 872e5b27..00000000 --- a/test/fixtures/stock_exchanges.yml +++ /dev/null @@ -1,13 +0,0 @@ -nasdaq: - name: NASDAQ - mic: XNAS - acronym: NASDAQ - country: USA - country_code: US - -nyse: - name: New York Stock Exchange - mic: XNYS - acronym: NYSE - country: USA - country_code: US diff --git a/test/fixtures/syncs.yml b/test/fixtures/syncs.yml index 1b010568..5d954280 100644 --- a/test/fixtures/syncs.yml +++ b/test/fixtures/syncs.yml @@ -1,17 +1,17 @@ account: syncable_type: Account syncable: depository - last_ran_at: <%= Time.now %> status: completed + completed_at: <%= Time.now %> plaid_item: syncable_type: PlaidItem syncable: one - last_ran_at: <%= Time.now %> status: completed + completed_at: <%= Time.now %> family: syncable_type: Family syncable: dylan_family - last_ran_at: <%= Time.now %> status: completed + completed_at: <%= Time.now %> diff --git a/test/interfaces/exchange_rate_provider_interface_test.rb b/test/interfaces/exchange_rate_provider_interface_test.rb index 9293c4d9..3716f6fc 100644 --- a/test/interfaces/exchange_rate_provider_interface_test.rb +++ b/test/interfaces/exchange_rate_provider_interface_test.rb @@ -15,6 +15,7 @@ module ExchangeRateProviderInterfaceTest assert_equal "USD", rate.from assert_equal "GBP", rate.to + assert rate.date.is_a?(Date) assert_in_delta 0.78, rate.rate, 0.01 end end @@ -26,6 +27,7 @@ module ExchangeRateProviderInterfaceTest ) assert_equal 213, response.data.count # 213 days between 01.01.2024 and 31.07.2024 + assert response.data.first.date.is_a?(Date) end end diff --git a/test/interfaces/security_provider_interface_test.rb b/test/interfaces/security_provider_interface_test.rb index 44385ede..a994bb73 100644 --- a/test/interfaces/security_provider_interface_test.rb +++ b/test/interfaces/security_provider_interface_test.rb @@ -7,7 +7,7 @@ module SecurityProviderInterfaceTest aapl = securities(:aapl) VCR.use_cassette("#{vcr_key_prefix}/security_price") do - response = @subject.fetch_security_price(aapl, date: Date.iso8601("2024-08-01")) + response = @subject.fetch_security_price(symbol: aapl.ticker, exchange_operating_mic: aapl.exchange_operating_mic, date: Date.iso8601("2024-08-01")) assert response.success? assert response.data.present? @@ -19,12 +19,14 @@ module SecurityProviderInterfaceTest VCR.use_cassette("#{vcr_key_prefix}/security_prices") do response = @subject.fetch_security_prices( - aapl, + symbol: aapl.ticker, + exchange_operating_mic: aapl.exchange_operating_mic, start_date: Date.iso8601("2024-01-01"), end_date: Date.iso8601("2024-08-01") ) assert response.success? + assert response.data.first.date.is_a?(Date) assert_equal 147, response.data.count # Synth won't return prices on weekends / holidays, so less than total day count of 213 end end @@ -44,7 +46,11 @@ module SecurityProviderInterfaceTest aapl = securities(:aapl) VCR.use_cassette("#{vcr_key_prefix}/security_info") do - response = @subject.fetch_security_info(aapl) + response = @subject.fetch_security_info( + symbol: aapl.ticker, + exchange_operating_mic: aapl.exchange_operating_mic + ) + info = response.data assert_equal "AAPL", info.symbol diff --git a/test/interfaces/syncable_interface_test.rb b/test/interfaces/syncable_interface_test.rb index 6613dbcc..a9f1fa7d 100644 --- a/test/interfaces/syncable_interface_test.rb +++ b/test/interfaces/syncable_interface_test.rb @@ -7,18 +7,14 @@ module SyncableInterfaceTest test "can sync later" do assert_difference "@syncable.syncs.count", 1 do assert_enqueued_with job: SyncJob do - @syncable.sync_later + @syncable.sync_later(window_start_date: 2.days.ago.to_date) end end end - test "can sync" do - assert_difference "@syncable.syncs.count", 1 do - @syncable.sync(start_date: 2.days.ago.to_date) - end - end - - test "implements sync_data" do - assert_respond_to @syncable, :sync_data + test "can perform sync" do + mock_sync = mock + @syncable.class.any_instance.expects(:perform_sync).with(mock_sync).once + @syncable.perform_sync(mock_sync) end end diff --git a/test/jobs/sync_job_test.rb b/test/jobs/sync_job_test.rb index b8d34400..b392b3a3 100644 --- a/test/jobs/sync_job_test.rb +++ b/test/jobs/sync_job_test.rb @@ -4,7 +4,7 @@ class SyncJobTest < ActiveJob::TestCase test "sync is performed" do syncable = accounts(:depository) - sync = syncable.syncs.create!(start_date: 2.days.ago.to_date) + sync = syncable.syncs.create!(window_start_date: 2.days.ago.to_date) sync.expects(:perform).once diff --git a/test/models/account/convertible_test.rb b/test/models/account/convertible_test.rb deleted file mode 100644 index 8d77cae5..00000000 --- a/test/models/account/convertible_test.rb +++ /dev/null @@ -1,52 +0,0 @@ -require "test_helper" -require "ostruct" - -class Account::ConvertibleTest < ActiveSupport::TestCase - include EntriesTestHelper, ProviderTestHelper - - setup do - @family = families(:empty) - @family.update!(currency: "USD") - - # Foreign account (currency is not in the family's primary currency, so it will require exchange rates for net worth rollups) - @account = @family.accounts.create!(name: "Test Account", currency: "EUR", balance: 10000, accountable: Depository.new) - - @provider = mock - ExchangeRate.stubs(:provider).returns(@provider) - end - - test "syncs required exchange rates for an account" do - create_valuation(account: @account, date: 1.day.ago.to_date, amount: 9500, currency: "EUR") - - # Since we had a valuation 1 day ago, this account starts 2 days ago and needs daily exchange rates looking forward - assert_equal 2.days.ago.to_date, @account.start_date - - ExchangeRate.delete_all - - provider_response = provider_success_response( - [ - OpenStruct.new(from: "EUR", to: "USD", date: 2.days.ago.to_date, rate: 1.1), - OpenStruct.new(from: "EUR", to: "USD", date: 1.day.ago.to_date, rate: 1.2), - OpenStruct.new(from: "EUR", to: "USD", date: Date.current, rate: 1.3) - ] - ) - - @provider.expects(:fetch_exchange_rates) - .with(from: "EUR", to: "USD", start_date: 2.days.ago.to_date, end_date: Date.current) - .returns(provider_response) - - assert_difference "ExchangeRate.count", 3 do - @account.sync_required_exchange_rates - end - end - - test "does not sync rates for a domestic account" do - @account.update!(currency: "USD") - - @provider.expects(:fetch_exchange_rates).never - - assert_no_difference "ExchangeRate.count" do - @account.sync_required_exchange_rates - end - end -end diff --git a/test/models/account/entry_test.rb b/test/models/account/entry_test.rb index 5a07ad6e..edd55e68 100644 --- a/test/models/account/entry_test.rb +++ b/test/models/account/entry_test.rb @@ -30,7 +30,7 @@ class EntryTest < ActiveSupport::TestCase prior_date = @entry.date - 1 @entry.update! date: prior_date - @entry.account.expects(:sync_later).with(start_date: prior_date) + @entry.account.expects(:sync_later).with(window_start_date: prior_date) @entry.sync_account_later end @@ -38,14 +38,14 @@ class EntryTest < ActiveSupport::TestCase prior_date = @entry.date @entry.update! date: @entry.date + 1 - @entry.account.expects(:sync_later).with(start_date: prior_date) + @entry.account.expects(:sync_later).with(window_start_date: prior_date) @entry.sync_account_later end test "triggers sync with correct start date when transaction deleted" do @entry.destroy! - @entry.account.expects(:sync_later).with(start_date: nil) + @entry.account.expects(:sync_later).with(window_start_date: nil) @entry.sync_account_later end diff --git a/test/models/account/market_data_importer_test.rb b/test/models/account/market_data_importer_test.rb new file mode 100644 index 00000000..74c42d36 --- /dev/null +++ b/test/models/account/market_data_importer_test.rb @@ -0,0 +1,107 @@ +require "test_helper" +require "ostruct" + +class Account::MarketDataImporterTest < ActiveSupport::TestCase + include ProviderTestHelper + + PROVIDER_BUFFER = 5.days + + setup do + # Ensure a clean slate for deterministic assertions + Security::Price.delete_all + ExchangeRate.delete_all + Trade.delete_all + Holding.delete_all + Security.delete_all + Entry.delete_all + + @provider = mock("provider") + Provider::Registry.any_instance + .stubs(:get_provider) + .with(:synth) + .returns(@provider) + end + + test "syncs required exchange rates for a foreign-currency account" do + family = Family.create!(name: "Smith", currency: "USD") + + account = family.accounts.create!( + name: "Chequing", + currency: "CAD", + balance: 100, + accountable: Depository.new + ) + + # Seed a rate for the first required day so that the importer only needs the next day forward + existing_date = account.start_date + ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: existing_date, rate: 2.0) + + expected_start_date = (existing_date + 1.day) - PROVIDER_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date + + @provider.expects(:fetch_exchange_rates) + .with(from: "CAD", + to: "USD", + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(from: "CAD", to: "USD", date: existing_date, rate: 1.5) + ])) + + before = ExchangeRate.count + Account::MarketDataImporter.new(account).import_all + after = ExchangeRate.count + + assert_operator after, :>, before, "Should insert at least one new exchange-rate row" + end + + test "syncs security prices for securities traded by the account" do + family = Family.create!(name: "Smith", currency: "USD") + + account = family.accounts.create!( + name: "Brokerage", + currency: "USD", + balance: 0, + accountable: Investment.new + ) + + security = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS") + + trade_date = 10.days.ago.to_date + trade = Trade.new(security: security, qty: 1, price: 100, currency: "USD") + + account.entries.create!( + name: "Buy AAPL", + date: trade_date, + amount: 100, + currency: "USD", + entryable: trade + ) + + expected_start_date = trade_date - PROVIDER_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date + + @provider.expects(:fetch_security_prices) + .with(symbol: security.ticker, + exchange_operating_mic: security.exchange_operating_mic, + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(security: security, + date: trade_date, + price: 100, + currency: "USD") + ])) + + @provider.stubs(:fetch_security_info) + .with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic) + .returns(provider_success_response(OpenStruct.new(name: "Apple", logo_url: "logo"))) + + # Ignore exchange-rate calls for this test + @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([])) + + Account::MarketDataImporter.new(account).import_all + + assert_equal 1, Security::Price.where(security: security, date: trade_date).count + end +end diff --git a/test/models/balance/forward_calculator_test.rb b/test/models/balance/forward_calculator_test.rb index 24aecdb5..05215c25 100644 --- a/test/models/balance/forward_calculator_test.rb +++ b/test/models/balance/forward_calculator_test.rb @@ -15,19 +15,19 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase test "balance generation respects user timezone and last generated date is current user date" do # Simulate user in EST timezone - Time.zone = "America/New_York" + Time.use_zone("America/New_York") do + # Set current time to 1am UTC on Jan 5, 2025 + # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for) + travel_to Time.utc(2025, 01, 05, 1, 0, 0) - # Set current time to 1am UTC on Jan 5, 2025 - # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for) - travel_to Time.utc(2025, 01, 05, 1, 0, 0) + # Create a valuation for Jan 3, 2025 + create_valuation(account: @account, date: "2025-01-03", amount: 17000) - # Create a valuation for Jan 3, 2025 - create_valuation(account: @account, date: "2025-01-03", amount: 17000) + expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 17000 ], [ "2025-01-04", 17000 ] ] + calculated = Balance::ForwardCalculator.new(@account).calculate - expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 17000 ], [ "2025-01-04", 17000 ] ] - calculated = Balance::ForwardCalculator.new(@account).calculate - - assert_equal expected, calculated.map { |b| [ b.date.to_s, b.balance ] } + assert_equal expected, calculated.map { |b| [ b.date.to_s, b.balance ] } + end end # When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0. diff --git a/test/models/balance/syncer_test.rb b/test/models/balance/materializer_test.rb similarity index 82% rename from test/models/balance/syncer_test.rb rename to test/models/balance/materializer_test.rb index 648f6b3a..4a5ac439 100644 --- a/test/models/balance/syncer_test.rb +++ b/test/models/balance/materializer_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class Balance::SyncerTest < ActiveSupport::TestCase +class Balance::MaterializerTest < ActiveSupport::TestCase include EntriesTestHelper setup do @@ -14,7 +14,7 @@ class Balance::SyncerTest < ActiveSupport::TestCase end test "syncs balances" do - Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once + Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once @account.expects(:start_date).returns(2.days.ago.to_date) @@ -26,7 +26,7 @@ class Balance::SyncerTest < ActiveSupport::TestCase ) assert_difference "@account.balances.count", 2 do - Balance::Syncer.new(@account, strategy: :forward).sync_balances + Balance::Materializer.new(@account, strategy: :forward).materialize_balances end end @@ -45,7 +45,7 @@ class Balance::SyncerTest < ActiveSupport::TestCase ) assert_difference "@account.balances.count", 3 do - Balance::Syncer.new(@account, strategy: :forward).sync_balances + Balance::Materializer.new(@account, strategy: :forward).materialize_balances end end end diff --git a/test/models/balance/reverse_calculator_test.rb b/test/models/balance/reverse_calculator_test.rb index 38ede057..6d73aea8 100644 --- a/test/models/balance/reverse_calculator_test.rb +++ b/test/models/balance/reverse_calculator_test.rb @@ -25,18 +25,18 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase test "balance generation respects user timezone and last generated date is current user date" do # Simulate user in EST timezone - Time.zone = "America/New_York" + Time.use_zone("America/New_York") do + # Set current time to 1am UTC on Jan 5, 2025 + # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for) + travel_to Time.utc(2025, 01, 05, 1, 0, 0) - # Set current time to 1am UTC on Jan 5, 2025 - # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for) - travel_to Time.utc(2025, 01, 05, 1, 0, 0) + create_valuation(account: @account, date: "2025-01-03", amount: 17000) - create_valuation(account: @account, date: "2025-01-03", amount: 17000) + expected = [ [ "2025-01-02", 17000 ], [ "2025-01-03", 17000 ], [ "2025-01-04", @account.balance ] ] + calculated = Balance::ReverseCalculator.new(@account).calculate - expected = [ [ "2025-01-02", 17000 ], [ "2025-01-03", 17000 ], [ "2025-01-04", @account.balance ] ] - calculated = Balance::ReverseCalculator.new(@account).calculate - - assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.balance ] } + assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.balance ] } + end end test "valuations sync" do diff --git a/test/models/exchange_rate/importer_test.rb b/test/models/exchange_rate/importer_test.rb new file mode 100644 index 00000000..dab40fa8 --- /dev/null +++ b/test/models/exchange_rate/importer_test.rb @@ -0,0 +1,148 @@ +require "test_helper" +require "ostruct" + +class ExchangeRate::ImporterTest < ActiveSupport::TestCase + include ProviderTestHelper + + setup do + @provider = mock + end + + test "syncs missing rates from provider" do + ExchangeRate.delete_all + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.3), + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.4), + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 2.days.ago.to_date, + end_date: Date.current + ).import_provider_rates + + db_rates = ExchangeRate.where(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date..Date.current) + .order(:date) + + assert_equal 3, db_rates.count + assert_equal 1.3, db_rates[0].rate + assert_equal 1.4, db_rates[1].rate + assert_equal 1.5, db_rates[2].rate + end + + test "syncs diff when some rates already exist" do + ExchangeRate.delete_all + + # Pre-populate DB with the first two days + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: 3.days.ago.to_date, rate: 1.2) + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date, rate: 1.25) + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.3) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(1.day.ago.to_date), end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 3.days.ago.to_date, + end_date: Date.current + ).import_provider_rates + + db_rates = ExchangeRate.order(:date) + assert_equal 4, db_rates.count + assert_equal [ 1.2, 1.25, 1.3, 1.3 ], db_rates.map(&:rate) + end + + test "no provider calls when all rates exist" do + ExchangeRate.delete_all + + (3.days.ago.to_date..Date.current).each_with_index do |date, idx| + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date:, rate: 1.2 + idx * 0.01) + end + + @provider.expects(:fetch_exchange_rates).never + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 3.days.ago.to_date, + end_date: Date.current + ).import_provider_rates + end + + # A helpful "reset" option for when we need to refresh provider data + test "full upsert if clear_cache is true" do + ExchangeRate.delete_all + + # Seed DB with stale data + (2.days.ago.to_date..Date.current).each do |date| + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date:, rate: 1.0) + end + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.3), + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.4), + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: 2.days.ago.to_date, + end_date: Date.current, + clear_cache: true + ).import_provider_rates + + db_rates = ExchangeRate.where(from_currency: "USD", to_currency: "EUR").order(:date) + assert_equal [ 1.3, 1.4, 1.5 ], db_rates.map(&:rate) + end + + test "clamps end_date to today when future date is provided" do + ExchangeRate.delete_all + + future_date = Date.current + 3.days + + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.6) + ]) + + @provider.expects(:fetch_exchange_rates) + .with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(Date.current), end_date: Date.current) + .returns(provider_response) + + ExchangeRate::Importer.new( + exchange_rate_provider: @provider, + from: "USD", + to: "EUR", + start_date: Date.current, + end_date: future_date + ).import_provider_rates + + assert_equal 1, ExchangeRate.count + end + + private + def get_provider_fetch_start_date(start_date) + # We fetch with a 5 day buffer to account for weekends and holidays + start_date - 5.days + end +end diff --git a/test/models/exchange_rate_test.rb b/test/models/exchange_rate_test.rb index 64fc328b..021b4edf 100644 --- a/test/models/exchange_rate_test.rb +++ b/test/models/exchange_rate_test.rb @@ -67,26 +67,4 @@ class ExchangeRateTest < ActiveSupport::TestCase assert_nil ExchangeRate.find_or_fetch_rate(from: "USD", to: "EUR", date: Date.current, cache: true) end - - test "upserts rates for currency pair and date range" do - ExchangeRate.delete_all - - ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "USD", to_currency: "EUR", rate: 0.9) - - provider_response = provider_success_response([ - OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.3), - OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.4), - OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.5) - ]) - - @provider.expects(:fetch_exchange_rates) - .with(from: "USD", to: "EUR", start_date: 2.days.ago.to_date, end_date: Date.current) - .returns(provider_response) - - ExchangeRate.sync_provider_rates(from: "USD", to: "EUR", start_date: 2.days.ago.to_date) - - assert_equal 1.3, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: Date.current).rate - assert_equal 1.4, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: 1.day.ago.to_date).rate - assert_equal 1.5, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date).rate - end end diff --git a/test/models/family/syncer_test.rb b/test/models/family/syncer_test.rb new file mode 100644 index 00000000..7fe01a7e --- /dev/null +++ b/test/models/family/syncer_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class Family::SyncerTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + end + + test "syncs plaid items and manual accounts" do + family_sync = syncs(:family) + + manual_accounts_count = @family.accounts.manual.count + items_count = @family.plaid_items.count + + syncer = Family::Syncer.new(@family) + + Account.any_instance + .expects(:sync_later) + .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) + .times(manual_accounts_count) + + PlaidItem.any_instance + .expects(:sync_later) + .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) + .times(items_count) + + syncer.perform_sync(family_sync) + + assert_equal "completed", family_sync.reload.status + end +end diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 24876a77..0229aa6e 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -1,25 +1,9 @@ require "test_helper" -require "csv" class FamilyTest < ActiveSupport::TestCase - include EntriesTestHelper include SyncableInterfaceTest def setup - @family = families(:empty) @syncable = families(:dylan_family) end - - test "syncs plaid items and manual accounts" do - family_sync = syncs(:family) - - manual_accounts_count = @syncable.accounts.manual.count - items_count = @syncable.plaid_items.count - - Account.any_instance.expects(:sync_later) - .with(start_date: nil, parent_sync: family_sync) - .times(manual_accounts_count) - - @syncable.sync_data(family_sync, start_date: family_sync.start_date) - end end diff --git a/test/models/holding/forward_calculator_test.rb b/test/models/holding/forward_calculator_test.rb index 7a8aaa53..89e9b28c 100644 --- a/test/models/holding/forward_calculator_test.rb +++ b/test/models/holding/forward_calculator_test.rb @@ -20,22 +20,22 @@ class Holding::ForwardCalculatorTest < ActiveSupport::TestCase test "holding generation respects user timezone and last generated date is current user date" do # Simulate user in EST timezone - Time.zone = "America/New_York" + Time.use_zone("America/New_York") do + # Set current time to 1am UTC on Jan 5, 2025 + # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for) + travel_to Time.utc(2025, 01, 05, 1, 0, 0) - # Set current time to 1am UTC on Jan 5, 2025 - # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for) - travel_to Time.utc(2025, 01, 05, 1, 0, 0) + voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF") + Security::Price.create!(security: voo, date: "2025-01-02", price: 500) + Security::Price.create!(security: voo, date: "2025-01-03", price: 500) + Security::Price.create!(security: voo, date: "2025-01-04", price: 500) + create_trade(voo, qty: 10, date: "2025-01-03", price: 500, account: @account) - voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF") - Security::Price.create!(security: voo, date: "2025-01-02", price: 500) - Security::Price.create!(security: voo, date: "2025-01-03", price: 500) - Security::Price.create!(security: voo, date: "2025-01-04", price: 500) - create_trade(voo, qty: 10, date: "2025-01-03", price: 500, account: @account) + expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 5000 ], [ "2025-01-04", 5000 ] ] + calculated = Holding::ForwardCalculator.new(@account).calculate - expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 5000 ], [ "2025-01-04", 5000 ] ] - calculated = Holding::ForwardCalculator.new(@account).calculate - - assert_equal expected, calculated.map { |b| [ b.date.to_s, b.amount ] } + assert_equal expected, calculated.map { |b| [ b.date.to_s, b.amount ] } + end end test "forward portfolio calculation" do diff --git a/test/models/holding/syncer_test.rb b/test/models/holding/materializer_test.rb similarity index 78% rename from test/models/holding/syncer_test.rb rename to test/models/holding/materializer_test.rb index 19e191c8..41ae8ff6 100644 --- a/test/models/holding/syncer_test.rb +++ b/test/models/holding/materializer_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class Holding::SyncerTest < ActiveSupport::TestCase +class Holding::MaterializerTest < ActiveSupport::TestCase include EntriesTestHelper setup do @@ -14,7 +14,7 @@ class Holding::SyncerTest < ActiveSupport::TestCase # Should have yesterday's and today's holdings assert_difference "@account.holdings.count", 2 do - Holding::Syncer.new(@account, strategy: :forward).sync_holdings + Holding::Materializer.new(@account, strategy: :forward).materialize_holdings end end @@ -23,7 +23,7 @@ class Holding::SyncerTest < ActiveSupport::TestCase Holding.create!(account: @account, security: @aapl, qty: 1, price: 100, amount: 100, currency: "USD", date: Date.current) assert_difference "Holding.count", -1 do - Holding::Syncer.new(@account, strategy: :forward).sync_holdings + Holding::Materializer.new(@account, strategy: :forward).materialize_holdings end end end diff --git a/test/models/holding/portfolio_cache_test.rb b/test/models/holding/portfolio_cache_test.rb index 2518e2b7..4677fc41 100644 --- a/test/models/holding/portfolio_cache_test.rb +++ b/test/models/holding/portfolio_cache_test.rb @@ -28,37 +28,18 @@ class Holding::PortfolioCacheTest < ActiveSupport::TestCase price: db_price ) - expect_provider_prices([], start_date: @account.start_date) - cache = Holding::PortfolioCache.new(@account) assert_equal db_price, cache.get_price(@security.id, Date.current).price end - test "if no price in DB, try fetching from provider" do - Security::Price.delete_all - - provider_price = Security::Price.new( - security: @security, - date: Date.current, - price: 220, - currency: "USD" - ) - - expect_provider_prices([ provider_price ], start_date: @account.start_date) - - cache = Holding::PortfolioCache.new(@account) - assert_equal provider_price.price, cache.get_price(@security.id, Date.current).price - end - - test "if no price from db or provider, try getting the price from trades" do + test "if no price from db, try getting the price from trades" do Security::Price.destroy_all - expect_provider_prices([], start_date: @account.start_date) cache = Holding::PortfolioCache.new(@account) assert_equal @trade.price, cache.get_price(@security.id, @trade.entry.date).price end - test "if no price from db, provider, or trades, search holdings" do + test "if no price from db or trades, search holdings" do Security::Price.delete_all Entry.delete_all @@ -72,16 +53,7 @@ class Holding::PortfolioCacheTest < ActiveSupport::TestCase currency: "USD" ) - expect_provider_prices([], start_date: @account.start_date) - cache = Holding::PortfolioCache.new(@account, use_holdings: true) assert_equal holding.price, cache.get_price(@security.id, holding.date).price end - - private - def expect_provider_prices(prices, start_date:, end_date: Date.current) - @provider.expects(:fetch_security_prices) - .with(@security, start_date: start_date, end_date: end_date) - .returns(provider_success_response(prices)) - end end diff --git a/test/models/holding/reverse_calculator_test.rb b/test/models/holding/reverse_calculator_test.rb index ec9dc204..785d7b94 100644 --- a/test/models/holding/reverse_calculator_test.rb +++ b/test/models/holding/reverse_calculator_test.rb @@ -20,26 +20,26 @@ class Holding::ReverseCalculatorTest < ActiveSupport::TestCase test "holding generation respects user timezone and last generated date is current user date" do # Simulate user in EST timezone - Time.zone = "America/New_York" + Time.use_zone("America/New_York") do + # Set current time to 1am UTC on Jan 5, 2025 + # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for) + travel_to Time.utc(2025, 01, 05, 1, 0, 0) - # Set current time to 1am UTC on Jan 5, 2025 - # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for) - travel_to Time.utc(2025, 01, 05, 1, 0, 0) + voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF") + Security::Price.create!(security: voo, date: "2025-01-02", price: 500) + Security::Price.create!(security: voo, date: "2025-01-03", price: 500) + Security::Price.create!(security: voo, date: "2025-01-04", price: 500) - voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF") - Security::Price.create!(security: voo, date: "2025-01-02", price: 500) - Security::Price.create!(security: voo, date: "2025-01-03", price: 500) - Security::Price.create!(security: voo, date: "2025-01-04", price: 500) + # Today's holdings (provided) + @account.holdings.create!(security: voo, date: "2025-01-04", qty: 10, price: 500, amount: 5000, currency: "USD") - # Today's holdings (provided) - @account.holdings.create!(security: voo, date: "2025-01-04", qty: 10, price: 500, amount: 5000, currency: "USD") + create_trade(voo, qty: 10, date: "2025-01-03", price: 500, account: @account) - create_trade(voo, qty: 10, date: "2025-01-03", price: 500, account: @account) + expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 5000 ], [ "2025-01-04", 5000 ] ] + calculated = Holding::ReverseCalculator.new(@account).calculate - expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 5000 ], [ "2025-01-04", 5000 ] ] - calculated = Holding::ReverseCalculator.new(@account).calculate - - assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.amount ] } + assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.amount ] } + end end # Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings diff --git a/test/models/market_data_importer_test.rb b/test/models/market_data_importer_test.rb new file mode 100644 index 00000000..b39bf0ad --- /dev/null +++ b/test/models/market_data_importer_test.rb @@ -0,0 +1,85 @@ +require "test_helper" +require "ostruct" + +class MarketDataImporterTest < ActiveSupport::TestCase + include ProviderTestHelper + + SNAPSHOT_START_DATE = MarketDataImporter::SNAPSHOT_DAYS.days.ago.to_date + PROVIDER_BUFFER = 5.days + + setup do + Security::Price.delete_all + ExchangeRate.delete_all + Trade.delete_all + Holding.delete_all + Security.delete_all + + @provider = mock("provider") + Provider::Registry.any_instance + .stubs(:get_provider) + .with(:synth) + .returns(@provider) + end + + test "syncs required exchange rates" do + family = Family.create!(name: "Smith", currency: "USD") + family.accounts.create!(name: "Chequing", + currency: "CAD", + balance: 100, + accountable: Depository.new) + + # Seed stale rate so only the next missing day is fetched + ExchangeRate.create!(from_currency: "CAD", + to_currency: "USD", + date: SNAPSHOT_START_DATE, + rate: 2.0) + + expected_start_date = (SNAPSHOT_START_DATE + 1.day) - PROVIDER_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date + + @provider.expects(:fetch_exchange_rates) + .with(from: "CAD", + to: "USD", + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(from: "CAD", to: "USD", date: SNAPSHOT_START_DATE, rate: 1.5) + ])) + + before = ExchangeRate.count + MarketDataImporter.new(mode: :snapshot).import_exchange_rates + after = ExchangeRate.count + + assert_operator after, :>, before, "Should insert at least one new exchange-rate row" + end + + test "syncs security prices" do + security = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS") + + expected_start_date = SNAPSHOT_START_DATE - PROVIDER_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date + + @provider.expects(:fetch_security_prices) + .with(symbol: security.ticker, + exchange_operating_mic: security.exchange_operating_mic, + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(security: security, + date: SNAPSHOT_START_DATE, + price: 100, + currency: "USD") + ])) + + @provider.stubs(:fetch_security_info) + .with(symbol: "AAPL", exchange_operating_mic: "XNAS") + .returns(provider_success_response(OpenStruct.new(name: "Apple", logo_url: "logo"))) + + # Ignore exchange rate calls for this test + @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([])) + + MarketDataImporter.new(mode: :snapshot).import_security_prices + + assert_equal 1, Security::Price.where(security: security, date: SNAPSHOT_START_DATE).count + end +end diff --git a/test/models/security/price/importer_test.rb b/test/models/security/price/importer_test.rb new file mode 100644 index 00000000..665a91f6 --- /dev/null +++ b/test/models/security/price/importer_test.rb @@ -0,0 +1,143 @@ +require "test_helper" +require "ostruct" + +class Security::Price::ImporterTest < ActiveSupport::TestCase + include ProviderTestHelper + + setup do + @provider = mock + @security = Security.create!(ticker: "AAPL") + end + + test "syncs missing prices from provider" do + Security::Price.delete_all + + provider_response = provider_success_response([ + OpenStruct.new(security: @security, date: 2.days.ago.to_date, price: 150, currency: "USD"), + OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 155, currency: "USD"), + OpenStruct.new(security: @security, date: Date.current, price: 160, currency: "USD") + ]) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + Security::Price::Importer.new( + security: @security, + security_provider: @provider, + start_date: 2.days.ago.to_date, + end_date: Date.current + ).import_provider_prices + + db_prices = Security::Price.where(security: @security, date: 2.days.ago.to_date..Date.current).order(:date) + + assert_equal 3, db_prices.count + assert_equal [ 150, 155, 160 ], db_prices.map(&:price) + end + + test "syncs diff when some prices already exist" do + Security::Price.delete_all + + # Pre-populate DB with first two days + Security::Price.create!(security: @security, date: 3.days.ago.to_date, price: 140, currency: "USD") + Security::Price.create!(security: @security, date: 2.days.ago.to_date, price: 145, currency: "USD") + + provider_response = provider_success_response([ + OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 150, currency: "USD") + ]) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(1.day.ago.to_date), end_date: Date.current) + .returns(provider_response) + + Security::Price::Importer.new( + security: @security, + security_provider: @provider, + start_date: 3.days.ago.to_date, + end_date: Date.current + ).import_provider_prices + + db_prices = Security::Price.where(security: @security).order(:date) + assert_equal 4, db_prices.count + assert_equal [ 140, 145, 150, 150 ], db_prices.map(&:price) + end + + test "no provider calls when all prices exist" do + Security::Price.delete_all + + (3.days.ago.to_date..Date.current).each_with_index do |date, idx| + Security::Price.create!(security: @security, date:, price: 100 + idx, currency: "USD") + end + + @provider.expects(:fetch_security_prices).never + + Security::Price::Importer.new( + security: @security, + security_provider: @provider, + start_date: 3.days.ago.to_date, + end_date: Date.current + ).import_provider_prices + end + + test "full upsert if clear_cache is true" do + Security::Price.delete_all + + # Seed DB with stale prices + (2.days.ago.to_date..Date.current).each do |date| + Security::Price.create!(security: @security, date:, price: 100, currency: "USD") + end + + provider_response = provider_success_response([ + OpenStruct.new(security: @security, date: 2.days.ago.to_date, price: 150, currency: "USD"), + OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 155, currency: "USD"), + OpenStruct.new(security: @security, date: Date.current, price: 160, currency: "USD") + ]) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + Security::Price::Importer.new( + security: @security, + security_provider: @provider, + start_date: 2.days.ago.to_date, + end_date: Date.current, + clear_cache: true + ).import_provider_prices + + db_prices = Security::Price.where(security: @security).order(:date) + assert_equal [ 150, 155, 160 ], db_prices.map(&:price) + end + + test "clamps end_date to today when future date is provided" do + Security::Price.delete_all + + future_date = Date.current + 3.days + + provider_response = provider_success_response([ + OpenStruct.new(security: @security, date: Date.current, price: 165, currency: "USD") + ]) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(Date.current), end_date: Date.current) + .returns(provider_response) + + Security::Price::Importer.new( + security: @security, + security_provider: @provider, + start_date: Date.current, + end_date: future_date + ).import_provider_prices + + assert_equal 1, Security::Price.count + end + + private + def get_provider_fetch_start_date(start_date) + start_date - 5.days + end +end diff --git a/test/models/security/price_test.rb b/test/models/security/price_test.rb index bd150359..14c022fe 100644 --- a/test/models/security/price_test.rb +++ b/test/models/security/price_test.rb @@ -49,25 +49,6 @@ class Security::PriceTest < ActiveSupport::TestCase assert_not @security.find_or_fetch_price(date: Date.current) end - test "upserts historical prices from provider" do - Security::Price.delete_all - - # Will be overwritten by upsert - Security::Price.create!(security: @security, date: 1.day.ago.to_date, price: 190, currency: "USD") - - expect_provider_prices(security: @security, start_date: 2.days.ago.to_date, end_date: Date.current, prices: [ - Security::Price.new(security: @security, date: Date.current, price: 215, currency: "USD"), - Security::Price.new(security: @security, date: 1.day.ago.to_date, price: 214, currency: "USD"), - Security::Price.new(security: @security, date: 2.days.ago.to_date, price: 213, currency: "USD") - ]) - - @security.sync_provider_prices(start_date: 2.days.ago.to_date) - - assert_equal 215, @security.prices.find_by(date: Date.current).price - assert_equal 214, @security.prices.find_by(date: 1.day.ago.to_date).price - assert_equal 213, @security.prices.find_by(date: 2.days.ago.to_date).price - end - private def expect_provider_price(security:, price:, date:) @provider.expects(:fetch_security_price) diff --git a/test/models/subscription_test.rb b/test/models/subscription_test.rb index 3986335c..389aaaf0 100644 --- a/test/models/subscription_test.rb +++ b/test/models/subscription_test.rb @@ -2,7 +2,7 @@ require "test_helper" class SubscriptionTest < ActiveSupport::TestCase setup do - @family = families(:empty) + @family = Family.create!(name: "Test Family") end test "can create subscription without stripe details if trial" do diff --git a/test/models/sync_test.rb b/test/models/sync_test.rb index 99019146..12e410c6 100644 --- a/test/models/sync_test.rb +++ b/test/models/sync_test.rb @@ -1,34 +1,191 @@ require "test_helper" class SyncTest < ActiveSupport::TestCase - setup do - @sync = syncs(:account) - @sync.update(status: "pending") - end + include ActiveJob::TestHelper test "runs successful sync" do - @sync.syncable.expects(:sync_data).with(@sync, start_date: @sync.start_date).once + syncable = accounts(:depository) + sync = Sync.create!(syncable: syncable) - assert_equal "pending", @sync.status + syncable.expects(:perform_sync).with(sync).once - previously_ran_at = @sync.last_ran_at + assert_equal "pending", sync.status - @sync.perform + sync.perform - assert @sync.last_ran_at > previously_ran_at - assert_equal "completed", @sync.status + assert sync.completed_at < Time.now + assert_equal "completed", sync.status end test "handles sync errors" do - @sync.syncable.expects(:sync_data).with(@sync, start_date: @sync.start_date).raises(StandardError.new("test sync error")) + syncable = accounts(:depository) + sync = Sync.create!(syncable: syncable) - assert_equal "pending", @sync.status - previously_ran_at = @sync.last_ran_at + syncable.expects(:perform_sync).with(sync).raises(StandardError.new("test sync error")) - @sync.perform + assert_equal "pending", sync.status - assert @sync.last_ran_at > previously_ran_at - assert_equal "failed", @sync.status - assert_equal "test sync error", @sync.error + sync.perform + + assert sync.failed_at < Time.now + assert_equal "failed", sync.status + assert_equal "test sync error", sync.error + end + + test "can run nested syncs that alert the parent when complete" do + family = families(:dylan_family) + plaid_item = plaid_items(:one) + account = accounts(:connected) + + family_sync = Sync.create!(syncable: family) + plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync) + account_sync = Sync.create!(syncable: account, parent: plaid_item_sync) + + assert_equal "pending", family_sync.status + assert_equal "pending", plaid_item_sync.status + assert_equal "pending", account_sync.status + + family.expects(:perform_sync).with(family_sync).once + + family_sync.perform + + assert_equal "syncing", family_sync.reload.status + + plaid_item.expects(:perform_sync).with(plaid_item_sync).once + + plaid_item_sync.perform + + assert_equal "syncing", family_sync.reload.status + assert_equal "syncing", plaid_item_sync.reload.status + + account.expects(:perform_sync).with(account_sync).once + + # Since these are accessed through `parent`, they won't necessarily be the same + # instance we configured above + Account.any_instance.expects(:perform_post_sync).once + Account.any_instance.expects(:broadcast_sync_complete).once + PlaidItem.any_instance.expects(:perform_post_sync).once + PlaidItem.any_instance.expects(:broadcast_sync_complete).once + Family.any_instance.expects(:perform_post_sync).once + Family.any_instance.expects(:broadcast_sync_complete).once + + account_sync.perform + + assert_equal "completed", plaid_item_sync.reload.status + assert_equal "completed", account_sync.reload.status + assert_equal "completed", family_sync.reload.status + end + + test "failures propagate up the chain" do + family = families(:dylan_family) + plaid_item = plaid_items(:one) + account = accounts(:connected) + + family_sync = Sync.create!(syncable: family) + plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync) + account_sync = Sync.create!(syncable: account, parent: plaid_item_sync) + + assert_equal "pending", family_sync.status + assert_equal "pending", plaid_item_sync.status + assert_equal "pending", account_sync.status + + family.expects(:perform_sync).with(family_sync).once + + family_sync.perform + + assert_equal "syncing", family_sync.reload.status + + plaid_item.expects(:perform_sync).with(plaid_item_sync).once + + plaid_item_sync.perform + + assert_equal "syncing", family_sync.reload.status + assert_equal "syncing", plaid_item_sync.reload.status + + # This error should "bubble up" to the PlaidItem and Family sync results + account.expects(:perform_sync).with(account_sync).raises(StandardError.new("test account sync error")) + + # Since these are accessed through `parent`, they won't necessarily be the same + # instance we configured above + Account.any_instance.expects(:perform_post_sync).once + PlaidItem.any_instance.expects(:perform_post_sync).once + Family.any_instance.expects(:perform_post_sync).once + + Account.any_instance.expects(:broadcast_sync_complete).once + PlaidItem.any_instance.expects(:broadcast_sync_complete).once + Family.any_instance.expects(:broadcast_sync_complete).once + + account_sync.perform + + assert_equal "failed", plaid_item_sync.reload.status + assert_equal "failed", account_sync.reload.status + assert_equal "failed", family_sync.reload.status + end + + test "parent failure should not change status if child succeeds" do + family = families(:dylan_family) + plaid_item = plaid_items(:one) + account = accounts(:connected) + + family_sync = Sync.create!(syncable: family) + plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync) + account_sync = Sync.create!(syncable: account, parent: plaid_item_sync) + + assert_equal "pending", family_sync.status + assert_equal "pending", plaid_item_sync.status + assert_equal "pending", account_sync.status + + family.expects(:perform_sync).with(family_sync).raises(StandardError.new("test family sync error")) + + family_sync.perform + + assert_equal "failed", family_sync.reload.status + + plaid_item.expects(:perform_sync).with(plaid_item_sync).raises(StandardError.new("test plaid item sync error")) + + plaid_item_sync.perform + + assert_equal "failed", family_sync.reload.status + assert_equal "failed", plaid_item_sync.reload.status + + # Leaf level sync succeeds, but shouldn't change the status of the already-failed parent syncs + account.expects(:perform_sync).with(account_sync).once + + # Since these are accessed through `parent`, they won't necessarily be the same + # instance we configured above + Account.any_instance.expects(:perform_post_sync).once + PlaidItem.any_instance.expects(:perform_post_sync).once + Family.any_instance.expects(:perform_post_sync).once + + Account.any_instance.expects(:broadcast_sync_complete).once + PlaidItem.any_instance.expects(:broadcast_sync_complete).once + Family.any_instance.expects(:broadcast_sync_complete).once + + account_sync.perform + + assert_equal "failed", plaid_item_sync.reload.status + assert_equal "failed", family_sync.reload.status + assert_equal "completed", account_sync.reload.status + end + + test "clean marks stale incomplete rows" do + stale_pending = Sync.create!( + syncable: accounts(:depository), + status: :pending, + created_at: 3.hours.ago + ) + + stale_syncing = Sync.create!( + syncable: accounts(:depository), + status: :syncing, + created_at: 3.hours.ago, + pending_at: 3.hours.ago, + syncing_at: 2.hours.ago + ) + + Sync.clean + + assert_equal "stale", stale_pending.reload.status + assert_equal "stale", stale_syncing.reload.status end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 23f98faa..7eac9dde 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -17,6 +17,7 @@ require "rails/test_help" require "minitest/mock" require "minitest/autorun" require "mocha/minitest" +require "aasm/minitest" VCR.configure do |config| config.cassette_library_dir = "test/vcr_cassettes" diff --git a/test/vcr_cassettes/synth/exchange_rate.yml b/test/vcr_cassettes/synth/exchange_rate.yml index be1fa77a..adc22263 100644 --- a/test/vcr_cassettes/synth/exchange_rate.yml +++ b/test/vcr_cassettes/synth/exchange_rate.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sat, 15 Mar 2025 22:18:46 GMT + - Fri, 16 May 2025 13:01:38 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"b0b21c870fe53492404cc5ac258fa465" + - W/"0c93a67d0c68e6f206e2954a41aa2933" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 44367fcb-e5b4-457d + - 146e30b2-e03b-47e3 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 8ce9dc85-afbd-437c-b18d-ec788b712334 + - 3cf7ade1-8066-422a-97c7-5f8b99e24296 X-Runtime: - - '0.031963' + - '0.024284' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=SwRPS1vBsrKtk%2Ftb7Ix8j%2FCWYw9tZgbJxR1FCmotWn%2FIZAE3Ri%2FUwHtvkOSqBq6HN5pLVetfem5hp%2BkqWmD5GRCVho0mp3VgRr3J1tBMwrVK2p50tfpmb3X22Jj%2BOfapq1C22PnN"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ih8sEFqAOWyINqAEtKGKPKO2lr1qAYSVeipyB5F8g2umPODXvCD4hN3G6wTTs2Q7H8CDWsqiOlYkmVvmr%2BWvl2ojOtBwO25Ahk9TbhlcgRO9nT6mEIXOSdVXJpzpRn5Ov%2FMGigpQ"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,13 +69,13 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 920f6378fe582237-ORD + - 940b109b5df1a3d7-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=26670&min_rtt=26569&rtt_var=10167&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=922&delivery_rate=105759&cwnd=181&unsent_bytes=0&cid=f0a872e0b2909c59&ts=188&x=0" + - cfL4;desc="?proto=TCP&rtt=25865&min_rtt=25683&rtt_var=9996&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=922&delivery_rate=106690&cwnd=219&unsent_bytes=0&cid=e48ae188d1f86721&ts=190&x=0" body: encoding: ASCII-8BIT - string: '{"data":{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":249830,"date":"2024-01-01"}}' - recorded_at: Sat, 15 Mar 2025 22:18:46 GMT + string: '{"data":{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":249734,"date":"2024-01-01"}}' + recorded_at: Fri, 16 May 2025 13:01:38 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/exchange_rates.yml b/test/vcr_cassettes/synth/exchange_rates.yml index ffb7b69a..87071b8f 100644 --- a/test/vcr_cassettes/synth/exchange_rates.yml +++ b/test/vcr_cassettes/synth/exchange_rates.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sat, 15 Mar 2025 21:48:33 GMT + - Fri, 16 May 2025 13:01:35 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"8081859271e9ca46ee021f706a0cc683" + - W/"ad21b1fba71fe0b149fe37b483a60438" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 6d036078-7f2f-4037 + - 28bc6622-47b8-4aeb Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 9ec8d111-aa67-4fb9-8885-7de64e1b1219 + - fcf251a3-f850-4464-9592-ced9de5e0c86 X-Runtime: - - '0.025769' + - '0.080857' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=3PGbjN13Yz7GFiZNw1N13jCnLyMkC1O69nVw4k9Y0Iif7pu0H1eBKZxhkRTGzeECSRtzryqMRpzh9lG11e9SVXA9PNTSTR1%2BC%2FZkOMTsFUk%2Fajh29RmkkGeYrQgCAPEWBST36B3V"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=622lysAXubNaj3TsuhR9RYZRXPc%2BgnyMWj52fxy%2BptvXoPr%2FxVJgJZ0g02mOUjCywdAymkMpawfWCaZVQOIaPVpocco3g4Y%2B0FB667ilf3UtCyiHwqCosUq0T99JabIsgFFJ%2FhP4"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,13 +69,13 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 920f37347b14e7f9-ORD + - 940b108a2921607d-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=27528&min_rtt=26760&rtt_var=11571&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=961&delivery_rate=88005&cwnd=248&unsent_bytes=0&cid=28a3fac05fc0df52&ts=177&x=0" + - cfL4;desc="?proto=TCP&rtt=25729&min_rtt=25575&rtt_var=9899&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=961&delivery_rate=108019&cwnd=251&unsent_bytes=0&cid=ca574e4a637aba29&ts=241&x=0" body: encoding: ASCII-8BIT - string: '{"data":[{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},{"date":"2024-01-02","source":"USD","rates":{"GBP":0.785644}},{"date":"2024-01-03","source":"USD","rates":{"GBP":0.792232}},{"date":"2024-01-04","source":"USD","rates":{"GBP":0.789053}},{"date":"2024-01-05","source":"USD","rates":{"GBP":0.788487}},{"date":"2024-01-06","source":"USD","rates":{"GBP":0.785787}},{"date":"2024-01-07","source":"USD","rates":{"GBP":0.785994}},{"date":"2024-01-08","source":"USD","rates":{"GBP":0.786378}},{"date":"2024-01-09","source":"USD","rates":{"GBP":0.784775}},{"date":"2024-01-10","source":"USD","rates":{"GBP":0.786769}},{"date":"2024-01-11","source":"USD","rates":{"GBP":0.784633}},{"date":"2024-01-12","source":"USD","rates":{"GBP":0.782576}},{"date":"2024-01-13","source":"USD","rates":{"GBP":0.78447}},{"date":"2024-01-14","source":"USD","rates":{"GBP":0.784423}},{"date":"2024-01-15","source":"USD","rates":{"GBP":0.785204}},{"date":"2024-01-16","source":"USD","rates":{"GBP":0.786438}},{"date":"2024-01-17","source":"USD","rates":{"GBP":0.791264}},{"date":"2024-01-18","source":"USD","rates":{"GBP":0.788852}},{"date":"2024-01-19","source":"USD","rates":{"GBP":0.786744}},{"date":"2024-01-20","source":"USD","rates":{"GBP":0.787186}},{"date":"2024-01-21","source":"USD","rates":{"GBP":0.787166}},{"date":"2024-01-22","source":"USD","rates":{"GBP":0.787487}},{"date":"2024-01-23","source":"USD","rates":{"GBP":0.786985}},{"date":"2024-01-24","source":"USD","rates":{"GBP":0.787961}},{"date":"2024-01-25","source":"USD","rates":{"GBP":0.786236}},{"date":"2024-01-26","source":"USD","rates":{"GBP":0.786961}},{"date":"2024-01-27","source":"USD","rates":{"GBP":0.786935}},{"date":"2024-01-28","source":"USD","rates":{"GBP":0.787014}},{"date":"2024-01-29","source":"USD","rates":{"GBP":0.78761}},{"date":"2024-01-30","source":"USD","rates":{"GBP":0.786652}},{"date":"2024-01-31","source":"USD","rates":{"GBP":0.787736}},{"date":"2024-02-01","source":"USD","rates":{"GBP":0.788759}},{"date":"2024-02-02","source":"USD","rates":{"GBP":0.784546}},{"date":"2024-02-03","source":"USD","rates":{"GBP":0.791634}},{"date":"2024-02-04","source":"USD","rates":{"GBP":0.791637}},{"date":"2024-02-05","source":"USD","rates":{"GBP":0.792205}},{"date":"2024-02-06","source":"USD","rates":{"GBP":0.797836}},{"date":"2024-02-07","source":"USD","rates":{"GBP":0.79341}},{"date":"2024-02-08","source":"USD","rates":{"GBP":0.791971}},{"date":"2024-02-09","source":"USD","rates":{"GBP":0.792371}},{"date":"2024-02-10","source":"USD","rates":{"GBP":0.791997}},{"date":"2024-02-11","source":"USD","rates":{"GBP":0.792019}},{"date":"2024-02-12","source":"USD","rates":{"GBP":0.791339}},{"date":"2024-02-13","source":"USD","rates":{"GBP":0.791977}},{"date":"2024-02-14","source":"USD","rates":{"GBP":0.794262}},{"date":"2024-02-15","source":"USD","rates":{"GBP":0.795709}},{"date":"2024-02-16","source":"USD","rates":{"GBP":0.793714}},{"date":"2024-02-17","source":"USD","rates":{"GBP":0.793499}},{"date":"2024-02-18","source":"USD","rates":{"GBP":0.79367}},{"date":"2024-02-19","source":"USD","rates":{"GBP":0.792968}},{"date":"2024-02-20","source":"USD","rates":{"GBP":0.794437}},{"date":"2024-02-21","source":"USD","rates":{"GBP":0.791988}},{"date":"2024-02-22","source":"USD","rates":{"GBP":0.791262}},{"date":"2024-02-23","source":"USD","rates":{"GBP":0.789749}},{"date":"2024-02-24","source":"USD","rates":{"GBP":0.78886}},{"date":"2024-02-25","source":"USD","rates":{"GBP":0.789107}},{"date":"2024-02-26","source":"USD","rates":{"GBP":0.78917}},{"date":"2024-02-27","source":"USD","rates":{"GBP":0.788381}},{"date":"2024-02-28","source":"USD","rates":{"GBP":0.78861}},{"date":"2024-02-29","source":"USD","rates":{"GBP":0.789837}},{"date":"2024-03-01","source":"USD","rates":{"GBP":0.792028}},{"date":"2024-03-02","source":"USD","rates":{"GBP":0.790312}},{"date":"2024-03-03","source":"USD","rates":{"GBP":0.790258}},{"date":"2024-03-04","source":"USD","rates":{"GBP":0.789891}},{"date":"2024-03-05","source":"USD","rates":{"GBP":0.788025}},{"date":"2024-03-06","source":"USD","rates":{"GBP":0.787136}},{"date":"2024-03-07","source":"USD","rates":{"GBP":0.785219}},{"date":"2024-03-08","source":"USD","rates":{"GBP":0.780438}},{"date":"2024-03-09","source":"USD","rates":{"GBP":0.777772}},{"date":"2024-03-10","source":"USD","rates":{"GBP":0.777884}},{"date":"2024-03-11","source":"USD","rates":{"GBP":0.77786}},{"date":"2024-03-12","source":"USD","rates":{"GBP":0.780067}},{"date":"2024-03-13","source":"USD","rates":{"GBP":0.781535}},{"date":"2024-03-14","source":"USD","rates":{"GBP":0.781184}},{"date":"2024-03-15","source":"USD","rates":{"GBP":0.784604}},{"date":"2024-03-16","source":"USD","rates":{"GBP":0.785537}},{"date":"2024-03-17","source":"USD","rates":{"GBP":0.785147}},{"date":"2024-03-18","source":"USD","rates":{"GBP":0.785457}},{"date":"2024-03-19","source":"USD","rates":{"GBP":0.785746}},{"date":"2024-03-20","source":"USD","rates":{"GBP":0.786238}},{"date":"2024-03-21","source":"USD","rates":{"GBP":0.781351}},{"date":"2024-03-22","source":"USD","rates":{"GBP":0.789841}},{"date":"2024-03-23","source":"USD","rates":{"GBP":0.793659}},{"date":"2024-03-24","source":"USD","rates":{"GBP":0.793385}},{"date":"2024-03-25","source":"USD","rates":{"GBP":0.793673}},{"date":"2024-03-26","source":"USD","rates":{"GBP":0.791344}},{"date":"2024-03-27","source":"USD","rates":{"GBP":0.791899}},{"date":"2024-03-28","source":"USD","rates":{"GBP":0.792585}},{"date":"2024-03-29","source":"USD","rates":{"GBP":0.792205}},{"date":"2024-03-30","source":"USD","rates":{"GBP":0.792228}},{"date":"2024-03-31","source":"USD","rates":{"GBP":0.792057}},{"date":"2024-04-01","source":"USD","rates":{"GBP":0.79134}},{"date":"2024-04-02","source":"USD","rates":{"GBP":0.797058}},{"date":"2024-04-03","source":"USD","rates":{"GBP":0.795147}},{"date":"2024-04-04","source":"USD","rates":{"GBP":0.790398}},{"date":"2024-04-05","source":"USD","rates":{"GBP":0.791151}},{"date":"2024-04-06","source":"USD","rates":{"GBP":0.791314}},{"date":"2024-04-07","source":"USD","rates":{"GBP":0.791273}},{"date":"2024-04-08","source":"USD","rates":{"GBP":0.792111}},{"date":"2024-04-09","source":"USD","rates":{"GBP":0.790047}},{"date":"2024-04-10","source":"USD","rates":{"GBP":0.788828}},{"date":"2024-04-11","source":"USD","rates":{"GBP":0.797646}},{"date":"2024-04-12","source":"USD","rates":{"GBP":0.796524}},{"date":"2024-04-13","source":"USD","rates":{"GBP":0.803024}},{"date":"2024-04-14","source":"USD","rates":{"GBP":0.802912}},{"date":"2024-04-15","source":"USD","rates":{"GBP":0.8025}},{"date":"2024-04-16","source":"USD","rates":{"GBP":0.80344}},{"date":"2024-04-17","source":"USD","rates":{"GBP":0.804505}},{"date":"2024-04-18","source":"USD","rates":{"GBP":0.80301}},{"date":"2024-04-19","source":"USD","rates":{"GBP":0.804145}},{"date":"2024-04-20","source":"USD","rates":{"GBP":0.80845}},{"date":"2024-04-21","source":"USD","rates":{"GBP":0.808199}},{"date":"2024-04-22","source":"USD","rates":{"GBP":0.808004}},{"date":"2024-04-23","source":"USD","rates":{"GBP":0.809734}},{"date":"2024-04-24","source":"USD","rates":{"GBP":0.802955}},{"date":"2024-04-25","source":"USD","rates":{"GBP":0.80264}},{"date":"2024-04-26","source":"USD","rates":{"GBP":0.799526}},{"date":"2024-04-27","source":"USD","rates":{"GBP":0.80053}},{"date":"2024-04-28","source":"USD","rates":{"GBP":0.800761}},{"date":"2024-04-29","source":"USD","rates":{"GBP":0.799397}},{"date":"2024-04-30","source":"USD","rates":{"GBP":0.796217}},{"date":"2024-05-01","source":"USD","rates":{"GBP":0.800703}},{"date":"2024-05-02","source":"USD","rates":{"GBP":0.797562}},{"date":"2024-05-03","source":"USD","rates":{"GBP":0.797457}},{"date":"2024-05-04","source":"USD","rates":{"GBP":0.797001}},{"date":"2024-05-05","source":"USD","rates":{"GBP":0.797107}},{"date":"2024-05-06","source":"USD","rates":{"GBP":0.797363}},{"date":"2024-05-07","source":"USD","rates":{"GBP":0.796218}},{"date":"2024-05-08","source":"USD","rates":{"GBP":0.799915}},{"date":"2024-05-09","source":"USD","rates":{"GBP":0.800422}},{"date":"2024-05-10","source":"USD","rates":{"GBP":0.798411}},{"date":"2024-05-11","source":"USD","rates":{"GBP":0.798489}},{"date":"2024-05-12","source":"USD","rates":{"GBP":0.798475}},{"date":"2024-05-13","source":"USD","rates":{"GBP":0.79853}},{"date":"2024-05-14","source":"USD","rates":{"GBP":0.796122}},{"date":"2024-05-15","source":"USD","rates":{"GBP":0.794614}},{"date":"2024-05-16","source":"USD","rates":{"GBP":0.78804}},{"date":"2024-05-17","source":"USD","rates":{"GBP":0.789188}},{"date":"2024-05-18","source":"USD","rates":{"GBP":0.787162}},{"date":"2024-05-19","source":"USD","rates":{"GBP":0.787194}},{"date":"2024-05-20","source":"USD","rates":{"GBP":0.787022}},{"date":"2024-05-21","source":"USD","rates":{"GBP":0.786793}},{"date":"2024-05-22","source":"USD","rates":{"GBP":0.786723}},{"date":"2024-05-23","source":"USD","rates":{"GBP":0.786132}},{"date":"2024-05-24","source":"USD","rates":{"GBP":0.78778}},{"date":"2024-05-25","source":"USD","rates":{"GBP":0.785013}},{"date":"2024-05-26","source":"USD","rates":{"GBP":0.785081}},{"date":"2024-05-27","source":"USD","rates":{"GBP":0.78526}},{"date":"2024-05-28","source":"USD","rates":{"GBP":0.78296}},{"date":"2024-05-29","source":"USD","rates":{"GBP":0.783808}},{"date":"2024-05-30","source":"USD","rates":{"GBP":0.787552}},{"date":"2024-05-31","source":"USD","rates":{"GBP":0.785599}},{"date":"2024-06-01","source":"USD","rates":{"GBP":0.785113}},{"date":"2024-06-02","source":"USD","rates":{"GBP":0.785019}},{"date":"2024-06-03","source":"USD","rates":{"GBP":0.784657}},{"date":"2024-06-04","source":"USD","rates":{"GBP":0.780649}},{"date":"2024-06-05","source":"USD","rates":{"GBP":0.782934}},{"date":"2024-06-06","source":"USD","rates":{"GBP":0.781631}},{"date":"2024-06-07","source":"USD","rates":{"GBP":0.781732}},{"date":"2024-06-08","source":"USD","rates":{"GBP":0.785947}},{"date":"2024-06-09","source":"USD","rates":{"GBP":0.785767}},{"date":"2024-06-10","source":"USD","rates":{"GBP":0.785588}},{"date":"2024-06-11","source":"USD","rates":{"GBP":0.785791}},{"date":"2024-06-12","source":"USD","rates":{"GBP":0.784932}},{"date":"2024-06-13","source":"USD","rates":{"GBP":0.781472}},{"date":"2024-06-14","source":"USD","rates":{"GBP":0.784041}},{"date":"2024-06-15","source":"USD","rates":{"GBP":0.789096}},{"date":"2024-06-16","source":"USD","rates":{"GBP":0.788449}},{"date":"2024-06-17","source":"USD","rates":{"GBP":0.788479}},{"date":"2024-06-18","source":"USD","rates":{"GBP":0.786542}},{"date":"2024-06-19","source":"USD","rates":{"GBP":0.786916}},{"date":"2024-06-20","source":"USD","rates":{"GBP":0.786107}},{"date":"2024-06-21","source":"USD","rates":{"GBP":0.789875}},{"date":"2024-06-22","source":"USD","rates":{"GBP":0.79058}},{"date":"2024-06-23","source":"USD","rates":{"GBP":0.790546}},{"date":"2024-06-24","source":"USD","rates":{"GBP":0.791248}},{"date":"2024-06-25","source":"USD","rates":{"GBP":0.788496}},{"date":"2024-06-26","source":"USD","rates":{"GBP":0.788395}},{"date":"2024-06-27","source":"USD","rates":{"GBP":0.792298}},{"date":"2024-06-28","source":"USD","rates":{"GBP":0.79087}},{"date":"2024-06-29","source":"USD","rates":{"GBP":0.790726}},{"date":"2024-06-30","source":"USD","rates":{"GBP":0.790719}},{"date":"2024-07-01","source":"USD","rates":{"GBP":0.790622}},{"date":"2024-07-02","source":"USD","rates":{"GBP":0.790812}},{"date":"2024-07-03","source":"USD","rates":{"GBP":0.78816}},{"date":"2024-07-04","source":"USD","rates":{"GBP":0.784451}},{"date":"2024-07-05","source":"USD","rates":{"GBP":0.783992}},{"date":"2024-07-06","source":"USD","rates":{"GBP":0.780243}},{"date":"2024-07-07","source":"USD","rates":{"GBP":0.780594}},{"date":"2024-07-08","source":"USD","rates":{"GBP":0.780827}},{"date":"2024-07-09","source":"USD","rates":{"GBP":0.780333}},{"date":"2024-07-10","source":"USD","rates":{"GBP":0.781936}},{"date":"2024-07-11","source":"USD","rates":{"GBP":0.777992}},{"date":"2024-07-12","source":"USD","rates":{"GBP":0.773816}},{"date":"2024-07-13","source":"USD","rates":{"GBP":0.770374}},{"date":"2024-07-14","source":"USD","rates":{"GBP":0.770294}},{"date":"2024-07-15","source":"USD","rates":{"GBP":0.771174}},{"date":"2024-07-16","source":"USD","rates":{"GBP":0.771041}},{"date":"2024-07-17","source":"USD","rates":{"GBP":0.770574}},{"date":"2024-07-18","source":"USD","rates":{"GBP":0.768775}},{"date":"2024-07-19","source":"USD","rates":{"GBP":0.772195}},{"date":"2024-07-20","source":"USD","rates":{"GBP":0.774311}},{"date":"2024-07-21","source":"USD","rates":{"GBP":0.774096}},{"date":"2024-07-22","source":"USD","rates":{"GBP":0.773251}},{"date":"2024-07-23","source":"USD","rates":{"GBP":0.773304}},{"date":"2024-07-24","source":"USD","rates":{"GBP":0.775165}},{"date":"2024-07-25","source":"USD","rates":{"GBP":0.775289}},{"date":"2024-07-26","source":"USD","rates":{"GBP":0.777882}},{"date":"2024-07-27","source":"USD","rates":{"GBP":0.777203}},{"date":"2024-07-28","source":"USD","rates":{"GBP":0.776969}},{"date":"2024-07-29","source":"USD","rates":{"GBP":0.777176}},{"date":"2024-07-30","source":"USD","rates":{"GBP":0.777613}},{"date":"2024-07-31","source":"USD","rates":{"GBP":0.778999}}],"paging":{"prev":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=\u0026to=GBP","next":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=\u0026to=GBP","total_records":213,"current_page":1,"per_page":500,"total_pages":1},"meta":{"credits_used":1,"credits_remaining":249832,"date_start":"2024-01-01","date_end":"2024-07-31"}}' - recorded_at: Sat, 15 Mar 2025 21:48:33 GMT + string: '{"data":[{"date":"2024-01-01","source":"USD","rates":{"GBP":0.785476}},{"date":"2024-01-02","source":"USD","rates":{"GBP":0.785644}},{"date":"2024-01-03","source":"USD","rates":{"GBP":0.792232}},{"date":"2024-01-04","source":"USD","rates":{"GBP":0.789053}},{"date":"2024-01-05","source":"USD","rates":{"GBP":0.788487}},{"date":"2024-01-06","source":"USD","rates":{"GBP":0.785787}},{"date":"2024-01-07","source":"USD","rates":{"GBP":0.785994}},{"date":"2024-01-08","source":"USD","rates":{"GBP":0.786378}},{"date":"2024-01-09","source":"USD","rates":{"GBP":0.784775}},{"date":"2024-01-10","source":"USD","rates":{"GBP":0.786769}},{"date":"2024-01-11","source":"USD","rates":{"GBP":0.784633}},{"date":"2024-01-12","source":"USD","rates":{"GBP":0.782576}},{"date":"2024-01-13","source":"USD","rates":{"GBP":0.78447}},{"date":"2024-01-14","source":"USD","rates":{"GBP":0.784423}},{"date":"2024-01-15","source":"USD","rates":{"GBP":0.785204}},{"date":"2024-01-16","source":"USD","rates":{"GBP":0.786438}},{"date":"2024-01-17","source":"USD","rates":{"GBP":0.791264}},{"date":"2024-01-18","source":"USD","rates":{"GBP":0.788852}},{"date":"2024-01-19","source":"USD","rates":{"GBP":0.786744}},{"date":"2024-01-20","source":"USD","rates":{"GBP":0.787186}},{"date":"2024-01-21","source":"USD","rates":{"GBP":0.787166}},{"date":"2024-01-22","source":"USD","rates":{"GBP":0.787487}},{"date":"2024-01-23","source":"USD","rates":{"GBP":0.786985}},{"date":"2024-01-24","source":"USD","rates":{"GBP":0.787961}},{"date":"2024-01-25","source":"USD","rates":{"GBP":0.786236}},{"date":"2024-01-26","source":"USD","rates":{"GBP":0.786961}},{"date":"2024-01-27","source":"USD","rates":{"GBP":0.786935}},{"date":"2024-01-28","source":"USD","rates":{"GBP":0.787014}},{"date":"2024-01-29","source":"USD","rates":{"GBP":0.78761}},{"date":"2024-01-30","source":"USD","rates":{"GBP":0.786652}},{"date":"2024-01-31","source":"USD","rates":{"GBP":0.787736}},{"date":"2024-02-01","source":"USD","rates":{"GBP":0.788759}},{"date":"2024-02-02","source":"USD","rates":{"GBP":0.784546}},{"date":"2024-02-03","source":"USD","rates":{"GBP":0.791634}},{"date":"2024-02-04","source":"USD","rates":{"GBP":0.791637}},{"date":"2024-02-05","source":"USD","rates":{"GBP":0.792205}},{"date":"2024-02-06","source":"USD","rates":{"GBP":0.797836}},{"date":"2024-02-07","source":"USD","rates":{"GBP":0.79341}},{"date":"2024-02-08","source":"USD","rates":{"GBP":0.791971}},{"date":"2024-02-09","source":"USD","rates":{"GBP":0.792371}},{"date":"2024-02-10","source":"USD","rates":{"GBP":0.791997}},{"date":"2024-02-11","source":"USD","rates":{"GBP":0.792019}},{"date":"2024-02-12","source":"USD","rates":{"GBP":0.791339}},{"date":"2024-02-13","source":"USD","rates":{"GBP":0.791977}},{"date":"2024-02-14","source":"USD","rates":{"GBP":0.794262}},{"date":"2024-02-15","source":"USD","rates":{"GBP":0.795709}},{"date":"2024-02-16","source":"USD","rates":{"GBP":0.793714}},{"date":"2024-02-17","source":"USD","rates":{"GBP":0.793499}},{"date":"2024-02-18","source":"USD","rates":{"GBP":0.79367}},{"date":"2024-02-19","source":"USD","rates":{"GBP":0.792968}},{"date":"2024-02-20","source":"USD","rates":{"GBP":0.794437}},{"date":"2024-02-21","source":"USD","rates":{"GBP":0.791988}},{"date":"2024-02-22","source":"USD","rates":{"GBP":0.791262}},{"date":"2024-02-23","source":"USD","rates":{"GBP":0.789749}},{"date":"2024-02-24","source":"USD","rates":{"GBP":0.78886}},{"date":"2024-02-25","source":"USD","rates":{"GBP":0.789107}},{"date":"2024-02-26","source":"USD","rates":{"GBP":0.78917}},{"date":"2024-02-27","source":"USD","rates":{"GBP":0.788381}},{"date":"2024-02-28","source":"USD","rates":{"GBP":0.78861}},{"date":"2024-02-29","source":"USD","rates":{"GBP":0.789837}},{"date":"2024-03-01","source":"USD","rates":{"GBP":0.792028}},{"date":"2024-03-02","source":"USD","rates":{"GBP":0.790312}},{"date":"2024-03-03","source":"USD","rates":{"GBP":0.790258}},{"date":"2024-03-04","source":"USD","rates":{"GBP":0.789891}},{"date":"2024-03-05","source":"USD","rates":{"GBP":0.788025}},{"date":"2024-03-06","source":"USD","rates":{"GBP":0.787136}},{"date":"2024-03-07","source":"USD","rates":{"GBP":0.785219}},{"date":"2024-03-08","source":"USD","rates":{"GBP":0.780438}},{"date":"2024-03-09","source":"USD","rates":{"GBP":0.777772}},{"date":"2024-03-10","source":"USD","rates":{"GBP":0.777884}},{"date":"2024-03-11","source":"USD","rates":{"GBP":0.77786}},{"date":"2024-03-12","source":"USD","rates":{"GBP":0.780067}},{"date":"2024-03-13","source":"USD","rates":{"GBP":0.781535}},{"date":"2024-03-14","source":"USD","rates":{"GBP":0.781184}},{"date":"2024-03-15","source":"USD","rates":{"GBP":0.784604}},{"date":"2024-03-16","source":"USD","rates":{"GBP":0.785537}},{"date":"2024-03-17","source":"USD","rates":{"GBP":0.785147}},{"date":"2024-03-18","source":"USD","rates":{"GBP":0.785457}},{"date":"2024-03-19","source":"USD","rates":{"GBP":0.785746}},{"date":"2024-03-20","source":"USD","rates":{"GBP":0.786238}},{"date":"2024-03-21","source":"USD","rates":{"GBP":0.781351}},{"date":"2024-03-22","source":"USD","rates":{"GBP":0.789841}},{"date":"2024-03-23","source":"USD","rates":{"GBP":0.793659}},{"date":"2024-03-24","source":"USD","rates":{"GBP":0.793385}},{"date":"2024-03-25","source":"USD","rates":{"GBP":0.793673}},{"date":"2024-03-26","source":"USD","rates":{"GBP":0.791344}},{"date":"2024-03-27","source":"USD","rates":{"GBP":0.791899}},{"date":"2024-03-28","source":"USD","rates":{"GBP":0.792585}},{"date":"2024-03-29","source":"USD","rates":{"GBP":0.792205}},{"date":"2024-03-30","source":"USD","rates":{"GBP":0.792228}},{"date":"2024-03-31","source":"USD","rates":{"GBP":0.792057}},{"date":"2024-04-01","source":"USD","rates":{"GBP":0.79134}},{"date":"2024-04-02","source":"USD","rates":{"GBP":0.797058}},{"date":"2024-04-03","source":"USD","rates":{"GBP":0.795147}},{"date":"2024-04-04","source":"USD","rates":{"GBP":0.790398}},{"date":"2024-04-05","source":"USD","rates":{"GBP":0.791151}},{"date":"2024-04-06","source":"USD","rates":{"GBP":0.791314}},{"date":"2024-04-07","source":"USD","rates":{"GBP":0.791273}},{"date":"2024-04-08","source":"USD","rates":{"GBP":0.792111}},{"date":"2024-04-09","source":"USD","rates":{"GBP":0.790047}},{"date":"2024-04-10","source":"USD","rates":{"GBP":0.788828}},{"date":"2024-04-11","source":"USD","rates":{"GBP":0.797646}},{"date":"2024-04-12","source":"USD","rates":{"GBP":0.796524}},{"date":"2024-04-13","source":"USD","rates":{"GBP":0.803024}},{"date":"2024-04-14","source":"USD","rates":{"GBP":0.802912}},{"date":"2024-04-15","source":"USD","rates":{"GBP":0.8025}},{"date":"2024-04-16","source":"USD","rates":{"GBP":0.80344}},{"date":"2024-04-17","source":"USD","rates":{"GBP":0.804505}},{"date":"2024-04-18","source":"USD","rates":{"GBP":0.80301}},{"date":"2024-04-19","source":"USD","rates":{"GBP":0.804145}},{"date":"2024-04-20","source":"USD","rates":{"GBP":0.80845}},{"date":"2024-04-21","source":"USD","rates":{"GBP":0.808199}},{"date":"2024-04-22","source":"USD","rates":{"GBP":0.808004}},{"date":"2024-04-23","source":"USD","rates":{"GBP":0.809734}},{"date":"2024-04-24","source":"USD","rates":{"GBP":0.802955}},{"date":"2024-04-25","source":"USD","rates":{"GBP":0.80264}},{"date":"2024-04-26","source":"USD","rates":{"GBP":0.799526}},{"date":"2024-04-27","source":"USD","rates":{"GBP":0.80053}},{"date":"2024-04-28","source":"USD","rates":{"GBP":0.800761}},{"date":"2024-04-29","source":"USD","rates":{"GBP":0.799397}},{"date":"2024-04-30","source":"USD","rates":{"GBP":0.796217}},{"date":"2024-05-01","source":"USD","rates":{"GBP":0.800703}},{"date":"2024-05-02","source":"USD","rates":{"GBP":0.797562}},{"date":"2024-05-03","source":"USD","rates":{"GBP":0.797457}},{"date":"2024-05-04","source":"USD","rates":{"GBP":0.797001}},{"date":"2024-05-05","source":"USD","rates":{"GBP":0.797107}},{"date":"2024-05-06","source":"USD","rates":{"GBP":0.797363}},{"date":"2024-05-07","source":"USD","rates":{"GBP":0.796218}},{"date":"2024-05-08","source":"USD","rates":{"GBP":0.799915}},{"date":"2024-05-09","source":"USD","rates":{"GBP":0.800422}},{"date":"2024-05-10","source":"USD","rates":{"GBP":0.798411}},{"date":"2024-05-11","source":"USD","rates":{"GBP":0.798489}},{"date":"2024-05-12","source":"USD","rates":{"GBP":0.798475}},{"date":"2024-05-13","source":"USD","rates":{"GBP":0.79853}},{"date":"2024-05-14","source":"USD","rates":{"GBP":0.796122}},{"date":"2024-05-15","source":"USD","rates":{"GBP":0.794614}},{"date":"2024-05-16","source":"USD","rates":{"GBP":0.78804}},{"date":"2024-05-17","source":"USD","rates":{"GBP":0.789188}},{"date":"2024-05-18","source":"USD","rates":{"GBP":0.787162}},{"date":"2024-05-19","source":"USD","rates":{"GBP":0.787194}},{"date":"2024-05-20","source":"USD","rates":{"GBP":0.787022}},{"date":"2024-05-21","source":"USD","rates":{"GBP":0.786793}},{"date":"2024-05-22","source":"USD","rates":{"GBP":0.786723}},{"date":"2024-05-23","source":"USD","rates":{"GBP":0.786132}},{"date":"2024-05-24","source":"USD","rates":{"GBP":0.78778}},{"date":"2024-05-25","source":"USD","rates":{"GBP":0.785013}},{"date":"2024-05-26","source":"USD","rates":{"GBP":0.785081}},{"date":"2024-05-27","source":"USD","rates":{"GBP":0.78526}},{"date":"2024-05-28","source":"USD","rates":{"GBP":0.78296}},{"date":"2024-05-29","source":"USD","rates":{"GBP":0.783808}},{"date":"2024-05-30","source":"USD","rates":{"GBP":0.787552}},{"date":"2024-05-31","source":"USD","rates":{"GBP":0.785599}},{"date":"2024-06-01","source":"USD","rates":{"GBP":0.785113}},{"date":"2024-06-02","source":"USD","rates":{"GBP":0.785019}},{"date":"2024-06-03","source":"USD","rates":{"GBP":0.784657}},{"date":"2024-06-04","source":"USD","rates":{"GBP":0.780649}},{"date":"2024-06-05","source":"USD","rates":{"GBP":0.782934}},{"date":"2024-06-06","source":"USD","rates":{"GBP":0.781631}},{"date":"2024-06-07","source":"USD","rates":{"GBP":0.781732}},{"date":"2024-06-08","source":"USD","rates":{"GBP":0.785947}},{"date":"2024-06-09","source":"USD","rates":{"GBP":0.785767}},{"date":"2024-06-10","source":"USD","rates":{"GBP":0.785588}},{"date":"2024-06-11","source":"USD","rates":{"GBP":0.785791}},{"date":"2024-06-12","source":"USD","rates":{"GBP":0.784932}},{"date":"2024-06-13","source":"USD","rates":{"GBP":0.781472}},{"date":"2024-06-14","source":"USD","rates":{"GBP":0.784041}},{"date":"2024-06-15","source":"USD","rates":{"GBP":0.789096}},{"date":"2024-06-16","source":"USD","rates":{"GBP":0.788449}},{"date":"2024-06-17","source":"USD","rates":{"GBP":0.788479}},{"date":"2024-06-18","source":"USD","rates":{"GBP":0.786542}},{"date":"2024-06-19","source":"USD","rates":{"GBP":0.786916}},{"date":"2024-06-20","source":"USD","rates":{"GBP":0.786107}},{"date":"2024-06-21","source":"USD","rates":{"GBP":0.789875}},{"date":"2024-06-22","source":"USD","rates":{"GBP":0.79058}},{"date":"2024-06-23","source":"USD","rates":{"GBP":0.790546}},{"date":"2024-06-24","source":"USD","rates":{"GBP":0.791248}},{"date":"2024-06-25","source":"USD","rates":{"GBP":0.788496}},{"date":"2024-06-26","source":"USD","rates":{"GBP":0.788395}},{"date":"2024-06-27","source":"USD","rates":{"GBP":0.792298}},{"date":"2024-06-28","source":"USD","rates":{"GBP":0.79087}},{"date":"2024-06-29","source":"USD","rates":{"GBP":0.790726}},{"date":"2024-06-30","source":"USD","rates":{"GBP":0.790719}},{"date":"2024-07-01","source":"USD","rates":{"GBP":0.790622}},{"date":"2024-07-02","source":"USD","rates":{"GBP":0.790812}},{"date":"2024-07-03","source":"USD","rates":{"GBP":0.78816}},{"date":"2024-07-04","source":"USD","rates":{"GBP":0.784451}},{"date":"2024-07-05","source":"USD","rates":{"GBP":0.783992}},{"date":"2024-07-06","source":"USD","rates":{"GBP":0.780243}},{"date":"2024-07-07","source":"USD","rates":{"GBP":0.780594}},{"date":"2024-07-08","source":"USD","rates":{"GBP":0.780827}},{"date":"2024-07-09","source":"USD","rates":{"GBP":0.780333}},{"date":"2024-07-10","source":"USD","rates":{"GBP":0.781936}},{"date":"2024-07-11","source":"USD","rates":{"GBP":0.777992}},{"date":"2024-07-12","source":"USD","rates":{"GBP":0.773816}},{"date":"2024-07-13","source":"USD","rates":{"GBP":0.770374}},{"date":"2024-07-14","source":"USD","rates":{"GBP":0.770294}},{"date":"2024-07-15","source":"USD","rates":{"GBP":0.771174}},{"date":"2024-07-16","source":"USD","rates":{"GBP":0.771041}},{"date":"2024-07-17","source":"USD","rates":{"GBP":0.770574}},{"date":"2024-07-18","source":"USD","rates":{"GBP":0.768775}},{"date":"2024-07-19","source":"USD","rates":{"GBP":0.772195}},{"date":"2024-07-20","source":"USD","rates":{"GBP":0.774311}},{"date":"2024-07-21","source":"USD","rates":{"GBP":0.774096}},{"date":"2024-07-22","source":"USD","rates":{"GBP":0.773251}},{"date":"2024-07-23","source":"USD","rates":{"GBP":0.773304}},{"date":"2024-07-24","source":"USD","rates":{"GBP":0.775165}},{"date":"2024-07-25","source":"USD","rates":{"GBP":0.775289}},{"date":"2024-07-26","source":"USD","rates":{"GBP":0.777882}},{"date":"2024-07-27","source":"USD","rates":{"GBP":0.777203}},{"date":"2024-07-28","source":"USD","rates":{"GBP":0.776969}},{"date":"2024-07-29","source":"USD","rates":{"GBP":0.777176}},{"date":"2024-07-30","source":"USD","rates":{"GBP":0.777613}},{"date":"2024-07-31","source":"USD","rates":{"GBP":0.778999}}],"paging":{"prev":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=\u0026to=GBP","next":"/rates/historical-range?date_end=2024-07-31\u0026date_start=2024-01-01\u0026from=USD\u0026page=\u0026to=GBP","total_records":213,"current_page":1,"per_page":500,"total_pages":1},"meta":{"credits_used":1,"credits_remaining":249739,"date_start":"2024-01-01","date_end":"2024-07-31"}}' + recorded_at: Fri, 16 May 2025 13:01:35 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/health.yml b/test/vcr_cassettes/synth/health.yml index 4d8ca054..24e3e67b 100644 --- a/test/vcr_cassettes/synth/health.yml +++ b/test/vcr_cassettes/synth/health.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sat, 15 Mar 2025 22:18:47 GMT + - Fri, 16 May 2025 13:01:39 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"4ec3e0a20895d90b1e1241ca67f10ca3" + - W/"c5c1d51b68b499d00936c9eb1e8bfdbb" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 0cab64c9-e312-4bec + - 3abc1256-5517-44a7 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 1958563c-7c18-4201-a03c-a4b343dc68ab + - aaf85301-dd16-4b9b-a3a4-c4fbcf1d3f55 X-Runtime: - - '0.014938' + - '0.014386' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=P3OWn4c8LFFWI0Dwr2CSYwHLaNhf9iD9TfAhqdx5PtLoWZ0pSImebfUsh00ZbOmh4r2cRJEQOmvy67wAwl6p0W%2Fx9017EkCnCaXibBBCKqJTBOdGnsSuV%2B45LrHsQmg%2BGeBwrw4b"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=OaVSdNPSl6CQ8gbhnDzkCisX2ILOEWAwweMW3rXXP5rBKuxZoDT024srQWmHKGLsCEhpt4G9mqCthDwlHu2%2BuZ3AyTJQcnBONtE%2FNQ7fKT9x8nLz4mnqL8iyynLuRWQSUJ8SWMj5"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,14 +69,14 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 920f637aa8cf1152-ORD + - 940b109d086eb4b8-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=25627&min_rtt=25594&rtt_var=9664&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=878&delivery_rate=111991&cwnd=248&unsent_bytes=0&cid=c8e4c4e269114d14&ts=263&x=0" + - cfL4;desc="?proto=TCP&rtt=32457&min_rtt=26792&rtt_var=14094&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2826&recv_bytes=878&delivery_rate=108091&cwnd=229&unsent_bytes=0&cid=a6f330e4d5f16682&ts=309&x=0" body: encoding: ASCII-8BIT - string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"test@maybe.co","name":"Test - User","plan":"Business","api_calls_remaining":249830,"api_limit":250000,"credits_reset_at":"2025-04-01T00:00:00.000-04:00","current_period_start":"2025-03-01T00:00:00.000-05:00"}' - recorded_at: Sat, 15 Mar 2025 22:18:47 GMT + string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"zach@maybe.co","name":"Zach + Gollwitzer","plan":"Business","api_calls_remaining":249733,"api_limit":250000,"credits_reset_at":"2025-06-01T00:00:00.000-04:00","current_period_start":"2025-05-01T00:00:00.000-04:00"}' + recorded_at: Fri, 16 May 2025 13:01:39 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_info.yml b/test/vcr_cassettes/synth/security_info.yml index 8122b882..8dbb76d1 100644 --- a/test/vcr_cassettes/synth/security_info.yml +++ b/test/vcr_cassettes/synth/security_info.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:04:12 GMT + - Fri, 16 May 2025 13:01:37 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"a9deeb6437d359f080be449b9b2c547b" + - W/"75f336ad88e262c72044e8b865265298" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 1e77ae49-050a-45fc + - ba973abf-7d96-4a9a Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 222dacf1-37f3-4eb8-91d5-edf13d732d46 + - 76cb13a6-0d7e-4c36-8df9-bb63110d9e2a X-Runtime: - - '0.059222' + - '0.099716' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=%2BLW%2Fd%2BbcNg4%2FleO6ECyB4RJBMbm6vWG3%2FX4oKQXfn1ROSPVrISc3ZFVlXfITGW4XYJSPyUDF%2FXrrRF6p3Wzow07QamOrsux7sxBMvtWmcubgpCMFI4zgnhESklW6KcmAefwrgj9i"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=aDn7ApAO9Ma86gZ%2BJKCUCFjH2Re%2BtXdB5gcqYj2KTGXJKNpgf5TNgzbrp5%2Bw%2FGL5nTvtp%2B7cxT8MMcLWjAV6Ne1r6z5YBFq1K4W7Zw5m1lhMiqYLnTnEs2Oq85TjzOvpsE%2BmC33d"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,11 +69,11 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 92141c97bfd9124c-ORD + - 940b10910abdd2ec-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=27459&min_rtt=26850&rtt_var=11288&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=905&delivery_rate=91272&cwnd=104&unsent_bytes=0&cid=ccd6aa7e48e4b0eb&ts=287&x=0" + - cfL4;desc="?proto=TCP&rtt=28163&min_rtt=27237&rtt_var=12066&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=905&delivery_rate=83590&cwnd=239&unsent_bytes=0&cid=7ef62bd693b52ccd&ts=240&x=0" body: encoding: ASCII-8BIT string: '{"data":{"ticker":"AAPL","name":"Apple Inc.","links":{"homepage_url":"https://www.apple.com"},"logo_url":"https://logo.synthfinance.com/ticker/AAPL","description":"Apple @@ -100,6 +100,6 @@ http_interactions: Apple Park Way","city":"Cupertino","state":"CA","postal_code":"95014"},"exchange":{"name":"Nasdaq/Ngs (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United States","country_code":"US","timezone":"America/New_York"},"ceo":"Mr. Timothy - D. Cook","founding_year":1976,"industry":"Consumer Electronics","sector":"Technology","phone":"408-996-1010","total_employees":161000,"composite_figi":"BBG000B9Y5X2","market_data":{"high_today":213.95,"low_today":209.58,"open_today":211.25,"close_today":213.49,"volume_today":60060200.0,"fifty_two_week_high":260.1,"fifty_two_week_low":164.08,"average_volume":62848099.37313433,"price_change":0.0,"percent_change":0.0}},"meta":{"credits_used":1,"credits_remaining":249808}}' - recorded_at: Sun, 16 Mar 2025 12:04:12 GMT + D. Cook","founding_year":1976,"industry":"Consumer Electronics","sector":"Technology","phone":"408-996-1010","total_employees":161000,"composite_figi":"BBG000B9Y5X2","market_data":{"high_today":212.96,"low_today":209.54,"open_today":210.95,"close_today":211.45,"volume_today":44979900.0,"fifty_two_week_high":260.1,"fifty_two_week_low":169.21,"average_volume":61769396.875,"price_change":0.0,"percent_change":0.0}},"meta":{"credits_used":1,"credits_remaining":249737}}' + recorded_at: Fri, 16 May 2025 13:01:37 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_price.yml b/test/vcr_cassettes/synth/security_price.yml index 2e6d1dfb..5cd435d4 100644 --- a/test/vcr_cassettes/synth/security_price.yml +++ b/test/vcr_cassettes/synth/security_price.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:08:00 GMT + - Fri, 16 May 2025 13:01:36 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"cdf04c2cd77e230c03117dd13d0921f9" + - W/"72340d82266397447b865407dda15492" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - e74b3425-0b7c-447d + - 4c3462aa-2471-40b4 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - b906c5e1-18cc-44cc-9085-313ff066a6ce + - bdbc757d-2528-44c3-ae08-9788e8ee15f7 X-Runtime: - - '0.544708' + - '0.034898' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=dZNe6qCGGI2XGXgByLr69%2FYrDQdy2FLtnXafxJnlsvyVjrRFiCvmbbIzgF5CDgtj9HZ8RC5Rh9jbuEI6hPokpa3Al4FEIAZB5AbfZ9toP%2Bc5muG%2FuBgHR%2FnIZpsWG%2BQKmBPu9MBa"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=2Mu4PK4XTsAq%2Bn1%2F2yxy%2Blj7kz3ZCiQ9t8ikr2m19BrhQhrqfeUQfPwxbLc1WIgGMIxpPInKYtDVIX3En%2FGpTNQLAeu%2FpuLKv%2BRmCx%2B7u28od5L%2F9%2BLmEhFWqJjs8Y6C1O2a3SKv"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,15 +69,15 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 921422292d0feacc-ORD + - 940b108f29129d03-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=30826&min_rtt=26727&rtt_var=12950&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=970&delivery_rate=108354&cwnd=219&unsent_bytes=0&cid=43c717161effdc57&ts=695&x=0" + - cfL4;desc="?proto=TCP&rtt=27793&min_rtt=26182&rtt_var=13041&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=970&delivery_rate=74111&cwnd=244&unsent_bytes=0&cid=9bcc030369a615fb&ts=210&x=0" body: encoding: ASCII-8BIT string: '{"ticker":"AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United - States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-08-01","open":224.37,"close":218.36,"high":224.48,"low":217.02,"volume":62501000}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","total_records":1,"current_page":1,"per_page":100,"total_pages":1},"meta":{"credits_used":1,"credits_remaining":249807}}' - recorded_at: Sun, 16 Mar 2025 12:08:00 GMT + States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-08-01","open":224.37,"close":218.36,"high":224.48,"low":217.02,"volume":62501000}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-08-01","total_records":1,"current_page":1,"per_page":100,"total_pages":1},"meta":{"total_records":1,"credits_used":1,"credits_remaining":249738}}' + recorded_at: Fri, 16 May 2025 13:01:36 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_prices.yml b/test/vcr_cassettes/synth/security_prices.yml index 1da82461..8f41f56a 100644 --- a/test/vcr_cassettes/synth/security_prices.yml +++ b/test/vcr_cassettes/synth/security_prices.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:02:51 GMT + - Fri, 16 May 2025 13:01:37 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"eb6f73b7cb267ae753291839d20c72e4" + - W/"909e48e0b9ed1f892c1a1e1b4abd3082" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - e0119cf5-873c-4315 + - 63af1418-59b9-4111 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 590af10c-b7c1-47a1-9a9a-7f8a5f031734 + - 74da6a68-0bbd-48fb-b52a-0c5a750bd925 X-Runtime: - - '0.511130' + - '0.044404' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=BgSpKtsSHqPqVuO8FUQ0Zb4nT2VXJ9Q%2F3QrLGiZyq1%2FvGm4KnPL2jbgehp8fTKMHqK64Dm4aoEfwI6iK22Gz%2BG9Kq8wpHPGugon0YRBz1tiYLnq5QVoyvNJi6HV%2B6IBWQ5jCK5wA"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=nwA0tsfR9it%2B9%2BtfHGjyzyfiSqPdNGxQqOLNF%2BIqlTeT1wJT6gLDCtbd1WFpOc1f8UXm2Zjn%2FJDOf7jOKWmGN6SKUBBjZvFLlBq%2FWyC7DN55NJwwyO77vD%2F5nf%2FaqduWCdPx7n7m"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,17 +69,17 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 92141a99ed232306-ORD + - 940b10932ec92305-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=26790&min_rtt=26357&rtt_var=10751&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=970&delivery_rate=97096&cwnd=126&unsent_bytes=0&cid=5ac523d87c018022&ts=666&x=0" + - cfL4;desc="?proto=TCP&rtt=27451&min_rtt=26715&rtt_var=11491&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=970&delivery_rate=88818&cwnd=249&unsent_bytes=0&cid=63105dfc059c15ef&ts=344&x=0" body: encoding: ASCII-8BIT string: '{"ticker":"AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United - States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-01-02","open":187.15,"close":185.64,"high":188.44,"low":183.89,"volume":82488700},{"date":"2024-01-03","open":184.22,"close":184.25,"high":185.88,"low":183.43,"volume":58414500},{"date":"2024-01-04","open":182.15,"close":181.91,"high":183.09,"low":180.88,"volume":71983600},{"date":"2024-01-05","open":181.99,"close":181.18,"high":182.76,"low":180.17,"volume":62303300},{"date":"2024-01-08","open":182.09,"close":185.56,"high":185.6,"low":181.5,"volume":59144500},{"date":"2024-01-09","open":183.92,"close":185.14,"high":185.15,"low":182.73,"volume":42841800},{"date":"2024-01-10","open":184.35,"close":186.19,"high":186.4,"low":183.92,"volume":46792900},{"date":"2024-01-11","open":186.54,"close":185.59,"high":187.05,"low":183.62,"volume":49128400},{"date":"2024-01-12","open":186.06,"close":185.92,"high":186.74,"low":185.19,"volume":40444700},{"date":"2024-01-16","open":182.16,"close":183.63,"high":184.26,"low":180.93,"volume":65603000},{"date":"2024-01-17","open":181.27,"close":182.68,"high":182.93,"low":180.3,"volume":47317400},{"date":"2024-01-18","open":186.09,"close":188.63,"high":189.14,"low":185.83,"volume":78005800},{"date":"2024-01-19","open":189.33,"close":191.56,"high":191.95,"low":188.82,"volume":68741000},{"date":"2024-01-22","open":192.3,"close":193.89,"high":195.33,"low":192.26,"volume":60133900},{"date":"2024-01-23","open":195.02,"close":195.18,"high":195.75,"low":193.83,"volume":42355600},{"date":"2024-01-24","open":195.42,"close":194.5,"high":196.38,"low":194.34,"volume":53631300},{"date":"2024-01-25","open":195.22,"close":194.17,"high":196.27,"low":193.11,"volume":54822100},{"date":"2024-01-26","open":194.27,"close":192.42,"high":194.76,"low":191.94,"volume":44594000},{"date":"2024-01-29","open":192.01,"close":191.73,"high":192.2,"low":189.58,"volume":47145600},{"date":"2024-01-30","open":190.94,"close":188.04,"high":191.8,"low":187.47,"volume":55859400},{"date":"2024-01-31","open":187.04,"close":184.4,"high":187.1,"low":184.35,"volume":55467800},{"date":"2024-02-01","open":183.99,"close":186.86,"high":186.95,"low":183.82,"volume":64885400},{"date":"2024-02-02","open":179.86,"close":185.85,"high":187.33,"low":179.25,"volume":102518000},{"date":"2024-02-05","open":188.15,"close":187.68,"high":189.25,"low":185.84,"volume":69668800},{"date":"2024-02-06","open":186.86,"close":189.3,"high":189.31,"low":186.77,"volume":43490800},{"date":"2024-02-07","open":190.64,"close":189.41,"high":191.05,"low":188.61,"volume":53439000},{"date":"2024-02-08","open":189.39,"close":188.32,"high":189.54,"low":187.35,"volume":40962000},{"date":"2024-02-09","open":188.65,"close":188.85,"high":189.99,"low":188.0,"volume":45155200},{"date":"2024-02-12","open":188.42,"close":187.15,"high":188.67,"low":186.79,"volume":41781900},{"date":"2024-02-13","open":185.77,"close":185.04,"high":186.21,"low":183.51,"volume":56529500},{"date":"2024-02-14","open":185.32,"close":184.15,"high":185.53,"low":182.44,"volume":54630500},{"date":"2024-02-15","open":183.55,"close":183.86,"high":184.49,"low":181.35,"volume":65434500},{"date":"2024-02-16","open":183.42,"close":182.31,"high":184.85,"low":181.67,"volume":49701400},{"date":"2024-02-20","open":181.79,"close":181.56,"high":182.43,"low":180.0,"volume":53665600},{"date":"2024-02-21","open":181.94,"close":182.32,"high":182.89,"low":180.66,"volume":41529700},{"date":"2024-02-22","open":183.48,"close":184.37,"high":184.96,"low":182.46,"volume":52292200},{"date":"2024-02-23","open":185.01,"close":182.52,"high":185.04,"low":182.23,"volume":45119700},{"date":"2024-02-26","open":182.24,"close":181.16,"high":182.76,"low":180.65,"volume":40867400},{"date":"2024-02-27","open":181.1,"close":182.63,"high":183.92,"low":179.56,"volume":54318900},{"date":"2024-02-28","open":182.51,"close":181.42,"high":183.12,"low":180.13,"volume":48953900},{"date":"2024-02-29","open":181.27,"close":180.75,"high":182.57,"low":179.53,"volume":136682600},{"date":"2024-03-01","open":179.55,"close":179.66,"high":180.53,"low":177.38,"volume":73488000},{"date":"2024-03-04","open":176.15,"close":175.1,"high":176.9,"low":173.79,"volume":81510100},{"date":"2024-03-05","open":170.76,"close":170.12,"high":172.04,"low":169.62,"volume":95132400},{"date":"2024-03-06","open":171.06,"close":169.12,"high":171.24,"low":168.68,"volume":68587700},{"date":"2024-03-07","open":169.15,"close":169.0,"high":170.73,"low":168.49,"volume":71765100},{"date":"2024-03-08","open":169.0,"close":170.73,"high":173.7,"low":168.94,"volume":76114600},{"date":"2024-03-11","open":172.94,"close":172.75,"high":174.38,"low":172.05,"volume":60139500},{"date":"2024-03-12","open":173.15,"close":173.23,"high":174.03,"low":171.01,"volume":59825400},{"date":"2024-03-13","open":172.77,"close":171.13,"high":173.19,"low":170.76,"volume":52488700},{"date":"2024-03-14","open":172.91,"close":173.0,"high":174.31,"low":172.05,"volume":72913500},{"date":"2024-03-15","open":171.17,"close":172.62,"high":172.62,"low":170.29,"volume":121664700},{"date":"2024-03-18","open":175.57,"close":173.72,"high":177.71,"low":173.52,"volume":75604200},{"date":"2024-03-19","open":174.34,"close":176.08,"high":176.61,"low":173.03,"volume":55215200},{"date":"2024-03-20","open":175.72,"close":178.67,"high":178.67,"low":175.09,"volume":53423100},{"date":"2024-03-21","open":177.05,"close":171.37,"high":177.49,"low":170.84,"volume":106181300},{"date":"2024-03-22","open":171.76,"close":172.28,"high":173.05,"low":170.06,"volume":71106600},{"date":"2024-03-25","open":170.57,"close":170.85,"high":171.94,"low":169.45,"volume":54288300},{"date":"2024-03-26","open":170.0,"close":169.71,"high":171.42,"low":169.58,"volume":57388400},{"date":"2024-03-27","open":170.41,"close":173.31,"high":173.6,"low":170.11,"volume":60273300},{"date":"2024-03-28","open":171.75,"close":171.48,"high":172.23,"low":170.51,"volume":65672700},{"date":"2024-04-01","open":171.19,"close":170.03,"high":171.25,"low":169.48,"volume":46240500},{"date":"2024-04-02","open":169.08,"close":168.84,"high":169.34,"low":168.23,"volume":49329500},{"date":"2024-04-03","open":168.79,"close":169.65,"high":170.68,"low":168.58,"volume":47691700},{"date":"2024-04-04","open":170.29,"close":168.82,"high":171.92,"low":168.82,"volume":53704400},{"date":"2024-04-05","open":169.59,"close":169.58,"high":170.39,"low":168.95,"volume":42055200},{"date":"2024-04-08","open":169.03,"close":168.45,"high":169.2,"low":168.24,"volume":37425500},{"date":"2024-04-09","open":168.7,"close":169.67,"high":170.08,"low":168.35,"volume":42451200},{"date":"2024-04-10","open":168.8,"close":167.78,"high":169.09,"low":167.11,"volume":49709300},{"date":"2024-04-11","open":168.34,"close":175.04,"high":175.46,"low":168.16,"volume":91070300},{"date":"2024-04-12","open":174.26,"close":176.55,"high":178.36,"low":174.21,"volume":101593300},{"date":"2024-04-15","open":175.36,"close":172.69,"high":176.63,"low":172.5,"volume":73531800},{"date":"2024-04-16","open":171.75,"close":169.38,"high":173.76,"low":168.27,"volume":73711200},{"date":"2024-04-17","open":169.61,"close":168.0,"high":170.65,"low":168.0,"volume":50901200},{"date":"2024-04-18","open":168.03,"close":167.04,"high":168.64,"low":166.55,"volume":43122900},{"date":"2024-04-19","open":166.21,"close":165.0,"high":166.4,"low":164.08,"volume":67772100},{"date":"2024-04-22","open":165.52,"close":165.84,"high":167.26,"low":164.77,"volume":48116400},{"date":"2024-04-23","open":165.35,"close":166.9,"high":167.05,"low":164.92,"volume":49537800},{"date":"2024-04-24","open":166.54,"close":169.02,"high":169.3,"low":166.21,"volume":48251800},{"date":"2024-04-25","open":169.53,"close":169.89,"high":170.61,"low":168.15,"volume":50558300},{"date":"2024-04-26","open":169.88,"close":169.3,"high":171.34,"low":169.18,"volume":44838400},{"date":"2024-04-29","open":173.37,"close":173.5,"high":176.03,"low":173.1,"volume":68169400},{"date":"2024-04-30","open":173.33,"close":170.33,"high":174.99,"low":170.0,"volume":65934800},{"date":"2024-05-01","open":169.58,"close":169.3,"high":172.71,"low":169.11,"volume":50383100},{"date":"2024-05-02","open":172.51,"close":173.03,"high":173.42,"low":170.89,"volume":94214900},{"date":"2024-05-03","open":186.65,"close":183.38,"high":187.0,"low":182.66,"volume":163224100},{"date":"2024-05-06","open":182.35,"close":181.71,"high":184.2,"low":180.42,"volume":78569700},{"date":"2024-05-07","open":183.45,"close":182.4,"high":184.9,"low":181.32,"volume":77305800},{"date":"2024-05-08","open":182.85,"close":182.74,"high":183.07,"low":181.45,"volume":45057100},{"date":"2024-05-09","open":182.56,"close":184.57,"high":184.66,"low":182.11,"volume":48983000},{"date":"2024-05-10","open":184.9,"close":183.05,"high":185.09,"low":182.13,"volume":50759500},{"date":"2024-05-13","open":185.44,"close":186.28,"high":187.1,"low":184.62,"volume":72044800},{"date":"2024-05-14","open":187.51,"close":187.43,"high":188.3,"low":186.29,"volume":52393600},{"date":"2024-05-15","open":187.91,"close":189.72,"high":190.65,"low":187.37,"volume":70400000},{"date":"2024-05-16","open":190.47,"close":189.84,"high":191.1,"low":189.66,"volume":52845200},{"date":"2024-05-17","open":189.51,"close":189.87,"high":190.81,"low":189.18,"volume":41282900},{"date":"2024-05-20","open":189.33,"close":191.04,"high":191.92,"low":189.01,"volume":44361300},{"date":"2024-05-21","open":191.09,"close":192.35,"high":192.73,"low":190.92,"volume":42309400},{"date":"2024-05-22","open":192.27,"close":190.9,"high":192.82,"low":190.27,"volume":34648500},{"date":"2024-05-23","open":190.98,"close":186.88,"high":191.0,"low":186.63,"volume":51005900}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=2\u0026start_date=2024-01-01","total_records":147,"current_page":1,"per_page":100,"total_pages":2},"meta":{"credits_used":1,"credits_remaining":249810}}' - recorded_at: Sun, 16 Mar 2025 12:02:51 GMT + States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-01-02","open":187.15,"close":185.64,"high":188.44,"low":183.89,"volume":82488700},{"date":"2024-01-03","open":184.22,"close":184.25,"high":185.88,"low":183.43,"volume":58414500},{"date":"2024-01-04","open":182.15,"close":181.91,"high":183.09,"low":180.88,"volume":71983600},{"date":"2024-01-05","open":181.99,"close":181.18,"high":182.76,"low":180.17,"volume":62303300},{"date":"2024-01-08","open":182.09,"close":185.56,"high":185.6,"low":181.5,"volume":59144500},{"date":"2024-01-09","open":183.92,"close":185.14,"high":185.15,"low":182.73,"volume":42841800},{"date":"2024-01-10","open":184.35,"close":186.19,"high":186.4,"low":183.92,"volume":46792900},{"date":"2024-01-11","open":186.54,"close":185.59,"high":187.05,"low":183.62,"volume":49128400},{"date":"2024-01-12","open":186.06,"close":185.92,"high":186.74,"low":185.19,"volume":40444700},{"date":"2024-01-16","open":182.16,"close":183.63,"high":184.26,"low":180.93,"volume":65603000},{"date":"2024-01-17","open":181.27,"close":182.68,"high":182.93,"low":180.3,"volume":47317400},{"date":"2024-01-18","open":186.09,"close":188.63,"high":189.14,"low":185.83,"volume":78005800},{"date":"2024-01-19","open":189.33,"close":191.56,"high":191.95,"low":188.82,"volume":68741000},{"date":"2024-01-22","open":192.3,"close":193.89,"high":195.33,"low":192.26,"volume":60133900},{"date":"2024-01-23","open":195.02,"close":195.18,"high":195.75,"low":193.83,"volume":42355600},{"date":"2024-01-24","open":195.42,"close":194.5,"high":196.38,"low":194.34,"volume":53631300},{"date":"2024-01-25","open":195.22,"close":194.17,"high":196.27,"low":193.11,"volume":54822100},{"date":"2024-01-26","open":194.27,"close":192.42,"high":194.76,"low":191.94,"volume":44594000},{"date":"2024-01-29","open":192.01,"close":191.73,"high":192.2,"low":189.58,"volume":47145600},{"date":"2024-01-30","open":190.94,"close":188.04,"high":191.8,"low":187.47,"volume":55859400},{"date":"2024-01-31","open":187.04,"close":184.4,"high":187.1,"low":184.35,"volume":55467800},{"date":"2024-02-01","open":183.99,"close":186.86,"high":186.95,"low":183.82,"volume":64885400},{"date":"2024-02-02","open":179.86,"close":185.85,"high":187.33,"low":179.25,"volume":102518000},{"date":"2024-02-05","open":188.15,"close":187.68,"high":189.25,"low":185.84,"volume":69668800},{"date":"2024-02-06","open":186.86,"close":189.3,"high":189.31,"low":186.77,"volume":43490800},{"date":"2024-02-07","open":190.64,"close":189.41,"high":191.05,"low":188.61,"volume":53439000},{"date":"2024-02-08","open":189.39,"close":188.32,"high":189.54,"low":187.35,"volume":40962000},{"date":"2024-02-09","open":188.65,"close":188.85,"high":189.99,"low":188.0,"volume":45155200},{"date":"2024-02-12","open":188.42,"close":187.15,"high":188.67,"low":186.79,"volume":41781900},{"date":"2024-02-13","open":185.77,"close":185.04,"high":186.21,"low":183.51,"volume":56529500},{"date":"2024-02-14","open":185.32,"close":184.15,"high":185.53,"low":182.44,"volume":54630500},{"date":"2024-02-15","open":183.55,"close":183.86,"high":184.49,"low":181.35,"volume":65434500},{"date":"2024-02-16","open":183.42,"close":182.31,"high":184.85,"low":181.67,"volume":49701400},{"date":"2024-02-20","open":181.79,"close":181.56,"high":182.43,"low":180.0,"volume":53665600},{"date":"2024-02-21","open":181.94,"close":182.32,"high":182.89,"low":180.66,"volume":41529700},{"date":"2024-02-22","open":183.48,"close":184.37,"high":184.96,"low":182.46,"volume":52292200},{"date":"2024-02-23","open":185.01,"close":182.52,"high":185.04,"low":182.23,"volume":45119700},{"date":"2024-02-26","open":182.24,"close":181.16,"high":182.76,"low":180.65,"volume":40867400},{"date":"2024-02-27","open":181.1,"close":182.63,"high":183.92,"low":179.56,"volume":54318900},{"date":"2024-02-28","open":182.51,"close":181.42,"high":183.12,"low":180.13,"volume":48953900},{"date":"2024-02-29","open":181.27,"close":180.75,"high":182.57,"low":179.53,"volume":136682600},{"date":"2024-03-01","open":179.55,"close":179.66,"high":180.53,"low":177.38,"volume":73488000},{"date":"2024-03-04","open":176.15,"close":175.1,"high":176.9,"low":173.79,"volume":81510100},{"date":"2024-03-05","open":170.76,"close":170.12,"high":172.04,"low":169.62,"volume":95132400},{"date":"2024-03-06","open":171.06,"close":169.12,"high":171.24,"low":168.68,"volume":68587700},{"date":"2024-03-07","open":169.15,"close":169.0,"high":170.73,"low":168.49,"volume":71765100},{"date":"2024-03-08","open":169.0,"close":170.73,"high":173.7,"low":168.94,"volume":76114600},{"date":"2024-03-11","open":172.94,"close":172.75,"high":174.38,"low":172.05,"volume":60139500},{"date":"2024-03-12","open":173.15,"close":173.23,"high":174.03,"low":171.01,"volume":59825400},{"date":"2024-03-13","open":172.77,"close":171.13,"high":173.19,"low":170.76,"volume":52488700},{"date":"2024-03-14","open":172.91,"close":173.0,"high":174.31,"low":172.05,"volume":72913500},{"date":"2024-03-15","open":171.17,"close":172.62,"high":172.62,"low":170.29,"volume":121664700},{"date":"2024-03-18","open":175.57,"close":173.72,"high":177.71,"low":173.52,"volume":75604200},{"date":"2024-03-19","open":174.34,"close":176.08,"high":176.61,"low":173.03,"volume":55215200},{"date":"2024-03-20","open":175.72,"close":178.67,"high":178.67,"low":175.09,"volume":53423100},{"date":"2024-03-21","open":177.05,"close":171.37,"high":177.49,"low":170.84,"volume":106181300},{"date":"2024-03-22","open":171.76,"close":172.28,"high":173.05,"low":170.06,"volume":71106600},{"date":"2024-03-25","open":170.57,"close":170.85,"high":171.94,"low":169.45,"volume":54288300},{"date":"2024-03-26","open":170.0,"close":169.71,"high":171.42,"low":169.58,"volume":57388400},{"date":"2024-03-27","open":170.41,"close":173.31,"high":173.6,"low":170.11,"volume":60273300},{"date":"2024-03-28","open":171.75,"close":171.48,"high":172.23,"low":170.51,"volume":65672700},{"date":"2024-04-01","open":171.19,"close":170.03,"high":171.25,"low":169.48,"volume":46240500},{"date":"2024-04-02","open":169.08,"close":168.84,"high":169.34,"low":168.23,"volume":49329500},{"date":"2024-04-03","open":168.79,"close":169.65,"high":170.68,"low":168.58,"volume":47691700},{"date":"2024-04-04","open":170.29,"close":168.82,"high":171.92,"low":168.82,"volume":53704400},{"date":"2024-04-05","open":169.59,"close":169.58,"high":170.39,"low":168.95,"volume":42055200},{"date":"2024-04-08","open":169.03,"close":168.45,"high":169.2,"low":168.24,"volume":37425500},{"date":"2024-04-09","open":168.7,"close":169.67,"high":170.08,"low":168.35,"volume":42451200},{"date":"2024-04-10","open":168.8,"close":167.78,"high":169.09,"low":167.11,"volume":49709300},{"date":"2024-04-11","open":168.34,"close":175.04,"high":175.46,"low":168.16,"volume":91070300},{"date":"2024-04-12","open":174.26,"close":176.55,"high":178.36,"low":174.21,"volume":101593300},{"date":"2024-04-15","open":175.36,"close":172.69,"high":176.63,"low":172.5,"volume":73531800},{"date":"2024-04-16","open":171.75,"close":169.38,"high":173.76,"low":168.27,"volume":73711200},{"date":"2024-04-17","open":169.61,"close":168.0,"high":170.65,"low":168.0,"volume":50901200},{"date":"2024-04-18","open":168.03,"close":167.04,"high":168.64,"low":166.55,"volume":43122900},{"date":"2024-04-19","open":166.21,"close":165.0,"high":166.4,"low":164.08,"volume":67772100},{"date":"2024-04-22","open":165.52,"close":165.84,"high":167.26,"low":164.77,"volume":48116400},{"date":"2024-04-23","open":165.35,"close":166.9,"high":167.05,"low":164.92,"volume":49537800},{"date":"2024-04-24","open":166.54,"close":169.02,"high":169.3,"low":166.21,"volume":48251800},{"date":"2024-04-25","open":169.53,"close":169.89,"high":170.61,"low":168.15,"volume":50558300},{"date":"2024-04-26","open":169.88,"close":169.3,"high":171.34,"low":169.18,"volume":44838400},{"date":"2024-04-29","open":173.37,"close":173.5,"high":176.03,"low":173.1,"volume":68169400},{"date":"2024-04-30","open":173.33,"close":170.33,"high":174.99,"low":170.0,"volume":65934800},{"date":"2024-05-01","open":169.58,"close":169.3,"high":172.71,"low":169.11,"volume":50383100},{"date":"2024-05-02","open":172.51,"close":173.03,"high":173.42,"low":170.89,"volume":94214900},{"date":"2024-05-03","open":186.65,"close":183.38,"high":187.0,"low":182.66,"volume":163224100},{"date":"2024-05-06","open":182.35,"close":181.71,"high":184.2,"low":180.42,"volume":78569700},{"date":"2024-05-07","open":183.45,"close":182.4,"high":184.9,"low":181.32,"volume":77305800},{"date":"2024-05-08","open":182.85,"close":182.74,"high":183.07,"low":181.45,"volume":45057100},{"date":"2024-05-09","open":182.56,"close":184.57,"high":184.66,"low":182.11,"volume":48983000},{"date":"2024-05-10","open":184.9,"close":183.05,"high":185.09,"low":182.13,"volume":50759500},{"date":"2024-05-13","open":185.44,"close":186.28,"high":187.1,"low":184.62,"volume":72044800},{"date":"2024-05-14","open":187.51,"close":187.43,"high":188.3,"low":186.29,"volume":52393600},{"date":"2024-05-15","open":187.91,"close":189.72,"high":190.65,"low":187.37,"volume":70400000},{"date":"2024-05-16","open":190.47,"close":189.84,"high":191.1,"low":189.66,"volume":52845200},{"date":"2024-05-17","open":189.51,"close":189.87,"high":190.81,"low":189.18,"volume":41282900},{"date":"2024-05-20","open":189.33,"close":191.04,"high":191.92,"low":189.01,"volume":44361300},{"date":"2024-05-21","open":191.09,"close":192.35,"high":192.73,"low":190.92,"volume":42309400},{"date":"2024-05-22","open":192.27,"close":190.9,"high":192.82,"low":190.27,"volume":34648500},{"date":"2024-05-23","open":190.98,"close":186.88,"high":191.0,"low":186.63,"volume":51005900}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=2\u0026start_date=2024-01-01","total_records":147,"current_page":1,"per_page":100,"total_pages":2},"meta":{"total_records":147,"credits_used":1,"credits_remaining":249736}}' + recorded_at: Fri, 16 May 2025 13:01:37 GMT - request: method: get uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&operating_mic_code=XNAS&page=2&start_date=2024-01-01 @@ -94,7 +94,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -105,7 +105,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:02:52 GMT + - Fri, 16 May 2025 13:01:37 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -115,11 +115,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"78f6663a1523295a82d0ded13df426e4" + - W/"bbc82ef9591694561dd9992a9c06d491" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - b0cd3704-937c-4017 + - 63ebee52-f1b2-4e81 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -133,15 +133,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 59a55ec3-49af-4fa1-a104-77480fa6914e + - dd95cb59-aead-4d1e-b1a2-881696e742fb X-Runtime: - - '0.583469' + - '0.031482' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ze9Rfqww2cEeTSTiP5axby5TPvYyBZDoEHRZKniMMybJrqYiBI1oGlCViODsaOXisw23njq1YaO%2Fhc0yGlPaqYdTcMXc6bQbVnWANjASqMS%2BQoVmPBFPr3nvSqeU99huB4BKWGlY"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ebrOdAId1yoCYepT0CPKImNIA%2BOe8V3W3BHYheEOkVQFLsffFpfl%2B%2BYXfEHL21wczvW5dkZSd3OrF%2FklB%2FwGGDahXpveXzf497azY1Ho4YJrtDJeghxyZV6J%2BALPYwwpGrfUpv%2F1"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -149,15 +149,15 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 92141a9edbeee15f-ORD + - 940b1095ccd41156-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=32376&min_rtt=31508&rtt_var=12435&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=970&delivery_rate=91913&cwnd=171&unsent_bytes=0&cid=d782914cf2ed620a&ts=758&x=0" + - cfL4;desc="?proto=TCP&rtt=26344&min_rtt=26162&rtt_var=10175&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=970&delivery_rate=104847&cwnd=237&unsent_bytes=0&cid=9603fe0eb1df39aa&ts=212&x=0" body: encoding: ASCII-8BIT string: '{"ticker":"AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs (Global Select Market)","mic_code":"XNGS","operating_mic_code":"XNAS","acronym":"NGS","country":"United - States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-05-24","open":188.82,"close":189.98,"high":190.58,"low":188.04,"volume":36294600},{"date":"2024-05-28","open":191.51,"close":189.99,"high":193.0,"low":189.1,"volume":52280100},{"date":"2024-05-29","open":189.61,"close":190.29,"high":192.25,"low":189.51,"volume":53068000},{"date":"2024-05-30","open":190.76,"close":191.29,"high":192.18,"low":190.63,"volume":49947900},{"date":"2024-05-31","open":191.44,"close":192.25,"high":192.57,"low":189.91,"volume":75158300},{"date":"2024-06-03","open":192.9,"close":194.03,"high":194.99,"low":192.52,"volume":50080500},{"date":"2024-06-04","open":194.64,"close":194.35,"high":195.32,"low":193.03,"volume":47471400},{"date":"2024-06-05","open":195.4,"close":195.87,"high":196.9,"low":194.87,"volume":54156800},{"date":"2024-06-06","open":195.69,"close":194.48,"high":196.5,"low":194.17,"volume":41181800},{"date":"2024-06-07","open":194.65,"close":196.89,"high":196.94,"low":194.14,"volume":53103900},{"date":"2024-06-10","open":196.9,"close":193.12,"high":197.3,"low":192.15,"volume":97262100},{"date":"2024-06-11","open":193.65,"close":207.15,"high":207.16,"low":193.63,"volume":172373300},{"date":"2024-06-12","open":207.37,"close":213.07,"high":220.2,"low":206.9,"volume":198134300},{"date":"2024-06-13","open":214.74,"close":214.24,"high":216.75,"low":211.6,"volume":97862700},{"date":"2024-06-14","open":213.85,"close":212.49,"high":215.17,"low":211.3,"volume":70122700},{"date":"2024-06-17","open":213.37,"close":216.67,"high":218.95,"low":212.72,"volume":93728300},{"date":"2024-06-18","open":217.59,"close":214.29,"high":218.63,"low":213.0,"volume":79943300},{"date":"2024-06-20","open":213.93,"close":209.68,"high":214.24,"low":208.85,"volume":86172500},{"date":"2024-06-21","open":210.39,"close":207.49,"high":211.89,"low":207.11,"volume":246421400},{"date":"2024-06-24","open":207.72,"close":208.14,"high":212.7,"low":206.59,"volume":80727000},{"date":"2024-06-25","open":209.15,"close":209.07,"high":211.38,"low":208.61,"volume":56713900},{"date":"2024-06-26","open":211.5,"close":213.25,"high":214.86,"low":210.64,"volume":66213200},{"date":"2024-06-27","open":214.69,"close":214.1,"high":215.74,"low":212.35,"volume":49772700},{"date":"2024-06-28","open":215.77,"close":210.62,"high":216.07,"low":210.3,"volume":82542700},{"date":"2024-07-01","open":212.09,"close":216.75,"high":217.51,"low":211.92,"volume":60402900},{"date":"2024-07-02","open":216.15,"close":220.27,"high":220.38,"low":215.1,"volume":58046200},{"date":"2024-07-03","open":220.0,"close":221.55,"high":221.55,"low":219.03,"volume":37369800},{"date":"2024-07-05","open":221.65,"close":226.34,"high":226.45,"low":221.65,"volume":60412400},{"date":"2024-07-08","open":227.09,"close":227.82,"high":227.85,"low":223.25,"volume":59085900},{"date":"2024-07-09","open":227.93,"close":228.68,"high":229.4,"low":226.37,"volume":48076100},{"date":"2024-07-10","open":229.3,"close":232.98,"high":233.08,"low":229.25,"volume":62627700},{"date":"2024-07-11","open":231.39,"close":227.57,"high":232.39,"low":225.77,"volume":64710600},{"date":"2024-07-12","open":228.92,"close":230.54,"high":232.64,"low":228.68,"volume":53046500},{"date":"2024-07-15","open":236.48,"close":234.4,"high":237.23,"low":233.09,"volume":62631300},{"date":"2024-07-16","open":235.0,"close":234.82,"high":236.27,"low":232.33,"volume":43234300},{"date":"2024-07-17","open":229.45,"close":228.88,"high":231.46,"low":226.64,"volume":57345900},{"date":"2024-07-18","open":230.28,"close":224.18,"high":230.44,"low":222.27,"volume":66034600},{"date":"2024-07-19","open":224.82,"close":224.31,"high":226.8,"low":223.28,"volume":49151500},{"date":"2024-07-22","open":227.01,"close":223.96,"high":227.78,"low":223.09,"volume":48201800},{"date":"2024-07-23","open":224.37,"close":225.01,"high":226.94,"low":222.68,"volume":39960300},{"date":"2024-07-24","open":224.0,"close":218.54,"high":224.8,"low":217.13,"volume":61777600},{"date":"2024-07-25","open":218.93,"close":217.49,"high":220.85,"low":214.62,"volume":51391200},{"date":"2024-07-26","open":218.7,"close":217.96,"high":219.49,"low":216.01,"volume":41601300},{"date":"2024-07-29","open":216.96,"close":218.24,"high":219.3,"low":215.75,"volume":36311800},{"date":"2024-07-30","open":219.19,"close":218.8,"high":220.33,"low":216.12,"volume":41643800},{"date":"2024-07-31","open":221.44,"close":222.08,"high":223.82,"low":220.63,"volume":50036300},{"date":"2024-08-01","open":224.37,"close":218.36,"high":224.48,"low":217.02,"volume":62501000}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=1\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-01-01","total_records":147,"current_page":2,"per_page":100,"total_pages":2},"meta":{"credits_used":1,"credits_remaining":249809}}' - recorded_at: Sun, 16 Mar 2025 12:02:52 GMT + States","country_code":"US","timezone":"America/New_York"},"prices":[{"date":"2024-05-24","open":188.82,"close":189.98,"high":190.58,"low":188.04,"volume":36294600},{"date":"2024-05-28","open":191.51,"close":189.99,"high":193.0,"low":189.1,"volume":52280100},{"date":"2024-05-29","open":189.61,"close":190.29,"high":192.25,"low":189.51,"volume":53068000},{"date":"2024-05-30","open":190.76,"close":191.29,"high":192.18,"low":190.63,"volume":49947900},{"date":"2024-05-31","open":191.44,"close":192.25,"high":192.57,"low":189.91,"volume":75158300},{"date":"2024-06-03","open":192.9,"close":194.03,"high":194.99,"low":192.52,"volume":50080500},{"date":"2024-06-04","open":194.64,"close":194.35,"high":195.32,"low":193.03,"volume":47471400},{"date":"2024-06-05","open":195.4,"close":195.87,"high":196.9,"low":194.87,"volume":54156800},{"date":"2024-06-06","open":195.69,"close":194.48,"high":196.5,"low":194.17,"volume":41181800},{"date":"2024-06-07","open":194.65,"close":196.89,"high":196.94,"low":194.14,"volume":53103900},{"date":"2024-06-10","open":196.9,"close":193.12,"high":197.3,"low":192.15,"volume":97262100},{"date":"2024-06-11","open":193.65,"close":207.15,"high":207.16,"low":193.63,"volume":172373300},{"date":"2024-06-12","open":207.37,"close":213.07,"high":220.2,"low":206.9,"volume":198134300},{"date":"2024-06-13","open":214.74,"close":214.24,"high":216.75,"low":211.6,"volume":97862700},{"date":"2024-06-14","open":213.85,"close":212.49,"high":215.17,"low":211.3,"volume":70122700},{"date":"2024-06-17","open":213.37,"close":216.67,"high":218.95,"low":212.72,"volume":93728300},{"date":"2024-06-18","open":217.59,"close":214.29,"high":218.63,"low":213.0,"volume":79943300},{"date":"2024-06-20","open":213.93,"close":209.68,"high":214.24,"low":208.85,"volume":86172500},{"date":"2024-06-21","open":210.39,"close":207.49,"high":211.89,"low":207.11,"volume":246421400},{"date":"2024-06-24","open":207.72,"close":208.14,"high":212.7,"low":206.59,"volume":80727000},{"date":"2024-06-25","open":209.15,"close":209.07,"high":211.38,"low":208.61,"volume":56713900},{"date":"2024-06-26","open":211.5,"close":213.25,"high":214.86,"low":210.64,"volume":66213200},{"date":"2024-06-27","open":214.69,"close":214.1,"high":215.74,"low":212.35,"volume":49772700},{"date":"2024-06-28","open":215.77,"close":210.62,"high":216.07,"low":210.3,"volume":82542700},{"date":"2024-07-01","open":212.09,"close":216.75,"high":217.51,"low":211.92,"volume":60402900},{"date":"2024-07-02","open":216.15,"close":220.27,"high":220.38,"low":215.1,"volume":58046200},{"date":"2024-07-03","open":220.0,"close":221.55,"high":221.55,"low":219.03,"volume":37369800},{"date":"2024-07-05","open":221.65,"close":226.34,"high":226.45,"low":221.65,"volume":60412400},{"date":"2024-07-08","open":227.09,"close":227.82,"high":227.85,"low":223.25,"volume":59085900},{"date":"2024-07-09","open":227.93,"close":228.68,"high":229.4,"low":226.37,"volume":48076100},{"date":"2024-07-10","open":229.3,"close":232.98,"high":233.08,"low":229.25,"volume":62627700},{"date":"2024-07-11","open":231.39,"close":227.57,"high":232.39,"low":225.77,"volume":64710600},{"date":"2024-07-12","open":228.92,"close":230.54,"high":232.64,"low":228.68,"volume":53046500},{"date":"2024-07-15","open":236.48,"close":234.4,"high":237.23,"low":233.09,"volume":62631300},{"date":"2024-07-16","open":235.0,"close":234.82,"high":236.27,"low":232.33,"volume":43234300},{"date":"2024-07-17","open":229.45,"close":228.88,"high":231.46,"low":226.64,"volume":57345900},{"date":"2024-07-18","open":230.28,"close":224.18,"high":230.44,"low":222.27,"volume":66034600},{"date":"2024-07-19","open":224.82,"close":224.31,"high":226.8,"low":223.28,"volume":49151500},{"date":"2024-07-22","open":227.01,"close":223.96,"high":227.78,"low":223.09,"volume":48201800},{"date":"2024-07-23","open":224.37,"close":225.01,"high":226.94,"low":222.68,"volume":39960300},{"date":"2024-07-24","open":224.0,"close":218.54,"high":224.8,"low":217.13,"volume":61777600},{"date":"2024-07-25","open":218.93,"close":217.49,"high":220.85,"low":214.62,"volume":51391200},{"date":"2024-07-26","open":218.7,"close":217.96,"high":219.49,"low":216.01,"volume":41601300},{"date":"2024-07-29","open":216.96,"close":218.24,"high":219.3,"low":215.75,"volume":36311800},{"date":"2024-07-30","open":219.19,"close":218.8,"high":220.33,"low":216.12,"volume":41643800},{"date":"2024-07-31","open":221.44,"close":222.08,"high":223.82,"low":220.63,"volume":50036300},{"date":"2024-08-01","open":224.37,"close":218.36,"high":224.48,"low":217.02,"volume":62501000}],"paging":{"prev":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=1\u0026start_date=2024-01-01","next":"/tickers/AAPL/open-close?end_date=2024-08-01\u0026operating_mic_code=XNAS\u0026page=\u0026start_date=2024-01-01","total_records":147,"current_page":2,"per_page":100,"total_pages":2},"meta":{"total_records":147,"credits_used":1,"credits_remaining":249735}}' + recorded_at: Fri, 16 May 2025 13:01:37 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/security_search.yml b/test/vcr_cassettes/synth/security_search.yml index f9504804..c06c0f42 100644 --- a/test/vcr_cassettes/synth/security_search.yml +++ b/test/vcr_cassettes/synth/security_search.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sun, 16 Mar 2025 12:01:58 GMT + - Fri, 16 May 2025 13:01:38 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -39,7 +39,7 @@ http_interactions: Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 2effb56b-f67f-402d + - 701ae22a-18c8-4e62 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - 33470619-5119-4923-b4e0-e9a0eeb532a1 + - edb55bc6-e3ea-470b-b7af-9b4d9883420b X-Runtime: - - '0.453770' + - '0.355152' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=ayZOlXkCwLgUl%2FrB2%2BlqtqR5HCllubf4HLDipEt3klWKyHS4nilHi9XZ1fiEQWx7xwiRMJZ5EW0Xzm7ISoHWTtEbkgMQHWYQwSTeg30ahFFHK1pkOOnET1fuW1UxiZwlJtq1XZGB"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=QGeBWdYED%2F%2FgT9BzborFAnM%2FG6UiNmI0ej212XGHWdFwYXUvTJ2GyqA9hMJrpYIvgbHdQ9Ed0MsQUv3KFb57VXQq0T6UXTNPa%2BFRPepK0hsXeGDLxch04v6KnkTATqcw2M8HuYHS"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,11 +69,11 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 921419514e0a6399-ORD + - 940b1097a830f856-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=25809&min_rtt=25801&rtt_var=9692&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=939&delivery_rate=111952&cwnd=121&unsent_bytes=0&cid=2beb787f15cd8ab9&ts=610&x=0" + - cfL4;desc="?proto=TCP&rtt=26401&min_rtt=25556&rtt_var=11273&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=939&delivery_rate=89615&cwnd=244&unsent_bytes=0&cid=cf6d0758d165295d&ts=500&x=0" body: encoding: ASCII-8BIT string: '{"data":[{"symbol":"AAPL","name":"Apple Inc.","logo_url":"https://logo.synthfinance.com/ticker/AAPL","currency":"USD","exchange":{"name":"Nasdaq/Ngs @@ -100,5 +100,5 @@ http_interactions: Inc.","logo_url":"https://logo.synthfinance.com/ticker/AAPJ","currency":"USD","exchange":{"name":"Otc Pink Marketplace","mic_code":"PINX","operating_mic_code":"OTCM","acronym":"","country":"United States","country_code":"US","timezone":"America/New_York"}}]}' - recorded_at: Sun, 16 Mar 2025 12:01:58 GMT + recorded_at: Fri, 16 May 2025 13:01:38 GMT recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/transaction_enrich.yml b/test/vcr_cassettes/synth/transaction_enrich.yml deleted file mode 100644 index 08463ed7..00000000 --- a/test/vcr_cassettes/synth/transaction_enrich.yml +++ /dev/null @@ -1,82 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: https://api.synthfinance.com/enrich?amount=25.5&city=San%20Francisco&country=US&date=2025-03-16&description=UBER%20EATS&state=CA - body: - encoding: US-ASCII - string: '' - headers: - Authorization: - - Bearer - X-Source: - - maybe_app - X-Source-Type: - - managed - User-Agent: - - Faraday v2.12.2 - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Sun, 16 Mar 2025 12:09:33 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cache-Control: - - max-age=0, private, must-revalidate - Etag: - - W/"00411c83cfeaade519bcc3e57d9e461e" - Referrer-Policy: - - strict-origin-when-cross-origin - Rndr-Id: - - 56a8791d-85ed-4342 - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - X-Content-Type-Options: - - nosniff - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Render-Origin-Server: - - Render - X-Request-Id: - - 1b35b9c1-0092-40b1-8b70-2bce7c5796af - X-Runtime: - - '0.884634' - X-Xss-Protection: - - '0' - Cf-Cache-Status: - - DYNAMIC - Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=qUtB0aWbK%2Fh5W7cV%2FugsUGbWKtJzsf%2FXd5i8cm8KlepEtLyuVPH7XX0fqwzHp43OCWQkGr9r8hRBBSEcx9LWW5vS7%2B1kXCJaKPaTRn%2BWtsEymHg78OHqDcMahwSuy%2FkpSGLWo0or"}],"group":"cf-nel","max_age":604800}' - Nel: - - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' - Speculation-Rules: - - '"/cdn-cgi/speculation"' - Server: - - cloudflare - Cf-Ray: - - 921424681aa4acab-ORD - Alt-Svc: - - h3=":443"; ma=86400 - Server-Timing: - - cfL4;desc="?proto=TCP&rtt=26975&min_rtt=26633&rtt_var=10231&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2829&recv_bytes=969&delivery_rate=108737&cwnd=210&unsent_bytes=0&cid=318ff675628918e1&ts=1035&x=0" - body: - encoding: ASCII-8BIT - string: '{"merchant":"Uber Eats","merchant_id":"mer_aea41e7f29ce47b5873f3caf49d5972d","category":"Dining - Out","website":"ubereats.com","icon":"https://logo.synthfinance.com/ubereats.com","meta":{"credits_used":1,"credits_remaining":249806}}' - recorded_at: Sun, 16 Mar 2025 12:09:33 GMT -recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/synth/usage.yml b/test/vcr_cassettes/synth/usage.yml index 27e5300f..d60922db 100644 --- a/test/vcr_cassettes/synth/usage.yml +++ b/test/vcr_cassettes/synth/usage.yml @@ -14,7 +14,7 @@ http_interactions: X-Source-Type: - managed User-Agent: - - Faraday v2.12.2 + - Faraday v2.13.1 Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: @@ -25,7 +25,7 @@ http_interactions: message: OK headers: Date: - - Sat, 15 Mar 2025 22:18:47 GMT + - Fri, 16 May 2025 13:01:36 GMT Content-Type: - application/json; charset=utf-8 Transfer-Encoding: @@ -35,11 +35,11 @@ http_interactions: Cache-Control: - max-age=0, private, must-revalidate Etag: - - W/"4ec3e0a20895d90b1e1241ca67f10ca3" + - W/"7b8c2bf0cba54bc26b78bdc6e611dcbd" Referrer-Policy: - strict-origin-when-cross-origin Rndr-Id: - - 54c8ecf9-6858-4db6 + - 1b53adf6-b391-45b2 Strict-Transport-Security: - max-age=63072000; includeSubDomains Vary: @@ -53,15 +53,15 @@ http_interactions: X-Render-Origin-Server: - Render X-Request-Id: - - a4112cfb-0eac-4e3e-a880-7536d90dcba0 + - f88670a2-81d2-48b6-8d73-a911c846e330 X-Runtime: - - '0.007036' + - '0.018749' X-Xss-Protection: - '0' Cf-Cache-Status: - DYNAMIC Report-To: - - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=Rt0BTtrgXzYjWOQFgb%2Bg6N4xKvXtPI66Q251bq9nWtqUhGHo17GmVVAPkutwN7Gisw1RmvYfxYUiMCCxlc4%2BjuHxbU1%2BXr9KHy%2F5pUpLhgLNNrtkqqKOCW4GduODnDbw2I38Rocu"}],"group":"cf-nel","max_age":604800}' + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=oH4OsWB6itK0jpi%2FPs%2BswVyCZIbkJGPfyJaoR4TKFtTAfmnqa8Lp6aZhv22WKzotXJuAKbh99VdYdZIOkeIPWbYTc6j4rGw%2BkQB3Hw%2Fc44QxDBJFdIo6wJNe8TGiPAZ%2BvgoBVHWn"}],"group":"cf-nel","max_age":604800}' Nel: - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' Speculation-Rules: @@ -69,14 +69,14 @@ http_interactions: Server: - cloudflare Cf-Ray: - - 920f637d1fe8eb68-ORD + - 940b108c38f66392-ORD Alt-Svc: - h3=":443"; ma=86400 Server-Timing: - - cfL4;desc="?proto=TCP&rtt=28779&min_rtt=27036&rtt_var=11384&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2828&recv_bytes=878&delivery_rate=107116&cwnd=203&unsent_bytes=0&cid=52bc39ad09dd9eff&ts=145&x=0" + - cfL4;desc="?proto=TCP&rtt=33369&min_rtt=25798&rtt_var=15082&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2826&recv_bytes=878&delivery_rate=112256&cwnd=205&unsent_bytes=0&cid=1b13324eb0768fd3&ts=285&x=0" body: encoding: ASCII-8BIT - string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"test@maybe.co","name":"Test - User","plan":"Business","api_calls_remaining":1200,"api_limit":5000,"credits_reset_at":"2025-04-01T00:00:00.000-04:00","current_period_start":"2025-03-01T00:00:00.000-05:00"}' - recorded_at: Sat, 15 Mar 2025 22:18:47 GMT + string: '{"id":"user_3208c49393f54b3e974795e4bea5b864","email":"zach@maybe.co","name":"Zach + Gollwitzer","plan":"Business","api_calls_remaining":249738,"api_limit":250000,"credits_reset_at":"2025-06-01T00:00:00.000-04:00","current_period_start":"2025-05-01T00:00:00.000-04:00"}' + recorded_at: Fri, 16 May 2025 13:01:36 GMT recorded_with: VCR 6.3.1