From 1f8a994b4e3e4dd851bb84c2f3f0a448b52d2758 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Sun, 1 Jun 2025 06:17:11 -0500 Subject: [PATCH 01/20] Comment out auto-sync callback in AutoSync concern to disable family synchronization temporarily. --- app/controllers/concerns/auto_sync.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/concerns/auto_sync.rb b/app/controllers/concerns/auto_sync.rb index 15cdc557..705e69e9 100644 --- a/app/controllers/concerns/auto_sync.rb +++ b/app/controllers/concerns/auto_sync.rb @@ -1,9 +1,9 @@ module AutoSync extend ActiveSupport::Concern - included do - before_action :sync_family, if: :family_needs_auto_sync? - end + # included do + # before_action :sync_family, if: :family_needs_auto_sync? + # end private def sync_family -- 2.53.0 From 870b5436405282ec9aa8718b4fc92c73cf0de19e Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Sun, 1 Jun 2025 06:30:38 -0500 Subject: [PATCH 02/20] Refactor syncing? method in Family model to optimize query performance. Moved visible scope to the beginning and adjusted joins and where conditions to leverage composite indexing for improved efficiency. --- app/models/family.rb | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/models/family.rb b/app/models/family.rb index cd068cae..a4b2c8b1 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -37,10 +37,20 @@ class Family < ApplicationRecord # 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'") + # Check for any in-progress syncs that belong directly to the family, to one of the + # family's accounts, or to one of the family's Plaid items. By moving the `visible` + # scope to the beginning we narrow down the candidate rows **before** performing the + # joins and by explicitly constraining the `syncable_type` for the direct Family + # match we allow Postgres to use the composite index on `(syncable_type, syncable_id)`. + Sync.visible .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 + .joins("LEFT JOIN plaid_items ON plaid_items.id = syncs.syncable_id AND syncs.syncable_type = 'PlaidItem'") + .where( + "(syncs.syncable_type = 'Family' AND syncs.syncable_id = :family_id) OR " \ + "accounts.family_id = :family_id OR " \ + "plaid_items.family_id = :family_id", + family_id: id + ) .exists? end -- 2.53.0 From a76cc2dff8903f174b24d4648ad86267e667b82f Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Sun, 1 Jun 2025 06:37:46 -0500 Subject: [PATCH 03/20] Configure PlaidSandbox to use sandbox environment regardless of Rails config and set test environment variables for Plaid. Temporarily disable AutoSync functionality in tests. --- app/models/provider/plaid_sandbox.rb | 3 +++ test/controllers/concerns/auto_sync_test.rb | 2 ++ test/test_helper.rb | 6 +++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/models/provider/plaid_sandbox.rb b/app/models/provider/plaid_sandbox.rb index 52e06d2c..e4d7a347 100644 --- a/app/models/provider/plaid_sandbox.rb +++ b/app/models/provider/plaid_sandbox.rb @@ -44,6 +44,9 @@ class Provider::PlaidSandbox < Provider::Plaid Rails.application.config.plaid ) + # Force sandbox environment for PlaidSandbox regardless of Rails config + api_client.config.server_index = Plaid::Configuration::Environment["sandbox"] + Plaid::PlaidApi.new(api_client) end end diff --git a/test/controllers/concerns/auto_sync_test.rb b/test/controllers/concerns/auto_sync_test.rb index 0ae19ab1..d0fb83e3 100644 --- a/test/controllers/concerns/auto_sync_test.rb +++ b/test/controllers/concerns/auto_sync_test.rb @@ -10,12 +10,14 @@ class AutoSyncTest < ActionDispatch::IntegrationTest end test "auto-syncs family if hasn't synced" do + skip "AutoSync functionality temporarily disabled" assert_difference "Sync.count", 1 do get root_path end end test "auto-syncs family if hasn't synced in last 24 hours" do + skip "AutoSync functionality temporarily disabled" # 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 diff --git a/test/test_helper.rb b/test/test_helper.rb index 65f5db35..077eae5b 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -9,8 +9,12 @@ require_relative "../config/environment" ENV["RAILS_ENV"] ||= "test" +# Set Plaid to sandbox mode for tests +ENV["PLAID_ENV"] = "sandbox" +ENV["PLAID_CLIENT_ID"] ||= "test_client_id" +ENV["PLAID_SECRET"] ||= "test_secret" + # Fixes Segfaults on M1 Macs when running tests in parallel (temporary workaround) -# https://github.com/ged/ruby-pg/issues/538#issuecomment-1591629049 ENV["PGGSSENCMODE"] = "disable" require "rails/test_help" -- 2.53.0 From d05946596efa3b1c925ac07fccd7d9f7aaa094ce Mon Sep 17 00:00:00 2001 From: "Adam M. Goyer" <1908555+AdamGoyer@users.noreply.github.com> Date: Tue, 3 Jun 2025 03:14:25 -0700 Subject: [PATCH 04/20] Fix typo in docker hosting documentation (#2318) --- docs/hosting/docker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/hosting/docker.md b/docs/hosting/docker.md index 13f9469a..28ae6b16 100644 --- a/docs/hosting/docker.md +++ b/docs/hosting/docker.md @@ -19,7 +19,7 @@ Complete the following steps: docker run hello-world ``` -### Step 2: Configure your Docker Compose file and environnment +### Step 2: Configure your Docker Compose file and environment #### Create a directory for your app to run -- 2.53.0 From 9f6c9b4057c7c7cddd732647316d189aa3343dfc Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 9 Jun 2025 09:53:32 -0400 Subject: [PATCH 05/20] Update deps --- Gemfile.lock | 76 ++++++++++++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b4d4ccb6..c9be7d74 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -63,7 +63,7 @@ GEM activemodel (= 7.2.2.1) activesupport (= 7.2.2.1) timeout (>= 0.4.0) - activerecord-import (2.1.0) + activerecord-import (2.2.0) activerecord (>= 4.2) activestorage (7.2.2.1) actionpack (= 7.2.2.1) @@ -89,27 +89,27 @@ GEM activerecord (>= 4.2) activesupport ast (2.4.3) - aws-eventstream (1.3.2) - aws-partitions (1.1105.0) - aws-sdk-core (3.224.0) + aws-eventstream (1.4.0) + aws-partitions (1.1113.0) + aws-sdk-core (3.225.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.101.0) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-kms (1.104.0) + aws-sdk-core (~> 3, >= 3.225.0) aws-sigv4 (~> 1.5) aws-sdk-s3 (1.177.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.11.0) + aws-sigv4 (1.12.0) aws-eventstream (~> 1, >= 1.0.2) - base64 (0.2.0) + base64 (0.3.0) bcrypt (3.1.20) - benchmark (0.4.0) + benchmark (0.4.1) benchmark-ips (2.14.0) better_html (2.1.1) actionview (>= 6.0) @@ -118,7 +118,7 @@ GEM erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (3.1.9) + bigdecimal (3.2.2) bindex (0.8.1) bootsnap (1.18.6) msgpack (~> 1.2) @@ -149,7 +149,7 @@ GEM unicode (>= 0.4.4.5) css_parser (1.21.1) addressable - csv (3.3.4) + csv (3.3.5) date (3.4.1) debug (1.10.0) irb (~> 1.10) @@ -198,7 +198,7 @@ GEM raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - hashdiff (1.1.2) + hashdiff (1.2.0) highline (3.1.2) reline hotwire-livereload (2.0.0) @@ -244,7 +244,7 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.12.0) + json (2.12.2) jwt (2.10.1) base64 language_server-protocol (3.17.0.5) @@ -340,7 +340,7 @@ GEM ast (~> 2.4.1) racc pg (1.5.9) - plaid (39.0.0) + plaid (40.0.0) faraday (>= 1.0.1, < 3.0) faraday-multipart (>= 1.0.1, < 2.0) platform_agent (1.0.1) @@ -363,7 +363,7 @@ GEM nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.15) + rack (3.1.16) rack-mini-profiler (3.3.1) rack (>= 1.2.0) rack-session (2.1.1) @@ -387,7 +387,7 @@ GEM activesupport (= 7.2.2.1) bundler (>= 1.15.0) railties (= 7.2.2.1) - rails-dom-testing (2.2.0) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) @@ -409,7 +409,7 @@ GEM thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.2.1) + rake (13.3.0) rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) @@ -433,7 +433,7 @@ GEM chunky_png (~> 1.0) rqrcode_core (~> 2.0) rqrcode_core (2.0.0) - rubocop (1.75.6) + rubocop (1.76.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -441,10 +441,10 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.44.0, < 2.0) + rubocop-ast (>= 1.45.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.44.1) + rubocop-ast (1.45.1) parser (>= 3.3.7.2) prism (~> 1.4) rubocop-performance (1.25.0) @@ -461,19 +461,19 @@ GEM rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) - ruby-lsp (0.23.20) + ruby-lsp (0.24.1) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) - rbs (>= 3, < 4) + rbs (>= 3, < 5) sorbet-runtime (>= 0.5.10782) - ruby-lsp-rails (0.4.3) - ruby-lsp (>= 0.23.18, < 0.24.0) + ruby-lsp-rails (0.4.6) + ruby-lsp (>= 0.24.0, < 0.25.0) ruby-openai (8.1.0) event_stream_parser (>= 0.3.0, < 2.0.0) faraday (>= 1) faraday-multipart (>= 1) ruby-progressbar (1.13.0) - ruby-vips (2.2.3) + ruby-vips (2.2.4) ffi (~> 1.12) logger ruby2_keywords (0.0.5) @@ -497,7 +497,7 @@ GEM sentry-sidekiq (5.24.0) sentry-ruby (~> 5.24.0) sidekiq (>= 3.0) - sidekiq (8.0.3) + sidekiq (8.0.4) connection_pool (>= 2.5.0) json (>= 2.9.0) logger (>= 1.6.2) @@ -517,26 +517,26 @@ GEM skylight (6.0.4) activesupport (>= 5.2.0) smart_properties (1.17.0) - sorbet-runtime (0.5.12117) + sorbet-runtime (0.5.12163) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.7) - stripe (15.1.0) + stripe (15.2.1) tailwindcss-rails (4.2.3) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.1.7) - tailwindcss-ruby (4.1.7-aarch64-linux-gnu) - tailwindcss-ruby (4.1.7-aarch64-linux-musl) - tailwindcss-ruby (4.1.7-arm64-darwin) - tailwindcss-ruby (4.1.7-x86_64-darwin) - tailwindcss-ruby (4.1.7-x86_64-linux-gnu) - tailwindcss-ruby (4.1.7-x86_64-linux-musl) + tailwindcss-ruby (4.1.8) + tailwindcss-ruby (4.1.8-aarch64-linux-gnu) + tailwindcss-ruby (4.1.8-aarch64-linux-musl) + tailwindcss-ruby (4.1.8-arm64-darwin) + tailwindcss-ruby (4.1.8-x86_64-darwin) + tailwindcss-ruby (4.1.8-x86_64-linux-gnu) + tailwindcss-ruby (4.1.8-x86_64-linux-musl) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) thor (1.3.2) timeout (0.4.3) - turbo-rails (2.0.13) + turbo-rails (2.0.16) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) @@ -549,7 +549,7 @@ GEM useragent (0.16.11) vcr (6.3.1) base64 - vernier (1.7.1) + vernier (1.8.0) view_component (3.23.2) activesupport (>= 5.2.0, < 8.1) concurrent-ruby (~> 1) @@ -564,7 +564,7 @@ GEM crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) - websocket-driver (0.7.7) + websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) -- 2.53.0 From 1d2e7fcae0966cb818d0d5d42a34b17d21252ef2 Mon Sep 17 00:00:00 2001 From: "Iuri G." <289754+iuri-gg@users.noreply.github.com> Date: Mon, 9 Jun 2025 09:18:52 -0500 Subject: [PATCH 06/20] perf: Add index to sync status (#2337) * - Add index to sync status * - revert typo * - revert unrelated schema.rb change --- db/migrate/20250605031616_add_index_to_sync_status.rb | 7 +++++++ db/schema.rb | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20250605031616_add_index_to_sync_status.rb diff --git a/db/migrate/20250605031616_add_index_to_sync_status.rb b/db/migrate/20250605031616_add_index_to_sync_status.rb new file mode 100644 index 00000000..d1187a35 --- /dev/null +++ b/db/migrate/20250605031616_add_index_to_sync_status.rb @@ -0,0 +1,7 @@ +class AddIndexToSyncStatus < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_index :syncs, :status, if_not_exists: true, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index b5f41eab..c38c5375 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_05_23_131455) do +ActiveRecord::Schema[7.2].define(version: 2025_06_05_031616) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -593,6 +593,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_23_131455) do t.date "window_start_date" t.date "window_end_date" t.index ["parent_id"], name: "index_syncs_on_parent_id" + t.index ["status"], name: "index_syncs_on_status" t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" end -- 2.53.0 From 0063921de9fb70cd42a76f96c4a9ea3c8c7349dd Mon Sep 17 00:00:00 2001 From: Tony Tkachenko <103267335+tonytkachenko@users.noreply.github.com> Date: Mon, 9 Jun 2025 16:19:09 +0200 Subject: [PATCH 07/20] fix(ui): mfa backup codes dark mode (#2323) * fix(ui): mfa backup codes dark mode * Update app/views/mfa/backup_codes.html.erb Signed-off-by: Zach Gollwitzer --------- Signed-off-by: Zach Gollwitzer Co-authored-by: Zach Gollwitzer --- app/views/mfa/backup_codes.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/mfa/backup_codes.html.erb b/app/views/mfa/backup_codes.html.erb index e4684030..6292d9be 100644 --- a/app/views/mfa/backup_codes.html.erb +++ b/app/views/mfa/backup_codes.html.erb @@ -13,7 +13,7 @@
<% @backup_codes.each do |code| %> -
+
<%= code %>
<% end %> -- 2.53.0 From 9afc50a1469bd0bed626ae9a0894b8cdda1db89e Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 9 Jun 2025 10:50:56 -0400 Subject: [PATCH 08/20] Fix merchant editing (#2349) --- .../family_merchants_controller.rb | 15 +++++----- app/views/family_merchants/_form.html.erb | 1 + app/views/family_merchants/index.html.erb | 6 ++-- app/views/family_merchants/new.html.erb | 2 +- app/views/merchants/_merchant.html.erb | 29 ------------------- 5 files changed, 12 insertions(+), 41 deletions(-) delete mode 100644 app/views/merchants/_merchant.html.erb diff --git a/app/controllers/family_merchants_controller.rb b/app/controllers/family_merchants_controller.rb index 01531a47..1798056f 100644 --- a/app/controllers/family_merchants_controller.rb +++ b/app/controllers/family_merchants_controller.rb @@ -4,19 +4,19 @@ class FamilyMerchantsController < ApplicationController def index @breadcrumbs = [ [ "Home", root_path ], [ "Merchants", nil ] ] - @merchants = Current.family.merchants.alphabetically + @family_merchants = Current.family.merchants.alphabetically render layout: "settings" end def new - @merchant = FamilyMerchant.new(family: Current.family) + @family_merchant = FamilyMerchant.new(family: Current.family) end def create - @merchant = FamilyMerchant.new(merchant_params.merge(family: Current.family)) + @family_merchant = FamilyMerchant.new(merchant_params.merge(family: Current.family)) - if @merchant.save + if @family_merchant.save respond_to do |format| format.html { redirect_to family_merchants_path, notice: t(".success") } format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) } @@ -30,7 +30,7 @@ class FamilyMerchantsController < ApplicationController end def update - @merchant.update!(merchant_params) + @family_merchant.update!(merchant_params) respond_to do |format| format.html { redirect_to family_merchants_path, notice: t(".success") } format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) } @@ -38,14 +38,13 @@ class FamilyMerchantsController < ApplicationController end def destroy - @merchant.destroy! + @family_merchant.destroy! redirect_to family_merchants_path, notice: t(".success") end private - def set_merchant - @merchant = Current.family.merchants.find(params[:id]) + @family_merchant = Current.family.merchants.find(params[:id]) end def merchant_params diff --git a/app/views/family_merchants/_form.html.erb b/app/views/family_merchants/_form.html.erb index db5bd085..f0680ab5 100644 --- a/app/views/family_merchants/_form.html.erb +++ b/app/views/family_merchants/_form.html.erb @@ -6,6 +6,7 @@ <% if family_merchant.errors.any? %> <%= render "shared/form_errors", model: family_merchant %> <% end %> +
<%= render partial: "shared/color_avatar", locals: { name: family_merchant.name, color: family_merchant.color } %>
diff --git a/app/views/family_merchants/index.html.erb b/app/views/family_merchants/index.html.erb index 7438aa2b..a65efeb0 100644 --- a/app/views/family_merchants/index.html.erb +++ b/app/views/family_merchants/index.html.erb @@ -10,17 +10,17 @@
- <% if @merchants.any? %> + <% if @family_merchants.any? %>

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

· -

<%= @merchants.count %>

+

<%= @family_merchants.count %>

- <%= render partial: "family_merchants/family_merchant", collection: @merchants, spacer_template: "shared/ruler" %> + <%= render partial: "family_merchants/family_merchant", collection: @family_merchants, spacer_template: "shared/ruler" %>
diff --git a/app/views/family_merchants/new.html.erb b/app/views/family_merchants/new.html.erb index fa9da88f..044ae422 100644 --- a/app/views/family_merchants/new.html.erb +++ b/app/views/family_merchants/new.html.erb @@ -1,6 +1,6 @@ <%= render DialogComponent.new do |dialog| %> <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %> - <%= render "form", family_merchant: @merchant %> + <%= render "form", family_merchant: @family_merchant %> <% end %> <% end %> diff --git a/app/views/merchants/_merchant.html.erb b/app/views/merchants/_merchant.html.erb deleted file mode 100644 index 964a7fff..00000000 --- a/app/views/merchants/_merchant.html.erb +++ /dev/null @@ -1,29 +0,0 @@ -<%# locals: (merchant:) %> - -
-
- <% if merchant.icon_url %> -
- <%= image_tag merchant.icon_url, class: "w-8 h-8 rounded-full" %> -
- <% else %> - <%= render partial: "shared/color_avatar", locals: { name: merchant.name, color: merchant.color } %> - <% end %> - -

- <%= merchant.name %> -

-
-
- <%= render MenuComponent.new do |menu| %> - <% menu.with_item(variant: "link", text: t(".edit"), href: edit_merchant_path(merchant), icon: "pencil", data: { turbo_frame: "modal" }) %> - <% menu.with_item( - variant: "button", - text: t(".delete"), - href: merchant_path(merchant), - icon: "trash-2", - method: :delete, - confirm: CustomConfirm.for_resource_deletion(merchant.name)) %> - <% end %> -
-
-- 2.53.0 From 4044a8519fba5bdbe696543626019121eab6a8cc Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 9 Jun 2025 11:35:59 -0400 Subject: [PATCH 09/20] Add account sync button back to self hosted instances --- app/views/accounts/show/_header.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index b64408ce..35dcc2ed 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -20,7 +20,7 @@ <% end %>
- <% if Rails.env.development? %> + <% if Rails.env.development? || self_hosted? %> <%= icon( "refresh-cw", as_button: true, -- 2.53.0 From 9fabcf4c7253468143ab2288b5060713208e8b8a Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 9 Jun 2025 18:30:52 -0400 Subject: [PATCH 10/20] Redis check for self hosted apps (#2353) * Redis check for self hosted apps * Run linter with autocorrect * Add Redis to CI --- .github/workflows/ci.yml | 7 +++ app/controllers/concerns/self_hostable.rb | 27 +++++++++ app/controllers/pages_controller.rb | 11 ++-- .../accountable_sparklines/_error.html.erb | 2 +- app/views/accounts/_sparkline_error.html.erb | 2 +- .../accounts/new/_method_selector.html.erb | 4 +- app/views/chats/_chat.html.erb | 10 ++-- app/views/credit_cards/new.html.erb | 6 +- app/views/cryptos/new.html.erb | 6 +- app/views/depositories/new.html.erb | 6 +- app/views/imports/_table.html.erb | 2 +- app/views/investments/new.html.erb | 6 +- app/views/layouts/blank.html.erb | 13 ++++ app/views/loans/new.html.erb | 6 +- .../pages/dashboard/_balance_sheet.html.erb | 2 +- .../pages/redis_configuration_error.html.erb | 59 +++++++++++++++++++ .../plaid_items/_auto_link_opener.html.erb | 8 +-- app/views/plaid_items/edit.html.erb | 10 ++-- app/views/plaid_items/new.html.erb | 10 ++-- config/routes.rb | 2 + 20 files changed, 152 insertions(+), 47 deletions(-) create mode 100644 app/views/layouts/blank.html.erb create mode 100644 app/views/pages/redis_configuration_error.html.erb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2306ec69..eae6bb5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,7 @@ jobs: PLAID_CLIENT_ID: foo PLAID_SECRET: bar DATABASE_URL: postgres://postgres:postgres@localhost:5432 + REDIS_URL: redis://localhost:6379 RAILS_ENV: test services: @@ -92,6 +93,12 @@ jobs: - 5432:5432 options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 + redis: + image: redis + ports: + - 6379:6379 + options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 + steps: - name: Install packages run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev diff --git a/app/controllers/concerns/self_hostable.rb b/app/controllers/concerns/self_hostable.rb index a863f170..4208e03a 100644 --- a/app/controllers/concerns/self_hostable.rb +++ b/app/controllers/concerns/self_hostable.rb @@ -3,6 +3,8 @@ module SelfHostable included do helper_method :self_hosted?, :self_hosted_first_login? + + prepend_before_action :verify_self_host_config end private @@ -13,4 +15,29 @@ module SelfHostable def self_hosted_first_login? self_hosted? && User.count.zero? end + + def verify_self_host_config + return unless self_hosted? + + # Special handling for Redis configuration error page + if controller_name == "pages" && action_name == "redis_configuration_error" + # If Redis is now working, redirect to home + if redis_connected? + redirect_to root_path, notice: "Redis is now configured properly! You can now setup your Maybe application." + end + + return + end + + unless redis_connected? + redirect_to redis_configuration_error_path + end + end + + def redis_connected? + Redis.new.ping + true + rescue Redis::CannotConnectError + false + end end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index b921f89c..a3694fb9 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,7 +1,8 @@ class PagesController < ApplicationController - skip_before_action :authenticate_user!, only: %i[early_access] include Periodable + skip_authentication only: :redis_configuration_error + def dashboard @balance_sheet = Current.family.balance_sheet @accounts = Current.family.accounts.active.with_attached_logo @@ -47,12 +48,8 @@ class PagesController < ApplicationController render layout: "settings" end - def early_access - redirect_to root_path if self_hosted? - - @invite_codes_count = InviteCode.count - @invite_code = InviteCode.order("RANDOM()").limit(1).first - render layout: false + def redis_configuration_error + render layout: "blank" end private diff --git a/app/views/accountable_sparklines/_error.html.erb b/app/views/accountable_sparklines/_error.html.erb index 70ce745e..f43f609f 100644 --- a/app/views/accountable_sparklines/_error.html.erb +++ b/app/views/accountable_sparklines/_error.html.erb @@ -5,4 +5,4 @@

Error

-<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/accounts/_sparkline_error.html.erb b/app/views/accounts/_sparkline_error.html.erb index 2f813a0d..dbee8dbf 100644 --- a/app/views/accounts/_sparkline_error.html.erb +++ b/app/views/accounts/_sparkline_error.html.erb @@ -5,4 +5,4 @@

Error

-<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb index e30c2463..03beaa92 100644 --- a/app/views/accounts/new/_method_selector.html.erb +++ b/app/views/accounts/new/_method_selector.html.erb @@ -11,7 +11,7 @@ <% if show_us_link %> <%# Default US-only Link %> - <%= link_to new_plaid_item_path(region: "us", accountable_type: accountable_type), + <%= link_to new_plaid_item_path(region: "us", accountable_type: accountable_type), class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2", data: { turbo_frame: "modal" } do %> @@ -23,7 +23,7 @@ <%# EU Link %> <% if show_eu_link %> - <%= link_to new_plaid_item_path(region: "eu", accountable_type: accountable_type), + <%= link_to new_plaid_item_path(region: "eu", accountable_type: accountable_type), class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2", data: { turbo_frame: "modal" } do %> diff --git a/app/views/chats/_chat.html.erb b/app/views/chats/_chat.html.erb index c61c5a04..129cdbe7 100644 --- a/app/views/chats/_chat.html.erb +++ b/app/views/chats/_chat.html.erb @@ -13,12 +13,12 @@ <%= render MenuComponent.new(icon_vertical: true) do |menu| %> <% menu.with_item( - variant: "link", - text: "Edit chat title", - href: edit_chat_path(chat, ctx: "list"), - icon: "pencil", + variant: "link", + text: "Edit chat title", + href: edit_chat_path(chat, ctx: "list"), + icon: "pencil", frame: dom_id(chat, "title")) %> - + <% menu.with_item( variant: "button", text: "Delete chat", diff --git a/app/views/credit_cards/new.html.erb b/app/views/credit_cards/new.html.erb index f6cd7bca..95bca964 100644 --- a/app/views/credit_cards/new.html.erb +++ b/app/views/credit_cards/new.html.erb @@ -1,7 +1,7 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", - path: new_credit_card_path(return_to: params[:return_to]), - show_us_link: @show_us_link, + <%= render "accounts/new/method_selector", + path: new_credit_card_path(return_to: params[:return_to]), + show_us_link: @show_us_link, show_eu_link: @show_eu_link, accountable_type: "CreditCard" %> <% else %> diff --git a/app/views/cryptos/new.html.erb b/app/views/cryptos/new.html.erb index a803df7b..8e93242f 100644 --- a/app/views/cryptos/new.html.erb +++ b/app/views/cryptos/new.html.erb @@ -1,7 +1,7 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", - path: new_crypto_path(return_to: params[:return_to]), - show_us_link: @show_us_link, + <%= render "accounts/new/method_selector", + path: new_crypto_path(return_to: params[:return_to]), + show_us_link: @show_us_link, show_eu_link: @show_eu_link, accountable_type: "Crypto" %> <% else %> diff --git a/app/views/depositories/new.html.erb b/app/views/depositories/new.html.erb index c2110726..23bbd79b 100644 --- a/app/views/depositories/new.html.erb +++ b/app/views/depositories/new.html.erb @@ -1,7 +1,7 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", - path: new_depository_path(return_to: params[:return_to]), - show_us_link: @show_us_link, + <%= render "accounts/new/method_selector", + path: new_depository_path(return_to: params[:return_to]), + show_us_link: @show_us_link, show_eu_link: @show_eu_link, accountable_type: "Depository" %> <% else %> diff --git a/app/views/imports/_table.html.erb b/app/views/imports/_table.html.erb index 5cc3a49c..8de7cea3 100644 --- a/app/views/imports/_table.html.erb +++ b/app/views/imports/_table.html.erb @@ -14,7 +14,7 @@ <% headers.each_with_index do |header, index| %> <%= index == headers.length - 1 ? "rounded-tr-lg" : "" %> <%= index < headers.length - 1 ? "border-r border-r-alpha-black-200" : "" %> diff --git a/app/views/investments/new.html.erb b/app/views/investments/new.html.erb index d31af9c9..1c2fb8e4 100644 --- a/app/views/investments/new.html.erb +++ b/app/views/investments/new.html.erb @@ -1,7 +1,7 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", - path: new_investment_path(return_to: params[:return_to]), - show_us_link: @show_us_link, + <%= render "accounts/new/method_selector", + path: new_investment_path(return_to: params[:return_to]), + show_us_link: @show_us_link, show_eu_link: @show_eu_link, accountable_type: "Investment" %> <% else %> diff --git a/app/views/layouts/blank.html.erb b/app/views/layouts/blank.html.erb new file mode 100644 index 00000000..7193dcb6 --- /dev/null +++ b/app/views/layouts/blank.html.erb @@ -0,0 +1,13 @@ +<%= render "layouts/shared/htmldoc" do %> +
+
+ <%= yield %> +
+ + <% if content_for?(:footer) %> + <%= yield :footer %> + <% else %> + <%= render "layouts/shared/footer" %> + <% end %> +
+<% end %> diff --git a/app/views/loans/new.html.erb b/app/views/loans/new.html.erb index ae16df8e..c3784ffd 100644 --- a/app/views/loans/new.html.erb +++ b/app/views/loans/new.html.erb @@ -1,7 +1,7 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", - path: new_loan_path(return_to: params[:return_to]), - show_us_link: @show_us_link, + <%= render "accounts/new/method_selector", + path: new_loan_path(return_to: params[:return_to]), + show_us_link: @show_us_link, show_eu_link: @show_eu_link, accountable_type: "Loan" %> <% else %> diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index 337e5268..62a5077a 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -116,7 +116,7 @@ <% else %>
- <% + <% # Calculate weight as percentage of classification total classification_total = classification_group.total_money.amount account_weight = classification_total.zero? ? 0 : account.converted_balance / classification_total * 100 diff --git a/app/views/pages/redis_configuration_error.html.erb b/app/views/pages/redis_configuration_error.html.erb new file mode 100644 index 00000000..303ce4a7 --- /dev/null +++ b/app/views/pages/redis_configuration_error.html.erb @@ -0,0 +1,59 @@ +<% content_for :title, "Redis Configuration Required - Maybe" %> + +
+
+
+ +
+
+ <%= icon "alert-triangle", class: "w-8 h-8 text-red-600" %> +
+

Redis Configuration Required

+

Your self-hosted Maybe installation needs Redis to be properly configured.

+
+ + +
+
+
+ <%= icon "info", class: "w-5 h-5 text-amber-600 mt-0.5 mr-3 flex-shrink-0" %> +
+

Why is Redis required?

+

Maybe uses Redis to power Sidekiq background jobs for tasks like syncing account data, processing imports, and other background operations that keep your financial data up to date.

+
+
+
+ + +
+ <%= render LinkComponent.new( + text: "View Setup Guide", + href: "https://github.com/maybe-finance/maybe/blob/main/docs/hosting/docker.md", + variant: "primary", + size: "lg", + icon: "external-link", + full_width: true, + target: "_blank", + rel: "noopener noreferrer" + ) %> +

Follow our complete Docker setup guide to configure Redis

+
+
+ + +
+
+

Once you've configured Redis, refresh this page to continue.

+ <%= render ButtonComponent.new( + text: "Refresh Page", + variant: "secondary", + icon: "refresh-cw", + type: "button", + full_width: true, + onclick: "window.location.reload()" + ) %> +
+
+
+
+
diff --git a/app/views/plaid_items/_auto_link_opener.html.erb b/app/views/plaid_items/_auto_link_opener.html.erb index 0a43028b..7e9c7695 100644 --- a/app/views/plaid_items/_auto_link_opener.html.erb +++ b/app/views/plaid_items/_auto_link_opener.html.erb @@ -1,9 +1,9 @@ <%# locals: (link_token:, region:, item_id:, is_update: false) %> -<%= tag.div data: { - controller: "plaid", - plaid_link_token_value: link_token, +<%= tag.div data: { + controller: "plaid", + plaid_link_token_value: link_token, plaid_region_value: region, plaid_item_id_value: item_id, plaid_is_update_value: is_update - } %> \ No newline at end of file + } %> diff --git a/app/views/plaid_items/edit.html.erb b/app/views/plaid_items/edit.html.erb index 31620ad9..4830ad4e 100644 --- a/app/views/plaid_items/edit.html.erb +++ b/app/views/plaid_items/edit.html.erb @@ -1,8 +1,8 @@ <%# We render this in the empty modal frame so if Plaid flow is closed, user stays on same page they were on %> <%= turbo_frame_tag "modal" do %> - <%= render "plaid_items/auto_link_opener", - link_token: @link_token, - region: @plaid_item.plaid_region, - item_id: @plaid_item.id, + <%= render "plaid_items/auto_link_opener", + link_token: @link_token, + region: @plaid_item.plaid_region, + item_id: @plaid_item.id, is_update: true %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/plaid_items/new.html.erb b/app/views/plaid_items/new.html.erb index a3d83660..b927d375 100644 --- a/app/views/plaid_items/new.html.erb +++ b/app/views/plaid_items/new.html.erb @@ -1,8 +1,8 @@ <%# We render this in the empty modal frame so if Plaid flow is closed, user stays on same page they were on %> <%= turbo_frame_tag "modal" do %> - <%= render "plaid_items/auto_link_opener", - link_token: @link_token, - region: params[:region], - item_id: "", + <%= render "plaid_items/auto_link_opener", + link_token: @link_token, + region: params[:region], + item_id: "", is_update: false %> -<% end %> \ No newline at end of file +<% end %> diff --git a/config/routes.rb b/config/routes.rb index 35feaaf8..c2e8865e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -205,6 +205,8 @@ Rails.application.routes.draw do post "stripe" end + get "redis-configuration-error", to: "pages#redis_configuration_error" + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check -- 2.53.0 From 019a0d873c2ecfd813bc65064d4c992ffc9ce333 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Mon, 9 Jun 2025 18:39:04 -0400 Subject: [PATCH 11/20] Fix dark mode text hover styles --- app/views/trades/_trade.html.erb | 2 +- app/views/transactions/_transaction.html.erb | 2 +- app/views/transactions/_transfer_match.html.erb | 4 ++-- app/views/valuations/_valuation.html.erb | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/trades/_trade.html.erb b/app/views/trades/_trade.html.erb index 2a4c32f0..7d940aa6 100644 --- a/app/views/trades/_trade.html.erb +++ b/app/views/trades/_trade.html.erb @@ -23,7 +23,7 @@ <%= link_to entry.name, entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, - class: "hover:underline hover:text-gray-800" %> + class: "hover:underline" %>
<% end %>
diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index d031134f..a046915e 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -43,7 +43,7 @@ turbo_frame: "drawer", turbo_prefetch: false }, - class: "hover:underline hover:text-gray-800" + class: "hover:underline" ) %> <% if entry.excluded %> diff --git a/app/views/transactions/_transfer_match.html.erb b/app/views/transactions/_transfer_match.html.erb index a0824a63..1b3a6df7 100644 --- a/app/views/transactions/_transfer_match.html.erb +++ b/app/views/transactions/_transfer_match.html.erb @@ -12,7 +12,7 @@ <%= button_to transfer_path(transaction.transfer, transfer: { status: "confirmed" }), method: :patch, - class: "text-secondary hover:text-gray-800 flex items-center justify-center cursor-pointer", + class: "text-secondary flex items-center justify-center cursor-pointer", title: "Confirm match" do %> <%= icon "check", size: "sm", class: "text-secondary hover:text-primary" %> <% end %> @@ -20,7 +20,7 @@ <%= button_to transfer_path(transaction.transfer, transfer: { status: "rejected" }), method: :patch, data: { turbo: false }, - class: "text-secondary hover:text-gray-800 flex items-center justify-center cursor-pointer", + class: "text-secondary hover:text-primary flex items-center justify-center cursor-pointer", title: "Reject match" do %> <%= icon "x", size: "sm", class: "text-subdued hover:text-primary" %> <% end %> diff --git a/app/views/valuations/_valuation.html.erb b/app/views/valuations/_valuation.html.erb index 8d93f1e1..416f1775 100644 --- a/app/views/valuations/_valuation.html.erb +++ b/app/views/valuations/_valuation.html.erb @@ -20,7 +20,7 @@ <%= link_to entry.name, entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, - class: "hover:underline hover:text-gray-800" %> + class: "hover:underline" %>
-- 2.53.0 From dab693d74f5c2a70f914119a9235f28c7fb9264e Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Tue, 10 Jun 2025 05:10:57 -0500 Subject: [PATCH 12/20] Logtail updates --- config/environments/production.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/environments/production.rb b/config/environments/production.rb index e8327dc6..671b5239 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -49,10 +49,10 @@ Rails.application.configure do config.assume_ssl = ActiveModel::Type::Boolean.new.cast(ENV.fetch("RAILS_ASSUME_SSL", true)) # Log to Logtail if API key is present, otherwise log to STDOUT - base_logger = if ENV["LOGTAIL_API_KEY"].present? + base_logger = if ENV["LOGTAIL_API_KEY"].present? && ENV["LOGTAIL_INGESTING_HOST"].present? Logtail::Logger.create_default_logger( ENV["LOGTAIL_API_KEY"], - telemetry_host: "in.logs.betterstack.com" + ingesting_host: ENV["LOGTAIL_INGESTING_HOST"] ) else ActiveSupport::Logger.new(STDOUT) -- 2.53.0 From 10ce2c8e23b4695030982351f5bf09d5a6e4e803 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 10 Jun 2025 18:20:06 -0400 Subject: [PATCH 13/20] Balance sheet cache layer, non-blocking sync UI (#2356) * Balance sheet cache layer with cache-busting * Update family cache timestamps during Sync * Less blocking sync loaders * Consolidate family data caching key logic * Fix turbo stream broadcasts * Remove dev delay * Add back account group sorting --- .../accountable_sparklines_controller.rb | 41 +++--- app/controllers/accounts_controller.rb | 14 +- app/models/account.rb | 12 -- app/models/account/chartable.rb | 14 +- app/models/account/sync_complete_event.rb | 30 ++-- .../assistant/function/get_balance_sheet.rb | 8 +- app/models/balance_sheet.rb | 134 ++++-------------- app/models/balance_sheet/account_group.rb | 61 ++++++++ app/models/balance_sheet/account_totals.rb | 63 ++++++++ .../balance_sheet/classification_group.rb | 61 ++++++++ .../balance_sheet/net_worth_series_builder.rb | 38 +++++ .../balance_sheet/sync_status_monitor.rb | 35 +++++ app/models/concerns/syncable.rb | 2 +- app/models/family.rb | 29 +--- app/models/plaid_item.rb | 8 -- app/models/sync.rb | 27 +++- .../accountable_sparklines/_error.html.erb | 8 -- .../accountable_sparklines/show.html.erb | 16 +-- .../accounts/_account_sidebar_tabs.html.erb | 6 +- .../accounts/_accountable_group.html.erb | 59 ++++---- app/views/accounts/_sparkline_error.html.erb | 8 -- app/views/accounts/chart.html.erb | 28 ++-- app/views/accounts/show/_chart.html.erb | 12 +- app/views/accounts/show/_header.html.erb | 14 +- app/views/accounts/sparkline.html.erb | 16 +-- app/views/holdings/_cash.html.erb | 16 +-- app/views/holdings/_holding.html.erb | 36 ++--- .../pages/dashboard/_balance_sheet.html.erb | 102 +++++-------- .../pages/dashboard/_net_worth_chart.html.erb | 48 +++---- app/views/shared/_sync_indicator.html.erb | 5 + ...610181219_add_sync_timestamps_to_family.rb | 6 + db/schema.rb | 4 +- .../accountable_sparklines_controller_test.rb | 9 -- test/controllers/accounts_controller_test.rb | 9 -- test/models/balance_sheet_test.rb | 16 +-- 35 files changed, 529 insertions(+), 466 deletions(-) create mode 100644 app/models/balance_sheet/account_group.rb create mode 100644 app/models/balance_sheet/account_totals.rb create mode 100644 app/models/balance_sheet/classification_group.rb create mode 100644 app/models/balance_sheet/net_worth_series_builder.rb create mode 100644 app/models/balance_sheet/sync_status_monitor.rb delete mode 100644 app/views/accountable_sparklines/_error.html.erb delete mode 100644 app/views/accounts/_sparkline_error.html.erb create mode 100644 app/views/shared/_sync_indicator.html.erb create mode 100644 db/migrate/20250610181219_add_sync_timestamps_to_family.rb diff --git a/app/controllers/accountable_sparklines_controller.rb b/app/controllers/accountable_sparklines_controller.rb index 38b80cca..959717ec 100644 --- a/app/controllers/accountable_sparklines_controller.rb +++ b/app/controllers/accountable_sparklines_controller.rb @@ -2,25 +2,24 @@ class AccountableSparklinesController < ApplicationController def show @accountable = Accountable.from_type(params[:accountable_type]&.classify) - # Pre-load the series to catch any errors before rendering - @series = Rails.cache.fetch(cache_key) do - account_ids = family.accounts.active.where(accountable_type: @accountable.name).pluck(:id) + etag_key = cache_key - builder = Balance::ChartSeriesBuilder.new( - account_ids: account_ids, - currency: family.currency, - period: Period.last_30_days, - favorable_direction: @accountable.favorable_direction, - interval: "1 day" - ) + # Use HTTP conditional GET so the client receives 304 Not Modified when possible. + if stale?(etag: etag_key, last_modified: family.latest_sync_completed_at) + @series = Rails.cache.fetch(etag_key, expires_in: 24.hours) do + builder = Balance::ChartSeriesBuilder.new( + account_ids: account_ids, + currency: family.currency, + period: Period.last_30_days, + favorable_direction: @accountable.favorable_direction, + interval: "1 day" + ) - builder.balance_series + builder.balance_series + end + + render layout: false end - - render layout: false - rescue => e - Rails.logger.error "Accountable sparkline error for #{@accountable&.name}: #{e.message}" - render partial: "accountable_sparklines/error", layout: false end private @@ -28,7 +27,15 @@ class AccountableSparklinesController < ApplicationController Current.family end + def accountable + Accountable.from_type(params[:accountable_type]&.classify) + end + + def account_ids + family.accounts.active.where(accountable_type: accountable.name).pluck(:id) + end + def cache_key - family.build_cache_key("#{@accountable.name}_sparkline") + family.build_cache_key("#{@accountable.name}_sparkline", invalidate_on_data_updates: true) end end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 2477be98..854bd826 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -23,12 +23,14 @@ class AccountsController < ApplicationController end def sparkline - # Pre-load the sparkline series to catch any errors before rendering - @sparkline_series = @account.sparkline_series - render layout: false - rescue => e - Rails.logger.error "Sparkline error for account #{@account.id}: #{e.message}" - render partial: "accounts/sparkline_error", layout: false + etag_key = @account.family.build_cache_key("#{@account.id}_sparkline", invalidate_on_data_updates: true) + + # Short-circuit with 304 Not Modified when the client already has the latest version. + # We defer the expensive series computation until we know the content is stale. + if stale?(etag: etag_key, last_modified: @account.family.latest_sync_completed_at) + @sparkline_series = @account.sparkline_series + render layout: false + end end private diff --git a/app/models/account.rb b/app/models/account.rb index 4984fb89..b1e2c80c 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -61,18 +61,6 @@ 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 = plaid_account&.plaid_item&.institution_url return nil unless url_string.present? diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index 304fcbbd..b4f2645e 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -24,20 +24,10 @@ module Account::Chartable end def sparkline_series - cache_key = family.build_cache_key("#{id}_sparkline") + cache_key = family.build_cache_key("#{id}_sparkline", invalidate_on_data_updates: true) - Rails.cache.fetch(cache_key) do + Rails.cache.fetch(cache_key, expires_in: 24.hours) do balance_series end - rescue => e - Rails.logger.error "Sparkline series error for account #{id}: #{e.message}" - # Return empty series as fallback - Series.new( - start_date: 30.days.ago.to_date, - end_date: Date.current, - interval: "1 day", - values: [], - favorable_direction: favorable_direction - ) end end diff --git a/app/models/account/sync_complete_event.rb b/app/models/account/sync_complete_event.rb index 32315375..d26b62f8 100644 --- a/app/models/account/sync_complete_event.rb +++ b/app/models/account/sync_complete_event.rb @@ -16,13 +16,13 @@ class Account::SyncCompleteEvent locals: { account: account } ) - # Replace the groups this account belongs to in the sidebar - account_group_ids.each do |id| + # Replace the groups this account belongs to in both desktop and mobile sidebars + sidebar_targets.each do |(tab, mobile_flag)| account.broadcast_replace_to( account.family, - target: id, + target: account_group.dom_id(tab: tab, mobile: mobile_flag), partial: "accounts/accountable_group", - locals: { account_group: account_group, open: true } + locals: { account_group: account_group, open: true, all_tab: tab == :all, mobile: mobile_flag } ) end @@ -37,18 +37,18 @@ class Account::SyncCompleteEvent 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 + # Returns an array of [tab, mobile?] tuples that should receive an update. + # We broadcast to both the classification-specific tab and the "all" tab, + # for desktop (mobile: false) and mobile (mobile: true) variants. + def sidebar_targets + return [] unless account_group.present? - id = account_group.id - [ id, "#{account_group.classification}_#{id}" ] + [ + [ account_group.classification.to_sym, false ], + [ :all, false ], + [ account_group.classification.to_sym, true ], + [ :all, true ] + ] end def account_group diff --git a/app/models/assistant/function/get_balance_sheet.rb b/app/models/assistant/function/get_balance_sheet.rb index ea2b423e..5bea4c6d 100644 --- a/app/models/assistant/function/get_balance_sheet.rb +++ b/app/models/assistant/function/get_balance_sheet.rb @@ -31,11 +31,11 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function monthly_history: historical_data(period) }, assets: { - current: family.balance_sheet.total_assets_money.format, + current: family.balance_sheet.assets.total_money.format, monthly_history: historical_data(period, classification: "asset") }, liabilities: { - current: family.balance_sheet.total_liabilities_money.format, + current: family.balance_sheet.liabilities.total_money.format, monthly_history: historical_data(period, classification: "liability") }, insights: insights_data @@ -65,8 +65,8 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function end def insights_data - assets = family.balance_sheet.total_assets - liabilities = family.balance_sheet.total_liabilities + assets = family.balance_sheet.assets.total + liabilities = family.balance_sheet.liabilities.total ratio = liabilities.zero? ? 0 : (liabilities / assets.to_f) { diff --git a/app/models/balance_sheet.rb b/app/models/balance_sheet.rb index b5dc335d..bc5aaf15 100644 --- a/app/models/balance_sheet.rb +++ b/app/models/balance_sheet.rb @@ -1,7 +1,7 @@ class BalanceSheet include Monetizable - monetize :total_assets, :total_liabilities, :net_worth + monetize :net_worth attr_reader :family @@ -9,99 +9,36 @@ class BalanceSheet @family = family end - def total_assets - totals_query.filter { |t| t.classification == "asset" }.sum(&:converted_balance) + def assets + @assets ||= ClassificationGroup.new( + classification: "asset", + currency: family.currency, + accounts: account_totals.asset_accounts + ) end - def total_liabilities - totals_query.filter { |t| t.classification == "liability" }.sum(&:converted_balance) - end - - def net_worth - total_assets - total_liabilities + def liabilities + @liabilities ||= ClassificationGroup.new( + classification: "liability", + currency: family.currency, + accounts: account_totals.liability_accounts + ) end def classification_groups - Rails.cache.fetch(family.build_cache_key("bs_classification_groups")) do - 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: asset_groups, - syncing?: asset_groups.any?(&:syncing?) - ), - ClassificationGroup.new( - key: "liability", - display_name: "Debts", - icon: "minus", - total_money: total_liabilities_money, - account_groups: liability_groups, - syncing?: liability_groups.any?(&:syncing?) - ) - ] - end + [ assets, liabilities ] end - def account_groups(classification = nil) - Rails.cache.fetch(family.build_cache_key("bs_account_groups_#{classification || 'all'}")) do - classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query - classification_total = classification_accounts.sum(&:converted_balance) + def account_groups + [ assets.account_groups, liabilities.account_groups ].flatten + end - account_groups = classification_accounts.group_by(&:accountable_type) - .transform_keys { |k| Accountable.from_type(k) } - - groups = account_groups.map do |accountable, accounts| - group_total = accounts.sum(&:converted_balance) - - key = accountable.model_name.param_key - - group = AccountGroup.new( - id: classification ? "#{classification}_#{key}_group" : "#{key}_group", - key: key, - name: accountable.display_name, - classification: accountable.classification, - total: group_total, - total_money: Money.new(group_total, currency), - 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 - end.sort_by(&:converted_balance).reverse - ) - - group - 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 + assets.total - liabilities.total end def net_worth_series(period: Period.last_30_days) - memo_key = [ period.start_date, period.end_date ].compact.join("_") - - @net_worth_series ||= {} - - account_ids = active_accounts.pluck(:id) - - builder = (@net_worth_series[memo_key] ||= Balance::ChartSeriesBuilder.new( - account_ids: account_ids, - currency: currency, - period: period, - favorable_direction: "up" - )) - - builder.balance_series + net_worth_series_builder.net_worth_series(period: period) end def currency @@ -109,32 +46,19 @@ class BalanceSheet end def syncing? - classification_groups.any? { |group| group.syncing? } + sync_status_monitor.syncing? end private - 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 + def sync_status_monitor + @sync_status_monitor ||= SyncStatusMonitor.new(family) end - 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) - .to_a + def account_totals + @account_totals ||= AccountTotals.new(family, sync_status_monitor: sync_status_monitor) + end + + def net_worth_series_builder + @net_worth_series_builder ||= NetWorthSeriesBuilder.new(family) end end diff --git a/app/models/balance_sheet/account_group.rb b/app/models/balance_sheet/account_group.rb new file mode 100644 index 00000000..a4b23d1f --- /dev/null +++ b/app/models/balance_sheet/account_group.rb @@ -0,0 +1,61 @@ +class BalanceSheet::AccountGroup + include Monetizable + + monetize :total, as: :total_money + + attr_reader :name, :color, :accountable_type, :accounts + + def initialize(name:, color:, accountable_type:, accounts:, classification_group:) + @name = name + @color = color + @accountable_type = accountable_type + @accounts = accounts + @classification_group = classification_group + end + + # A stable DOM id for this group. + # Example outputs: + # dom_id(tab: :asset) # => "asset_depository" + # dom_id(tab: :all, mobile: true) # => "mobile_all_depository" + # + # Keeping all of the logic here means the view layer and broadcaster only + # need to ask the object for its DOM id instead of rebuilding string + # fragments in multiple places. + def dom_id(tab: nil, mobile: false) + parts = [] + parts << "mobile" if mobile + parts << (tab ? tab.to_s : classification.to_s) + parts << key + parts.compact.join("_") + end + + def key + accountable_type.to_s.underscore + end + + def total + accounts.sum(&:converted_balance) + end + + def weight + return 0 if classification_group.total.zero? + + total / classification_group.total.to_d * 100 + end + + def syncing? + accounts.any?(&:syncing?) + end + + # "asset" or "liability" + def classification + classification_group.classification + end + + def currency + classification_group.currency + end + + private + attr_reader :classification_group +end diff --git a/app/models/balance_sheet/account_totals.rb b/app/models/balance_sheet/account_totals.rb new file mode 100644 index 00000000..ee3f1718 --- /dev/null +++ b/app/models/balance_sheet/account_totals.rb @@ -0,0 +1,63 @@ +class BalanceSheet::AccountTotals + def initialize(family, sync_status_monitor:) + @family = family + @sync_status_monitor = sync_status_monitor + end + + def asset_accounts + @asset_accounts ||= account_rows.filter { |t| t.classification == "asset" } + end + + def liability_accounts + @liability_accounts ||= account_rows.filter { |t| t.classification == "liability" } + end + + private + attr_reader :family, :sync_status_monitor + + AccountRow = Data.define(:account, :converted_balance, :is_syncing) do + def syncing? = is_syncing + + # Allows Rails path helpers to generate URLs from the wrapper + def to_param = account.to_param + delegate_missing_to :account + end + + def active_accounts + @active_accounts ||= family.accounts.active.with_attached_logo + end + + def account_rows + @account_rows ||= query.map do |account_row| + AccountRow.new( + account: account_row, + converted_balance: account_row.converted_balance, + is_syncing: sync_status_monitor.account_syncing?(account_row) + ) + end + end + + def cache_key + family.build_cache_key( + "balance_sheet_account_rows", + invalidate_on_data_updates: true + ) + end + + def query + @query ||= Rails.cache.fetch(cache_key) do + active_accounts + .joins(ActiveRecord::Base.sanitize_sql_array([ + "LEFT JOIN exchange_rates ON exchange_rates.date = ? AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?", + Date.current, + family.currency + ])) + .select( + "accounts.*", + "SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance" + ) + .group(:classification, :accountable_type, :id) + .to_a + end + end +end diff --git a/app/models/balance_sheet/classification_group.rb b/app/models/balance_sheet/classification_group.rb new file mode 100644 index 00000000..a6d82bb3 --- /dev/null +++ b/app/models/balance_sheet/classification_group.rb @@ -0,0 +1,61 @@ +class BalanceSheet::ClassificationGroup + include Monetizable + + monetize :total, as: :total_money + + attr_reader :classification, :currency + + def initialize(classification:, currency:, accounts:) + @classification = normalize_classification!(classification) + @name = name + @currency = currency + @accounts = accounts + end + + def name + classification.titleize.pluralize + end + + def icon + classification == "asset" ? "plus" : "minus" + end + + def total + accounts.sum(&:converted_balance) + end + + def syncing? + accounts.any?(&:syncing?) + end + + # For now, we group by accountable type. This can be extended in the future to support arbitrary user groupings. + def account_groups + groups = accounts.group_by(&:accountable_type) + .transform_keys { |at| Accountable.from_type(at) } + .map do |accountable, account_rows| + BalanceSheet::AccountGroup.new( + name: accountable.display_name, + color: accountable.color, + accountable_type: accountable, + accounts: account_rows, + classification_group: self + ) + end + + # Sort the groups using the manual order defined by Accountable::TYPES so that + # the UI displays account groups in a predictable, domain-specific sequence. + groups.sort_by do |group| + manual_order = Accountable::TYPES + type_name = group.key.camelize + manual_order.index(type_name) || Float::INFINITY + end + end + + private + attr_reader :accounts + + def normalize_classification!(classification) + raise ArgumentError, "Invalid classification: #{classification}" unless %w[asset liability].include?(classification) + classification + end +end diff --git a/app/models/balance_sheet/net_worth_series_builder.rb b/app/models/balance_sheet/net_worth_series_builder.rb new file mode 100644 index 00000000..c4c79971 --- /dev/null +++ b/app/models/balance_sheet/net_worth_series_builder.rb @@ -0,0 +1,38 @@ +class BalanceSheet::NetWorthSeriesBuilder + def initialize(family) + @family = family + end + + def net_worth_series(period: Period.last_30_days) + Rails.cache.fetch(cache_key(period)) do + builder = Balance::ChartSeriesBuilder.new( + account_ids: active_account_ids, + currency: family.currency, + period: period, + favorable_direction: "up" + ) + + builder.balance_series + end + end + + private + attr_reader :family + + def active_account_ids + @active_account_ids ||= family.accounts.active.with_attached_logo.pluck(:id) + end + + def cache_key(period) + key = [ + "balance_sheet_net_worth_series", + period.start_date, + period.end_date + ].compact.join("_") + + family.build_cache_key( + key, + invalidate_on_data_updates: true + ) + end +end diff --git a/app/models/balance_sheet/sync_status_monitor.rb b/app/models/balance_sheet/sync_status_monitor.rb new file mode 100644 index 00000000..5682bd63 --- /dev/null +++ b/app/models/balance_sheet/sync_status_monitor.rb @@ -0,0 +1,35 @@ +class BalanceSheet::SyncStatusMonitor + def initialize(family) + @family = family + end + + def syncing? + syncing_account_ids.any? + end + + def account_syncing?(account) + syncing_account_ids.include?(account.id) + end + + private + attr_reader :family + + def syncing_account_ids + Rails.cache.fetch(cache_key) do + Sync.visible + .where(syncable_type: "Account", syncable_id: family.accounts.active.pluck(:id)) + .pluck(:syncable_id) + .to_set + end + end + + # We re-fetch the set of syncing IDs any time a sync that belongs to the family is started or completed. + # This ensures we're always fetching the latest sync statuses without re-querying on every page load in idle times (no syncs happening). + def cache_key + [ + "balance_sheet_sync_status", + family.id, + family.latest_sync_activity_at + ].join("_") + end +end diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb index 72556bf7..739d5381 100644 --- a/app/models/concerns/syncable.rb +++ b/app/models/concerns/syncable.rb @@ -6,7 +6,7 @@ module Syncable end def syncing? - raise NotImplementedError, "Subclasses must implement the syncing? method" + syncs.visible.any? end # Schedules a sync for syncable. If there is an existing sync pending/syncing for this syncable, diff --git a/app/models/family.rb b/app/models/family.rb index a4b2c8b1..20ad02a4 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -35,25 +35,6 @@ 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? - # Check for any in-progress syncs that belong directly to the family, to one of the - # family's accounts, or to one of the family's Plaid items. By moving the `visible` - # scope to the beginning we narrow down the candidate rows **before** performing the - # joins and by explicitly constraining the `syncable_type` for the direct Family - # match we allow Postgres to use the composite index on `(syncable_type, syncable_id)`. - Sync.visible - .joins("LEFT JOIN accounts ON accounts.id = syncs.syncable_id AND syncs.syncable_type = 'Account'") - .joins("LEFT JOIN plaid_items ON plaid_items.id = syncs.syncable_id AND syncs.syncable_type = 'PlaidItem'") - .where( - "(syncs.syncable_type = 'Family' AND syncs.syncable_id = :family_id) OR " \ - "accounts.family_id = :family_id OR " \ - "plaid_items.family_id = :family_id", - family_id: id - ) - .exists? - end - def assigned_merchants merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq Merchant.where(id: merchant_ids) @@ -110,13 +91,15 @@ class Family < ApplicationRecord entries.order(:date).first&.date || Date.current end - # Cache key that is invalidated when any of the family's entries are updated (which affect rollups and other calculations) - def build_cache_key(key) + def build_cache_key(key, invalidate_on_data_updates: false) + # Our data sync process updates this timestamp whenever any family account successfully completes a data update. + # By including it in the cache key, we can expire caches every time family account data changes. + data_invalidation_key = invalidate_on_data_updates ? latest_sync_completed_at : nil + [ - "family", id, key, - entries.maximum(:updated_at) + data_invalidation_key ].compact.join("_") end diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 25aa3e18..19970ce4 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -46,14 +46,6 @@ 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 import_latest_plaid_data PlaidItem::Importer.new(self, plaid_provider: plaid_provider).import end diff --git a/app/models/sync.rb b/app/models/sync.rb index 37c05dfa..7baf9e63 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -29,13 +29,13 @@ class Sync < ApplicationRecord state :failed state :stale - after_all_transitions :log_status_change + after_all_transitions :handle_transition - event :start, after_commit: :report_warnings do + event :start, after_commit: :handle_start_transition do transitions from: :pending, to: :syncing end - event :complete do + event :complete, after_commit: :handle_completion_transition do transitions from: :syncing, to: :completed end @@ -163,9 +163,30 @@ class Sync < ApplicationRecord end end + def handle_start_transition + report_warnings + end + + def handle_transition + log_status_change + family.touch(:latest_sync_activity_at) + end + + def handle_completion_transition + family.touch(:latest_sync_completed_at) + 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 + + def family + if syncable.is_a?(Family) + syncable + else + syncable.family + end + end end diff --git a/app/views/accountable_sparklines/_error.html.erb b/app/views/accountable_sparklines/_error.html.erb deleted file mode 100644 index f43f609f..00000000 --- a/app/views/accountable_sparklines/_error.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<%= turbo_frame_tag "#{params[:accountable_type]}_sparkline" do %> -
-
- <%= icon("alert-triangle", size: "sm", class: "text-warning") %> -
-

Error

-
-<% end %> diff --git a/app/views/accountable_sparklines/show.html.erb b/app/views/accountable_sparklines/show.html.erb index 236c3616..c090551a 100644 --- a/app/views/accountable_sparklines/show.html.erb +++ b/app/views/accountable_sparklines/show.html.erb @@ -1,13 +1,11 @@ -<% cache Current.family.build_cache_key("#{@accountable.name}_sparkline_html") do %> - <%= turbo_frame_tag "#{@accountable.model_name.param_key}_sparkline" do %> -
-
- <%= render "shared/sparkline", id: dom_id(@accountable, :sparkline_chart), series: @series %> -
+<%= turbo_frame_tag "#{@accountable.model_name.param_key}_sparkline" do %> +
+
+ <%= render "shared/sparkline", id: dom_id(@accountable, :sparkline_chart), series: @series %> +
- <%= tag.p @series.trend.percent_formatted, + <%= tag.p @series.trend.percent_formatted, style: "color: #{@series.trend.color}", class: "font-mono text-right text-xs font-medium text-primary" %> -
- <% end %> +
<% end %> diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb index 056e7be1..182bdd6e 100644 --- a/app/views/accounts/_account_sidebar_tabs.html.erb +++ b/app/views/accounts/_account_sidebar_tabs.html.erb @@ -41,7 +41,7 @@ ) %>
- <% family.balance_sheet.account_groups("asset").each do |group| %> + <% family.balance_sheet.assets.account_groups.each do |group| %> <%= render "accounts/accountable_group", account_group: group, mobile: mobile %> <% end %>
@@ -61,7 +61,7 @@ ) %>
- <% family.balance_sheet.account_groups("liability").each do |group| %> + <% family.balance_sheet.liabilities.account_groups.each do |group| %> <%= render "accounts/accountable_group", account_group: group, mobile: mobile %> <% end %>
@@ -82,7 +82,7 @@
<% family.balance_sheet.account_groups.each do |group| %> - <%= render "accounts/accountable_group", account_group: group, mobile: mobile %> + <%= render "accounts/accountable_group", account_group: group, mobile: mobile, all_tab: true %> <% end %>
diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index b4495afc..26b69be3 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -1,24 +1,21 @@ -<%# locals: (account_group:, mobile: false, open: nil, **args) %> +<%# locals: (account_group:, mobile: false, all_tab: false, open: nil, **args) %> -
"> +
<% 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? %> +
+ <%= render partial: "shared/sync_indicator", locals: { size: "xs" } %> +
+ <% end %> +
- <% if account_group.syncing? %> -
-
-
-
-
+ <%= 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", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %> +
+
- <% 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", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %> -
-
-
- <% end %> <% end %>
<% end %> @@ -34,29 +31,23 @@ <%= 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.name, class: "text-sm text-primary font-medium truncate" %> + <% if account.syncing? %> + <%= render partial: "shared/sync_indicator", locals: { size: "xs" } %> + <% end %> +
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
- <% if account.syncing? %> -
-
-
-
-
-
+
+ <%= 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", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %> +
+
-
- <% 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", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %> -
-
-
- <% end %> -
- <% end %> + <% end %> +
<% end %> <% end %>
diff --git a/app/views/accounts/_sparkline_error.html.erb b/app/views/accounts/_sparkline_error.html.erb deleted file mode 100644 index dbee8dbf..00000000 --- a/app/views/accounts/_sparkline_error.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<%= turbo_frame_tag dom_id(@account, :sparkline) do %> -
-
- <%= icon("alert-triangle", size: "sm", class: "text-warning") %> -
-

Error

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

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

+
+ <% end %> +
<% end %> diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index bf23efd2..00506be2 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -9,18 +9,14 @@
<%= tag.p account.investment? ? "Total value" : default_value_title, class: "text-sm font-medium text-secondary" %> - <% if !account.syncing? && account.investment? %> + <% if account.investment? %> <%= render "investments/value_tooltip", balance: account.balance_money, holdings: account.balance_money - account.cash_balance_money, cash: account.cash_balance_money %> <% end %>
- <% if account.syncing? %> -
- <% else %> - <%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %> - <% if account.currency != Current.family.currency %> - <%= tag.p format_money(account.balance_money.exchange_to(Current.family.currency, fallback_rate: 1)), class: "text-sm font-medium text-secondary" %> - <% end %> + <%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %> + <% if account.currency != Current.family.currency %> + <%= tag.p format_money(account.balance_money.exchange_to(Current.family.currency, fallback_rate: 1)), class: "text-sm font-medium text-secondary" %> <% end %>
diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index 35dcc2ed..283b4e05 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -10,10 +10,16 @@
<%= render "accounts/logo", account: account %> -
-

<%= title || account.name %>

- <% if subtitle.present? %> -

<%= subtitle %>

+
+
+

<%= title || account.name %>

+ <% if subtitle.present? %> +

<%= subtitle %>

+ <% end %> +
+ + <% if account.syncing? %> + <%= render partial: "shared/sync_indicator", locals: { size: "sm" } %> <% end %>
diff --git a/app/views/accounts/sparkline.html.erb b/app/views/accounts/sparkline.html.erb index 5eb8aa04..0a07c653 100644 --- a/app/views/accounts/sparkline.html.erb +++ b/app/views/accounts/sparkline.html.erb @@ -1,13 +1,11 @@ -<% cache Current.family.build_cache_key("account_#{@account.id}_sparkline_html") do %> - <%= turbo_frame_tag dom_id(@account, :sparkline) do %> -
-
- <%= render "shared/sparkline", id: dom_id(@account, :sparkline_chart), series: @sparkline_series %> -
+<%= turbo_frame_tag dom_id(@account, :sparkline) do %> +
+
+ <%= render "shared/sparkline", id: dom_id(@account, :sparkline_chart), series: @sparkline_series %> +
- <%= tag.p @sparkline_series.trend.percent_formatted, + <%= tag.p @sparkline_series.trend.percent_formatted, style: "color: #{@sparkline_series.trend.color}", class: "font-mono text-right text-xs font-medium text-primary" %> -
- <% end %> +
<% end %> diff --git a/app/views/holdings/_cash.html.erb b/app/views/holdings/_cash.html.erb index 702f40d0..98ce1d3a 100644 --- a/app/views/holdings/_cash.html.erb +++ b/app/views/holdings/_cash.html.erb @@ -20,12 +20,8 @@
<% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %> - <% if account.syncing? %> -
- <% else %> - <%= render "shared/progress_circle", progress: cash_weight %> - <%= tag.p number_to_percentage(cash_weight, precision: 1) %> - <% end %> + <%= render "shared/progress_circle", progress: cash_weight %> + <%= tag.p number_to_percentage(cash_weight, precision: 1) %>
@@ -33,13 +29,7 @@
- <% if account.syncing? %> -
-
-
- <% else %> - <%= tag.p format_money account.cash_balance_money %> - <% end %> + <%= tag.p format_money account.cash_balance_money %>
diff --git a/app/views/holdings/_holding.html.erb b/app/views/holdings/_holding.html.erb index c8a2ac59..5fe0e4c9 100644 --- a/app/views/holdings/_holding.html.erb +++ b/app/views/holdings/_holding.html.erb @@ -17,9 +17,7 @@
- <% if holding.account.syncing? %> -
- <% elsif holding.weight %> + <% if holding.weight %> <%= render "shared/progress_circle", progress: holding.weight %> <%= tag.p number_to_percentage(holding.weight, precision: 1) %> <% else %> @@ -28,39 +26,21 @@
- <% if holding.account.syncing? %> -
-
-
- <% else %> - <%= tag.p format_money holding.avg_cost %> - <%= tag.p t(".per_share"), class: "font-normal text-secondary" %> - <% end %> + <%= tag.p format_money holding.avg_cost %> + <%= tag.p t(".per_share"), class: "font-normal text-secondary" %>
- <% if holding.account.syncing? %> -
-
-
-
+ <% if holding.amount_money %> + <%= tag.p format_money holding.amount_money %> <% 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" %> + <%= tag.p "--", class: "text-secondary" %> <% end %> + <%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-secondary" %>
- <% if holding.account.syncing? %> -
-
-
-
- <% elsif holding.trend %> + <% if 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/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index 62a5077a..b0687042 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -3,26 +3,24 @@
<% balance_sheet.classification_groups.each do |classification_group| %>
-

- - <%= classification_group.display_name %> - +
+

+ + <%= classification_group.name %> + - <% if classification_group.account_groups.any? %> - · - - <% if classification_group.syncing? %> -
-
-
- <% else %> + <% if classification_group.account_groups.any? %> + · <%= classification_group.total_money.format(precision: 0) %> <% end %> +

+ + <% if classification_group.syncing? %> + <%= render partial: "shared/sync_indicator", locals: { size: "sm" } %> <% end %> -

+
<% if classification_group.account_groups.any? %> -
<% classification_group.account_groups.each do |account_group| %> @@ -30,19 +28,15 @@ <% 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 %> +
+ <% classification_group.account_groups.each do |account_group| %> +
+
+

<%= account_group.name %>

+

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

+
+ <% end %> +
@@ -71,27 +65,15 @@

<%= account_group.name %>

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

<%= format_money(account_group.total_money) %>

-
+
+

<%= format_money(account_group.total_money) %>

- <% end %> +
@@ -103,32 +85,20 @@ <%= link_to account.name, account_path(account) %>
- <% if account.syncing? %> -
-
-
-
- -
-
-
-
- <% else %> -
-
- <% +
+
+ <% # Calculate weight as percentage of classification total classification_total = classification_group.total_money.amount account_weight = classification_total.zero? ? 0 : account.converted_balance / classification_total * 100 %> - <%= render "pages/dashboard/group_weight", weight: account_weight, color: account_group.color %> -
- -
-

<%= format_money(account.balance_money) %>

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

<%= format_money(account.balance_money) %>

+
+
<% 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 d526247b..fabc0267 100644 --- a/app/views/pages/dashboard/_net_worth_chart.html.erb +++ b/app/views/pages/dashboard/_net_worth_chart.html.erb @@ -5,23 +5,22 @@
-

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

+
+

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

- <% if balance_sheet.syncing? %> -
-
-
-
- <% else %> -

- <%= series.trend.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 } %> + <% if balance_sheet.syncing? %> + <%= render partial: "shared/sync_indicator", locals: { size: "sm" } %> <% end %> +
+ +

+ <%= series.trend.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 %>
@@ -35,21 +34,16 @@ <% end %>
- <% if balance_sheet.syncing? %> -
-
-
- <% else %> - <% if series.any? %> -
+
- <% else %> -
-

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

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

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

+
<% end %> +
diff --git a/app/views/shared/_sync_indicator.html.erb b/app/views/shared/_sync_indicator.html.erb new file mode 100644 index 00000000..2ef56bf5 --- /dev/null +++ b/app/views/shared/_sync_indicator.html.erb @@ -0,0 +1,5 @@ +<%# locals: (size: "md") %> + +
+ <%= icon "loader-circle", color: "current", size: size %> +
diff --git a/db/migrate/20250610181219_add_sync_timestamps_to_family.rb b/db/migrate/20250610181219_add_sync_timestamps_to_family.rb new file mode 100644 index 00000000..8d5f7cab --- /dev/null +++ b/db/migrate/20250610181219_add_sync_timestamps_to_family.rb @@ -0,0 +1,6 @@ +class AddSyncTimestampsToFamily < ActiveRecord::Migration[7.2] + def change + add_column :families, :latest_sync_activity_at, :datetime, default: -> { "CURRENT_TIMESTAMP" } + add_column :families, :latest_sync_completed_at, :datetime, default: -> { "CURRENT_TIMESTAMP" } + end +end diff --git a/db/schema.rb b/db/schema.rb index c38c5375..9c10112c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_06_05_031616) do +ActiveRecord::Schema[7.2].define(version: 2025_06_10_181219) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -228,6 +228,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_05_031616) do t.boolean "data_enrichment_enabled", default: false t.boolean "early_access", default: false t.boolean "auto_sync_on_login", default: true, null: false + t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" } + t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } end create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/test/controllers/accountable_sparklines_controller_test.rb b/test/controllers/accountable_sparklines_controller_test.rb index b43c09e0..51ec49f3 100644 --- a/test/controllers/accountable_sparklines_controller_test.rb +++ b/test/controllers/accountable_sparklines_controller_test.rb @@ -9,13 +9,4 @@ class AccountableSparklinesControllerTest < ActionDispatch::IntegrationTest get accountable_sparkline_url("depository") assert_response :success end - - test "should handle sparkline errors gracefully" do - # Mock an error in the balance_series method - Balance::ChartSeriesBuilder.any_instance.stubs(:balance_series).raises(StandardError.new("Test error")) - - get accountable_sparkline_url("depository") - assert_response :success - assert_match /Error/, response.body - end end diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index 6c51c525..ba0b937e 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -25,13 +25,4 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest get sparkline_account_url(@account) assert_response :success end - - test "should handle sparkline errors gracefully" do - # Mock an error in the balance_series method to bypass the rescue in sparkline_series - Balance::ChartSeriesBuilder.any_instance.stubs(:balance_series).raises(StandardError.new("Test error")) - - get sparkline_account_url(@account) - assert_response :success - assert_match /Error/, response.body -end end diff --git a/test/models/balance_sheet_test.rb b/test/models/balance_sheet_test.rb index fb131ea7..c9478979 100644 --- a/test/models/balance_sheet_test.rb +++ b/test/models/balance_sheet_test.rb @@ -6,23 +6,23 @@ class BalanceSheetTest < ActiveSupport::TestCase end test "calculates total assets" do - assert_equal 0, BalanceSheet.new(@family).total_assets + assert_equal 0, BalanceSheet.new(@family).assets.total create_account(balance: 1000, accountable: Depository.new) create_account(balance: 5000, accountable: OtherAsset.new) create_account(balance: 10000, accountable: CreditCard.new) # ignored - assert_equal 1000 + 5000, BalanceSheet.new(@family).total_assets + assert_equal 1000 + 5000, BalanceSheet.new(@family).assets.total end test "calculates total liabilities" do - assert_equal 0, BalanceSheet.new(@family).total_liabilities + assert_equal 0, BalanceSheet.new(@family).liabilities.total create_account(balance: 1000, accountable: CreditCard.new) create_account(balance: 5000, accountable: OtherLiability.new) create_account(balance: 10000, accountable: Depository.new) # ignored - assert_equal 1000 + 5000, BalanceSheet.new(@family).total_liabilities + assert_equal 1000 + 5000, BalanceSheet.new(@family).liabilities.total end test "calculates net worth" do @@ -42,8 +42,8 @@ class BalanceSheetTest < ActiveSupport::TestCase other_liability.update!(is_active: false) assert_equal 10000 - 1000, BalanceSheet.new(@family).net_worth - assert_equal 10000, BalanceSheet.new(@family).total_assets - assert_equal 1000, BalanceSheet.new(@family).total_liabilities + assert_equal 10000, BalanceSheet.new(@family).assets.total + assert_equal 1000, BalanceSheet.new(@family).liabilities.total end test "calculates asset group totals" do @@ -53,7 +53,7 @@ class BalanceSheetTest < ActiveSupport::TestCase create_account(balance: 5000, accountable: OtherAsset.new) create_account(balance: 10000, accountable: CreditCard.new) # ignored - asset_groups = BalanceSheet.new(@family).account_groups("asset") + asset_groups = BalanceSheet.new(@family).assets.account_groups assert_equal 3, asset_groups.size assert_equal 1000 + 2000, asset_groups.find { |ag| ag.name == "Cash" }.total @@ -68,7 +68,7 @@ class BalanceSheetTest < ActiveSupport::TestCase create_account(balance: 5000, accountable: OtherLiability.new) create_account(balance: 10000, accountable: Depository.new) # ignored - liability_groups = BalanceSheet.new(@family).account_groups("liability") + liability_groups = BalanceSheet.new(@family).liabilities.account_groups assert_equal 2, liability_groups.size assert_equal 1000 + 2000, liability_groups.find { |ag| ag.name == "Credit Cards" }.total -- 2.53.0 From 0d62e60da12b56c24ade998da22c3fae17a51185 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 10 Jun 2025 21:30:53 -0400 Subject: [PATCH 14/20] Fix stale reference to classification group name --- app/views/pages/dashboard/_balance_sheet.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index b0687042..6d131782 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -121,8 +121,8 @@ icon: classification_group.icon, ) %> -

No <%= classification_group.display_name %> yet

-

<%= "Add your #{classification_group.display_name} accounts to see a full breakdown" %>

+

No <%= classification_group.name %> yet

+

<%= "Add your #{classification_group.name} accounts to see a full breakdown" %>

<% end %>
-- 2.53.0 From 5a4c955522a85b18db309739be2426853a79981b Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 11 Jun 2025 18:48:39 -0400 Subject: [PATCH 15/20] Realistic demo data for performance testing (#2361) * Realistic demo data for performance testing * Add note about performance testing * Fix bugbot issues * More realistic account values --- .gitignore | 3 + app/models/demo/account_generator.rb | 238 ++++++++ app/models/demo/base_scenario.rb | 30 + app/models/demo/data_cleaner.rb | 31 + app/models/demo/data_helper.rb | 85 +++ app/models/demo/generator.rb | 556 +++--------------- app/models/demo/rule_generator.rb | 79 +++ app/models/demo/scenarios/basic_budget.rb | 129 ++++ app/models/demo/scenarios/clean_slate.rb | 126 ++++ app/models/demo/scenarios/default.rb | 77 +++ app/models/demo/scenarios/multi_currency.rb | 241 ++++++++ .../demo/scenarios/performance_testing.rb | 349 +++++++++++ app/models/demo/security_generator.rb | 76 +++ app/models/demo/transaction_generator.rb | 448 ++++++++++++++ app/models/demo/transfer_generator.rb | 159 +++++ lib/tasks/demo_data.rake | 13 +- 16 files changed, 2166 insertions(+), 474 deletions(-) create mode 100644 app/models/demo/account_generator.rb create mode 100644 app/models/demo/base_scenario.rb create mode 100644 app/models/demo/data_cleaner.rb create mode 100644 app/models/demo/data_helper.rb create mode 100644 app/models/demo/rule_generator.rb create mode 100644 app/models/demo/scenarios/basic_budget.rb create mode 100644 app/models/demo/scenarios/clean_slate.rb create mode 100644 app/models/demo/scenarios/default.rb create mode 100644 app/models/demo/scenarios/multi_currency.rb create mode 100644 app/models/demo/scenarios/performance_testing.rb create mode 100644 app/models/demo/security_generator.rb create mode 100644 app/models/demo/transaction_generator.rb create mode 100644 app/models/demo/transfer_generator.rb diff --git a/.gitignore b/.gitignore index 95c60508..ca3ce84a 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,9 @@ node_modules/ *.roo* # OS specific # Task files +.taskmaster/docs +.taskmaster/config.json +.taskmaster/templates tasks.json tasks/ *.mcp.json diff --git a/app/models/demo/account_generator.rb b/app/models/demo/account_generator.rb new file mode 100644 index 00000000..4516bea0 --- /dev/null +++ b/app/models/demo/account_generator.rb @@ -0,0 +1,238 @@ +class Demo::AccountGenerator + include Demo::DataHelper + + def create_credit_card_accounts!(family, count: 1) + accounts = [] + count.times do |i| + account = family.accounts.create!( + accountable: CreditCard.new, + name: account_name("Chase Credit Card", i, count), + balance: 0, + currency: "USD" + ) + accounts << account + end + accounts + end + + def create_checking_accounts!(family, count: 1) + accounts = [] + count.times do |i| + account = family.accounts.create!( + accountable: Depository.new, + name: account_name("Chase Checking", i, count), + balance: 0, + currency: "USD" + ) + accounts << account + end + accounts + end + + def create_savings_accounts!(family, count: 1) + accounts = [] + count.times do |i| + account = family.accounts.create!( + accountable: Depository.new, + name: account_name("Demo Savings", i, count), + balance: 0, + currency: "USD", + subtype: "savings" + ) + accounts << account + end + accounts + end + + def create_properties_and_mortgages!(family, count: 1) + accounts = [] + count.times do |i| + property = family.accounts.create!( + accountable: Property.new, + name: account_name("123 Maybe Way", i, count), + balance: 0, + currency: "USD" + ) + accounts << property + + mortgage = family.accounts.create!( + accountable: Loan.new, + name: account_name("Mortgage", i, count), + balance: 0, + currency: "USD" + ) + accounts << mortgage + end + accounts + end + + def create_vehicles_and_loans!(family, vehicle_count: 1, loan_count: 1) + accounts = [] + + vehicle_count.times do |i| + vehicle = family.accounts.create!( + accountable: Vehicle.new, + name: account_name("Honda Accord", i, vehicle_count), + balance: 0, + currency: "USD" + ) + accounts << vehicle + end + + loan_count.times do |i| + loan = family.accounts.create!( + accountable: Loan.new, + name: account_name("Car Loan", i, loan_count), + balance: 0, + currency: "USD" + ) + accounts << loan + end + + accounts + end + + def create_other_accounts!(family, asset_count: 1, liability_count: 1) + accounts = [] + + asset_count.times do |i| + asset = family.accounts.create!( + accountable: OtherAsset.new, + name: account_name("Other Asset", i, asset_count), + balance: 0, + currency: "USD" + ) + accounts << asset + end + + liability_count.times do |i| + liability = family.accounts.create!( + accountable: OtherLiability.new, + name: account_name("Other Liability", i, liability_count), + balance: 0, + currency: "USD" + ) + accounts << liability + end + + accounts + end + + def create_investment_accounts!(family, count: 3) + accounts = [] + + if count <= 3 + account_configs = [ + { name: "401(k)", balance: 0 }, + { name: "Roth IRA", balance: 0 }, + { name: "Taxable Brokerage", balance: 0 } + ] + + count.times do |i| + config = account_configs[i] || { + name: "Investment Account #{i + 1}", + balance: 0 + } + + account = family.accounts.create!( + accountable: Investment.new, + name: config[:name], + balance: config[:balance], + currency: "USD" + ) + accounts << account + end + else + count.times do |i| + account = family.accounts.create!( + accountable: Investment.new, + name: "Investment Account #{i + 1}", + balance: 0, + currency: "USD" + ) + accounts << account + end + end + + accounts + end + + private + + def realistic_balance(type, count = 1) + return send("realistic_#{type}_balance") if count == 1 + send("random_#{type}_balance") + end + def realistic_credit_card_balance + 2300 + end + + def realistic_checking_balance + 15000 + end + + def realistic_savings_balance + 40000 + end + + def realistic_property_balance + 560000 + end + + def realistic_mortgage_balance + 495000 + end + + def realistic_vehicle_balance + 18000 + end + + def realistic_car_loan_balance + 8000 + end + + def realistic_other_asset_balance + 10000 + end + + def realistic_other_liability_balance + 5000 + end + + + def random_credit_card_balance + random_positive_amount(1000, 5000) + end + + def random_checking_balance + random_positive_amount(10000, 50000) + end + + def random_savings_balance + random_positive_amount(50000, 200000) + end + + def random_property_balance + random_positive_amount(400000, 800000) + end + + def random_mortgage_balance + random_positive_amount(200000, 600000) + end + + def random_vehicle_balance + random_positive_amount(15000, 50000) + end + + def random_car_loan_balance + random_positive_amount(5000, 25000) + end + + def random_other_asset_balance + random_positive_amount(5000, 50000) + end + + def random_other_liability_balance + random_positive_amount(2000, 20000) + end +end diff --git a/app/models/demo/base_scenario.rb b/app/models/demo/base_scenario.rb new file mode 100644 index 00000000..27d9995a --- /dev/null +++ b/app/models/demo/base_scenario.rb @@ -0,0 +1,30 @@ +# Base class for demo scenario handlers - subclasses must implement generate_family_data! +class Demo::BaseScenario + def initialize(generators) + @generators = generators + end + + def generate!(families, **options) + setup(**options) if respond_to?(:setup, true) + + families.each do |family| + ActiveRecord::Base.transaction do + generate_family_data!(family, **options) + end + puts "#{scenario_name} data created for #{family.name}" + end + end + + private + + def setup(**options) + end + + def generate_family_data!(family, **options) + raise NotImplementedError, "Subclasses must implement generate_family_data!(family, **options)" + end + + def scenario_name + self.class.name.split("::").last.downcase.gsub(/([a-z])([A-Z])/, '\1 \2') + end +end diff --git a/app/models/demo/data_cleaner.rb b/app/models/demo/data_cleaner.rb new file mode 100644 index 00000000..215de352 --- /dev/null +++ b/app/models/demo/data_cleaner.rb @@ -0,0 +1,31 @@ +# SAFETY: Only operates in development/test environments to prevent data loss +class Demo::DataCleaner + SAFE_ENVIRONMENTS = %w[development test] + + def initialize + ensure_safe_environment! + end + + # Main entry point for destroying all demo data + def destroy_everything! + puts "Clearing existing data..." + + # Rails associations handle cascading deletes + Family.destroy_all + Setting.destroy_all + InviteCode.destroy_all + ExchangeRate.destroy_all + Security.destroy_all + Security::Price.destroy_all + + puts "Data cleared" + end + + private + + def ensure_safe_environment! + unless SAFE_ENVIRONMENTS.include?(Rails.env) + raise SecurityError, "Demo::DataCleaner can only be used in #{SAFE_ENVIRONMENTS.join(', ')} environments. Current: #{Rails.env}" + end + end +end diff --git a/app/models/demo/data_helper.rb b/app/models/demo/data_helper.rb new file mode 100644 index 00000000..6a10d76e --- /dev/null +++ b/app/models/demo/data_helper.rb @@ -0,0 +1,85 @@ +module Demo::DataHelper + COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a].freeze + + PERFORMANCE_TRANSACTION_COUNTS = { + depository_sample: 75, + credit_card_sample: 75, + investment_trades: 35, + investment_transactions: 35, + other_account_sample: 20 + }.freeze + + module_function + + def random_date_within_days(max_days_ago) + Faker::Number.between(from: 0, to: max_days_ago).days.ago.to_date + end + + def random_amount(min, max) + Faker::Number.between(from: min, to: max) + end + + def random_positive_amount(min, max) + Faker::Number.positive(from: min, to: max) + end + + def group_accounts_by_type(family) + accounts = family.accounts.includes(:accountable) + + { + checking: filter_checking_accounts(accounts), + savings: filter_savings_accounts(accounts), + credit_cards: filter_credit_card_accounts(accounts), + investments: filter_investment_accounts(accounts), + loans: filter_loan_accounts(accounts), + properties: filter_property_accounts(accounts), + vehicles: filter_vehicle_accounts(accounts), + other_assets: filter_other_asset_accounts(accounts), + other_liabilities: filter_other_liability_accounts(accounts) + } + end + + def filter_checking_accounts(accounts) + accounts.select { |a| a.accountable_type == "Depository" && (a.subtype != "savings" || a.name.include?("Checking")) } + end + + def filter_savings_accounts(accounts) + accounts.select { |a| a.accountable_type == "Depository" && (a.subtype == "savings" || a.name.include?("Savings")) } + end + + def filter_credit_card_accounts(accounts) + accounts.select { |a| a.accountable_type == "CreditCard" } + end + + def filter_investment_accounts(accounts) + accounts.select { |a| a.accountable_type == "Investment" } + end + + def filter_loan_accounts(accounts) + accounts.select { |a| a.accountable_type == "Loan" } + end + + def filter_property_accounts(accounts) + accounts.select { |a| a.accountable_type == "Property" } + end + + def filter_vehicle_accounts(accounts) + accounts.select { |a| a.accountable_type == "Vehicle" } + end + + def filter_other_asset_accounts(accounts) + accounts.select { |a| a.accountable_type == "OtherAsset" } + end + + def filter_other_liability_accounts(accounts) + accounts.select { |a| a.accountable_type == "OtherLiability" } + end + + def random_color + COLORS.sample + end + + def account_name(base_name, index, count = 1) + count == 1 ? base_name : "#{base_name} #{index + 1}" + end +end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index bbcc292c..369b7d95 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -1,155 +1,102 @@ class Demo::Generator - COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] + include Demo::DataHelper - # Builds a semi-realistic mirror of what production data might look like + # Public API - these methods are called by rake tasks and must be preserved def reset_and_clear_data!(family_names, require_onboarding: false) - puts "Clearing existing data..." - - destroy_everything! - - puts "Data cleared" - - family_names.each_with_index do |family_name, index| - create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", require_onboarding: require_onboarding) - end - - puts "Users reset" + generate_for_scenario(:clean_slate, family_names, require_onboarding: require_onboarding) end def reset_data!(family_names) - puts "Clearing existing data..." + generate_for_scenario(:default, family_names) + end - destroy_everything! - - puts "Data cleared" - - family_names.each_with_index do |family_name, index| - create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local") - end - - puts "Users reset" - - load_securities! - - puts "Securities loaded" - - family_names.each do |family_name| - family = Family.find_by(name: family_name) - - ActiveRecord::Base.transaction do - create_tags!(family) - create_categories!(family) - create_merchants!(family) - create_rules!(family) - puts "tags, categories, merchants created for #{family_name}" - - create_credit_card_account!(family) - create_checking_account!(family) - create_savings_account!(family) - - create_investment_account!(family) - create_house_and_mortgage!(family) - create_car_and_loan!(family) - create_other_accounts!(family) - - create_transfer_transactions!(family) - end - - puts "accounts created for #{family_name}" - end - - puts "Demo data loaded successfully!" + def generate_performance_testing_data!(family_names) + generate_for_scenario(:performance_testing, family_names) end def generate_basic_budget_data!(family_names) - puts "Clearing existing data..." - - destroy_everything! - - puts "Data cleared" - - family_names.each_with_index do |family_name, index| - create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local") - end - - puts "Users reset" - - family_names.each do |family_name| - family = Family.find_by(name: family_name) - - ActiveRecord::Base.transaction do - # Create parent categories - food = family.categories.create!(name: "Food & Drink", color: COLORS.sample, classification: "expense") - transport = family.categories.create!(name: "Transportation", color: COLORS.sample, classification: "expense") - - # Create subcategory - restaurants = family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense") - - # Create checking account - checking = family.accounts.create!( - accountable: Depository.new, - name: "Demo Checking", - balance: 3000, - currency: "USD" - ) - - # Create one transaction for each category - create_transaction!(account: checking, amount: 100, name: "Grocery Store", category: food, date: 2.days.ago) - create_transaction!(account: checking, amount: 50, name: "Restaurant Meal", category: restaurants, date: 1.day.ago) - create_transaction!(account: checking, amount: 20, name: "Gas Station", category: transport, date: Date.current) - end - - puts "Basic budget data created for #{family_name}" - end - - puts "Demo data loaded successfully!" + generate_for_scenario(:basic_budget, family_names) end def generate_multi_currency_data!(family_names) - puts "Clearing existing data..." - - destroy_everything! - - puts "Data cleared" - - family_names.each_with_index do |family_name, index| - create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", currency: "EUR") - end - - puts "Users reset" - - family_names.each do |family_name| - puts "Generating demo data for #{family_name}" - family = Family.find_by(name: family_name) - - usd_checking = family.accounts.create!(name: "USD Checking", currency: "USD", balance: 10000, accountable: Depository.new) - eur_checking = family.accounts.create!(name: "EUR Checking", currency: "EUR", balance: 4900, accountable: Depository.new) - eur_credit_card = family.accounts.create!(name: "EUR Credit Card", currency: "EUR", balance: 2300, accountable: CreditCard.new) - - create_transaction!(account: eur_credit_card, amount: 1000, currency: "EUR", name: "EUR cc expense 1") - create_transaction!(account: eur_credit_card, amount: 1000, currency: "EUR", name: "EUR cc expense 2") - create_transaction!(account: eur_credit_card, amount: 300, currency: "EUR", name: "EUR cc expense 3") - - create_transaction!(account: usd_checking, amount: -11000, currency: "USD", name: "USD income Transaction") - create_transaction!(account: usd_checking, amount: 1000, currency: "USD", name: "USD expense Transaction") - create_transaction!(account: usd_checking, amount: 1000, currency: "USD", name: "USD expense Transaction") - create_transaction!(account: eur_checking, amount: -5000, currency: "EUR", name: "EUR income Transaction") - create_transaction!(account: eur_checking, amount: 100, currency: "EUR", name: "EUR expense Transaction") - - puts "Transactions created for #{family_name}" - end - - puts "Demo data loaded successfully!" + generate_for_scenario(:multi_currency, family_names) end private - def destroy_everything! - Family.destroy_all - Setting.destroy_all - InviteCode.destroy_all - ExchangeRate.destroy_all - Security.destroy_all - Security::Price.destroy_all + + # Registry pattern for clean scenario lookup and easy extensibility + def scenario_registry + @scenario_registry ||= { + clean_slate: Demo::Scenarios::CleanSlate, + default: Demo::Scenarios::Default, + basic_budget: Demo::Scenarios::BasicBudget, + multi_currency: Demo::Scenarios::MultiCurrency, + performance_testing: Demo::Scenarios::PerformanceTesting + }.freeze + end + + def generators + @generators ||= { + data_cleaner: Demo::DataCleaner.new, + rule_generator: Demo::RuleGenerator.new, + account_generator: Demo::AccountGenerator.new, + transaction_generator: Demo::TransactionGenerator.new, + security_generator: Demo::SecurityGenerator.new, + transfer_generator: Demo::TransferGenerator.new + } + end + + def generate_for_scenario(scenario_key, family_names, **options) + raise ArgumentError, "Scenario key is required" if scenario_key.nil? + raise ArgumentError, "Family names must be provided" if family_names.nil? || family_names.empty? + + scenario_class = scenario_registry[scenario_key] + unless scenario_class + raise ArgumentError, "Unknown scenario: #{scenario_key}. Available: #{scenario_registry.keys.join(', ')}" + end + + puts "Starting #{scenario_key} scenario generation for #{family_names.length} families..." + + clear_all_data! + create_families_and_users!(family_names, **options) + families = family_names.map { |name| Family.find_by(name: name) } + + scenario = scenario_class.new(generators) + scenario.generate!(families, **options) + + # Sync families after generation (except for performance testing) + unless scenario_key == :performance_testing + puts "Running account sync for generated data..." + families.each do |family| + family.accounts.each do |account| + sync = Sync.create!(syncable: account) + sync.perform + end + puts " - #{family.name} accounts synced (#{family.accounts.count} accounts)" + end + end + + puts "Demo data loaded successfully!" + end + + def clear_all_data! + family_count = Family.count + + if family_count > 200 + raise "Too much data to clear efficiently (#{family_count} families found). " \ + "Please run 'bundle exec rails db:reset' instead to quickly reset the database, " \ + "then re-run your demo data task." + end + + generators[:data_cleaner].destroy_everything! + end + + def create_families_and_users!(family_names, require_onboarding: false, currency: "USD") + family_names.each_with_index do |family_name, index| + create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", + currency: currency, require_onboarding: require_onboarding) + end + puts "Users reset" end def create_family_and_user!(family_name, user_email, currency: "USD", require_onboarding: false) @@ -184,341 +131,4 @@ class Demo::Generator password: "password", onboarded_at: require_onboarding ? nil : Time.current end - - def create_rules!(family) - family.rules.create!( - effective_date: 1.year.ago.to_date, - active: true, - resource_type: "transaction", - conditions: [ - Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: "Whole Foods") - ], - actions: [ - Rule::Action.new(action_type: "set_transaction_category", value: "Groceries") - ] - ) - end - - def create_tags!(family) - [ "Trips", "Emergency Fund", "Demo Tag" ].each do |tag| - family.tags.create!(name: tag) - end - end - - def create_categories!(family) - family.categories.bootstrap! - - food = family.categories.find_by(name: "Food & Drink") - family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, lucide_icon: "utensils", classification: "expense") - family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, lucide_icon: "shopping-cart", classification: "expense") - family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, lucide_icon: "beer", classification: "expense") - end - - def create_merchants!(family) - merchants = [ "Amazon", "Starbucks", "McDonald's", "Target", "Costco", - "Home Depot", "Shell", "Whole Foods", "Walgreens", "Nike", - "Uber", "Netflix", "Spotify", "Delta Airlines", "Airbnb", "Sephora" ] - - merchants.each do |merchant| - FamilyMerchant.create!(name: merchant, family: family, color: COLORS.sample) - end - end - - def create_credit_card_account!(family) - cc = family.accounts.create! \ - accountable: CreditCard.new, - name: "Chase Credit Card", - balance: 2300, - currency: "USD" - - 800.times do - merchant = random_family_record(Merchant, family) - create_transaction! \ - account: cc, - name: merchant.name, - amount: Faker::Number.positive(to: 200), - tags: [ tag_for_merchant(merchant, family) ], - category: category_for_merchant(merchant, family), - merchant: merchant - end - - 24.times do - create_transaction! \ - account: cc, - amount: Faker::Number.negative(from: -1000), - name: "CC Payment" - end - end - - def create_checking_account!(family) - checking = family.accounts.create! \ - accountable: Depository.new, - name: "Chase Checking", - balance: 15000, - currency: "USD" - - # First create income transactions to ensure positive balance - 50.times do - create_transaction! \ - account: checking, - amount: Faker::Number.negative(from: -2000, to: -500), - name: "Income", - category: family.categories.find_by(name: "Income") - end - - # Then create expenses that won't exceed the income - 200.times do - create_transaction! \ - account: checking, - name: "Expense", - amount: Faker::Number.positive(from: 8, to: 500) - end - end - - def create_savings_account!(family) - savings = family.accounts.create! \ - accountable: Depository.new, - name: "Demo Savings", - balance: 40000, - currency: "USD", - subtype: "savings" - - # Create larger income deposits first - 100.times do - create_transaction! \ - account: savings, - amount: Faker::Number.negative(from: -3000, to: -1000), - tags: [ family.tags.find_by(name: "Emergency Fund") ], - category: family.categories.find_by(name: "Income"), - name: "Income" - end - - # Add some smaller withdrawals that won't exceed the deposits - 50.times do - create_transaction! \ - account: savings, - amount: Faker::Number.positive(from: 100, to: 1000), - name: "Savings Withdrawal" - end - end - - def create_transfer_transactions!(family) - checking = family.accounts.find_by(name: "Chase Checking") - credit_card = family.accounts.find_by(name: "Chase Credit Card") - investment = family.accounts.find_by(name: "Robinhood") - - create_transaction!( - account: checking, - date: 1.day.ago.to_date, - amount: 100, - name: "Credit Card Payment" - ) - - create_transaction!( - account: credit_card, - date: 1.day.ago.to_date, - amount: -100, - name: "Credit Card Payment" - ) - - create_transaction!( - account: checking, - date: 3.days.ago.to_date, - amount: 500, - name: "Transfer to investment" - ) - - create_transaction!( - account: investment, - date: 2.days.ago.to_date, - amount: -500, - name: "Transfer from checking" - ) - end - - def load_securities! - # Create an unknown security to simulate edge cases - Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock" - - securities = [ - { ticker: "AAPL", exchange_mic: "XNGS", exchange_operating_mic: "XNAS", name: "Apple Inc.", reference_price: 210 }, - { ticker: "TM", exchange_mic: "XNYS", exchange_operating_mic: "XNYS", name: "Toyota Motor Corporation", reference_price: 202 }, - { ticker: "MSFT", exchange_mic: "XNGS", exchange_operating_mic: "XNAS", name: "Microsoft Corporation", reference_price: 455 } - ] - - securities.each do |security_attributes| - security = Security.create! security_attributes.except(:reference_price) - - # Load prices for last 2 years - (730.days.ago.to_date..Date.current).each do |date| - reference = security_attributes[:reference_price] - low_price = reference - 20 - high_price = reference + 20 - Security::Price.create! \ - security: security, - date: date, - price: Faker::Number.positive(from: low_price, to: high_price) - end - end - end - - def create_investment_account!(family) - account = family.accounts.create! \ - accountable: Investment.new, - name: "Robinhood", - balance: 100000, - currency: "USD" - - aapl = Security.find_by(ticker: "AAPL") - tm = Security.find_by(ticker: "TM") - msft = Security.find_by(ticker: "MSFT") - unknown = Security.find_by(ticker: "UNKNOWN") - - # Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices - account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Trade.new(qty: 20, price: 5, security: unknown, currency: "USD") - - trades = [ - { security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 }, - { security: msft, qty: -5 }, { security: tm, qty: 10 }, { security: msft, qty: 5 }, - { security: tm, qty: 10 }, { security: aapl, qty: -5 }, { security: msft, qty: -5 }, - { security: tm, qty: 10 }, { security: msft, qty: 5 }, { security: aapl, qty: -10 } - ] - - trades.each do |trade| - date = Faker::Number.positive(to: 730).days.ago.to_date - security = trade[:security] - qty = trade[:qty] - price = Security::Price.find_by(security: security, date: date)&.price || 1 - name_prefix = qty < 0 ? "Sell " : "Buy " - - account.entries.create! \ - date: date, - amount: qty * price, - currency: "USD", - name: name_prefix + "#{qty} shares of #{security.ticker}", - entryable: Trade.new(qty: qty, price: price, currency: "USD", security: security) - end - end - - def create_house_and_mortgage!(family) - house = family.accounts.create! \ - accountable: Property.new, - name: "123 Maybe Way", - balance: 560000, - currency: "USD" - - create_valuation!(house, 3.years.ago.to_date, 520000) - create_valuation!(house, 2.years.ago.to_date, 540000) - create_valuation!(house, 1.years.ago.to_date, 550000) - - mortgage = family.accounts.create! \ - accountable: Loan.new, - name: "Mortgage", - balance: 495000, - currency: "USD" - - create_valuation!(mortgage, 3.years.ago.to_date, 495000) - create_valuation!(mortgage, 2.years.ago.to_date, 490000) - create_valuation!(mortgage, 1.years.ago.to_date, 485000) - end - - def create_car_and_loan!(family) - vehicle = family.accounts.create! \ - accountable: Vehicle.new, - name: "Honda Accord", - balance: 18000, - currency: "USD" - - create_valuation!(vehicle, 1.year.ago.to_date, 18000) - - loan = family.accounts.create! \ - accountable: Loan.new, - name: "Car Loan", - balance: 8000, - currency: "USD" - - create_valuation!(loan, 1.year.ago.to_date, 8000) - end - - def create_other_accounts!(family) - other_asset = family.accounts.create! \ - accountable: OtherAsset.new, - name: "Other Asset", - balance: 10000, - currency: "USD" - - other_liability = family.accounts.create! \ - accountable: OtherLiability.new, - name: "Other Liability", - balance: 5000, - currency: "USD" - - create_valuation!(other_asset, 1.year.ago.to_date, 10000) - create_valuation!(other_liability, 1.year.ago.to_date, 5000) - end - - def create_transaction!(attributes = {}) - entry_attributes = attributes.except(:category, :tags, :merchant) - transaction_attributes = attributes.slice(:category, :tags, :merchant) - - entry_defaults = { - date: Faker::Number.between(from: 0, to: 730).days.ago.to_date, - currency: "USD", - entryable: Transaction.new(transaction_attributes) - } - - Entry.create! entry_defaults.merge(entry_attributes) - end - - def create_valuation!(account, date, amount) - Entry.create! \ - account: account, - date: date, - amount: amount, - currency: "USD", - name: "Balance update", - entryable: Valuation.new - end - - def random_family_record(model, family) - family_records = model.where(family_id: family.id) - model.offset(rand(family_records.count)).first - end - - def category_for_merchant(merchant, family) - mapping = { - "Amazon" => "Shopping", - "Starbucks" => "Food & Drink", - "McDonald's" => "Food & Drink", - "Target" => "Shopping", - "Costco" => "Food & Drink", - "Home Depot" => "Housing", - "Shell" => "Transportation", - "Whole Foods" => "Food & Drink", - "Walgreens" => "Healthcare", - "Nike" => "Shopping", - "Uber" => "Transportation", - "Netflix" => "Subscriptions", - "Spotify" => "Subscriptions", - "Delta Airlines" => "Transportation", - "Airbnb" => "Housing", - "Sephora" => "Shopping" - } - - family.categories.find_by(name: mapping[merchant.name]) - end - - def tag_for_merchant(merchant, family) - mapping = { - "Delta Airlines" => "Trips", - "Airbnb" => "Trips" - } - - tag_from_merchant = family.tags.find_by(name: mapping[merchant.name]) - tag_from_merchant || family.tags.find_by(name: "Demo Tag") - end - - def securities - @securities ||= Security.all.to_a - end end diff --git a/app/models/demo/rule_generator.rb b/app/models/demo/rule_generator.rb new file mode 100644 index 00000000..794dc45f --- /dev/null +++ b/app/models/demo/rule_generator.rb @@ -0,0 +1,79 @@ +class Demo::RuleGenerator + include Demo::DataHelper + + def create_rules!(family) + tags = create_tags!(family) + categories = create_categories!(family) + merchants = create_merchants!(family) + + rules = [] + + if merchants.any? && categories.any? + rule = family.rules.create!( + name: "Auto-categorize Grocery Purchases", + resource_type: "Transaction", + conditions: [ + Rule::Condition.new(condition_type: "merchant_name", operator: "contains", value: "Whole Foods") + ], + actions: [ + Rule::Action.new(action_type: "category_id", value: categories.first.id.to_s) + ] + ) + rules << rule + end + + rules + end + + def create_tags!(family) + tag_names = [ "Business", "Tax Deductible", "Recurring", "Emergency" ] + tags = [] + + tag_names.each do |name| + tag = family.tags.find_or_create_by!(name: name) do |t| + t.color = random_color + end + tags << tag + end + + tags + end + + def create_categories!(family) + category_data = [ + { name: "Groceries", color: random_color }, + { name: "Transportation", color: random_color }, + { name: "Entertainment", color: random_color }, + { name: "Utilities", color: random_color }, + { name: "Healthcare", color: random_color } + ] + + categories = [] + category_data.each do |data| + category = family.categories.find_or_create_by!(name: data[:name]) do |c| + c.color = data[:color] + end + categories << category + end + + categories + end + + def create_merchants!(family) + merchant_names = [ + "Whole Foods Market", + "Shell Gas Station", + "Netflix", + "Electric Company", + "Local Coffee Shop" + ] + + merchants = [] + merchant_names.each do |name| + merchant = family.merchants.find_or_create_by!(name: name) + merchants << merchant + end + + merchants + end +end diff --git a/app/models/demo/scenarios/basic_budget.rb b/app/models/demo/scenarios/basic_budget.rb new file mode 100644 index 00000000..f215bceb --- /dev/null +++ b/app/models/demo/scenarios/basic_budget.rb @@ -0,0 +1,129 @@ +# Basic budget scenario - minimal budgeting demonstration with categories +# +# This scenario creates a simple budget demonstration with parent/child categories +# and one transaction per category. Designed to showcase basic budgeting features +# without overwhelming complexity. Ideal for: +# - Basic budgeting feature demos +# - Category hierarchy demonstrations +# - Simple transaction categorization examples +# - Lightweight testing environments +# +class Demo::Scenarios::BasicBudget < Demo::BaseScenario + include Demo::DataHelper + + # Scenario characteristics and configuration + SCENARIO_NAME = "Basic Budget".freeze + PURPOSE = "Simple budget demonstration with category hierarchy".freeze + TARGET_ACCOUNTS_PER_FAMILY = 1 # Single checking account + TARGET_TRANSACTIONS_PER_FAMILY = 4 # One income, three expenses + TARGET_CATEGORIES = 4 # Income + 3 expense categories (with one subcategory) + INCLUDES_SECURITIES = false + INCLUDES_TRANSFERS = false + INCLUDES_RULES = false + + private + + # Generate basic budget demonstration data + # Creates simple category hierarchy and one transaction per category + # + # @param family [Family] The family to generate data for + # @param options [Hash] Additional options (unused in this scenario) + def generate_family_data!(family, **options) + create_category_hierarchy!(family) + create_demo_checking_account!(family) + create_sample_categorized_transactions!(family) + end + + # Create parent categories with one subcategory example + def create_category_hierarchy!(family) + # Create parent categories + @food_category = family.categories.create!( + name: "Food & Drink", + color: random_color, + classification: "expense" + ) + + @transport_category = family.categories.create!( + name: "Transportation", + color: random_color, + classification: "expense" + ) + + # Create subcategory to demonstrate hierarchy + @restaurants_category = family.categories.create!( + name: "Restaurants", + parent: @food_category, + color: random_color, + classification: "expense" + ) + + puts " - #{TARGET_CATEGORIES} categories created (with parent/child hierarchy)" + end + + # Create single checking account for budget demonstration + def create_demo_checking_account!(family) + @checking_account = family.accounts.create!( + accountable: Depository.new, + name: "Demo Checking", + balance: 0, # Will be calculated from transactions + currency: "USD" + ) + + puts " - #{TARGET_ACCOUNTS_PER_FAMILY} demo checking account created" + end + + # Create one transaction for each category to demonstrate categorization + def create_sample_categorized_transactions!(family) + # Create income category and transaction first + income_category = family.categories.create!( + name: "Income", + color: random_color, + classification: "income" + ) + + # Add income transaction (negative amount = inflow) + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: -500, # Income (negative) + name: "Salary", + category: income_category, + date: 5.days.ago + ) + + # Grocery transaction (parent category) + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: 100, + name: "Grocery Store", + category: @food_category, + date: 2.days.ago + ) + + # Restaurant transaction (subcategory) + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: 50, + name: "Restaurant Meal", + category: @restaurants_category, + date: 1.day.ago + ) + + # Transportation transaction + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: 20, + name: "Gas Station", + category: @transport_category, + date: Date.current + ) + + # Update account balance to match transaction sum + @generators[:transaction_generator].update_account_balances_from_transactions!(family) + + puts " - #{TARGET_TRANSACTIONS_PER_FAMILY + 1} categorized transactions created (including income)" + end + + def scenario_name + SCENARIO_NAME + end +end diff --git a/app/models/demo/scenarios/clean_slate.rb b/app/models/demo/scenarios/clean_slate.rb new file mode 100644 index 00000000..b37514d8 --- /dev/null +++ b/app/models/demo/scenarios/clean_slate.rb @@ -0,0 +1,126 @@ +# Clean slate scenario - minimal starter data for new user onboarding +# +# This scenario creates the absolute minimum data needed to help new users +# understand Maybe's core features without overwhelming them. Ideal for: +# - New user onboarding flows +# - Tutorial walkthroughs +# - Clean development environments +# - User acceptance testing with minimal data +# +# The scenario only generates data when explicitly requested via with_minimal_data: true, +# otherwise it creates no data at all (true "clean slate"). +# +# @example Minimal data generation +# scenario = Demo::Scenarios::CleanSlate.new(generators) +# scenario.generate!(families, with_minimal_data: true) +# +# @example True clean slate (no data) +# scenario = Demo::Scenarios::CleanSlate.new(generators) +# scenario.generate!(families) # Creates nothing +# +class Demo::Scenarios::CleanSlate < Demo::BaseScenario + # Scenario characteristics and configuration + SCENARIO_NAME = "Clean Slate".freeze + PURPOSE = "Minimal starter data for new user onboarding and tutorials".freeze + TARGET_ACCOUNTS_PER_FAMILY = 1 # Single checking account only + TARGET_TRANSACTIONS_PER_FAMILY = 3 # Just enough to show transaction history + INCLUDES_SECURITIES = false + INCLUDES_TRANSFERS = false + INCLUDES_RULES = false + MINIMAL_CATEGORIES = 2 # Essential expense and income categories only + + # Override the base generate! method to handle the special with_minimal_data option + # Only generates data when explicitly requested to avoid accidental data creation + # + # @param families [Array] Families to generate data for + # @param options [Hash] Options hash that may contain with_minimal_data or require_onboarding + def generate!(families, **options) + # For "empty" task, don't generate any data + # For "new_user" task, generate minimal data for onboarding users + with_minimal_data = options[:with_minimal_data] || options[:require_onboarding] + return unless with_minimal_data + + super(families, **options) + end + + private + + # Generate minimal family data for getting started + # Creates only essential accounts and transactions to demonstrate core features + # + # @param family [Family] The family to generate data for + # @param options [Hash] Additional options (with_minimal_data used for validation) + def generate_family_data!(family, **options) + create_essential_categories!(family) + create_primary_checking_account!(family) + create_sample_transaction_history!(family) + end + + # Create only the most essential categories for basic expense tracking + def create_essential_categories!(family) + @food_category = family.categories.create!( + name: "Food & Drink", + color: "#4da568", + classification: "expense" + ) + + @income_category = family.categories.create!( + name: "Income", + color: "#6471eb", + classification: "income" + ) + + puts " - #{MINIMAL_CATEGORIES} essential categories created" + end + + # Create a single primary checking account with a reasonable starting balance + def create_primary_checking_account!(family) + @checking_account = family.accounts.create!( + accountable: Depository.new, + name: "Main Checking", + balance: 0, # Will be calculated from transactions + currency: "USD" + ) + + puts " - #{TARGET_ACCOUNTS_PER_FAMILY} primary checking account created" + end + + # Create minimal transaction history showing income and expense patterns + def create_sample_transaction_history!(family) + # Recent salary deposit + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: -3000, # Income (negative = inflow) + name: "Salary", + category: @income_category, + date: 15.days.ago + ) + + # Recent grocery purchase + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: 75, # Expense (positive = outflow) + name: "Grocery Store", + category: @food_category, + date: 5.days.ago + ) + + # Recent restaurant expense + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: 45, # Expense + name: "Restaurant", + category: @food_category, + date: 2.days.ago + ) + + # Update account balance to match transaction sum + @generators[:transaction_generator].update_account_balances_from_transactions!(family) + + puts " - #{TARGET_TRANSACTIONS_PER_FAMILY} sample transactions created" + end + + def scenario_name + SCENARIO_NAME + end +end diff --git a/app/models/demo/scenarios/default.rb b/app/models/demo/scenarios/default.rb new file mode 100644 index 00000000..78eb6a8f --- /dev/null +++ b/app/models/demo/scenarios/default.rb @@ -0,0 +1,77 @@ +# Default demo scenario - comprehensive realistic data for product demonstrations +# +# This scenario creates a complete, realistic demo environment that showcases +# all of Maybe's features with believable data patterns. Ideal for: +# - Product demonstrations to potential users +# - UI/UX testing with realistic data volumes +# - Feature development with complete data sets +# - Screenshots and marketing materials +# +class Demo::Scenarios::Default < Demo::BaseScenario + # Scenario characteristics and configuration + SCENARIO_NAME = "Comprehensive Demo".freeze + PURPOSE = "Complete realistic demo environment showcasing all Maybe features".freeze + TARGET_ACCOUNTS_PER_FAMILY = 7 # 1 each: checking, savings, credit card, 3 investments, 1 property+mortgage + TARGET_TRANSACTIONS_PER_FAMILY = 50 # Realistic 3-month transaction history + INCLUDES_SECURITIES = true + INCLUDES_TRANSFERS = true + INCLUDES_RULES = true + + private + + # Load securities before generating family data + # Securities are needed for investment account trades + def setup(**options) + @generators[:security_generator].load_securities! + puts "Securities loaded for investment accounts" + end + + # Generate complete family financial data + # Creates all account types with realistic balances and transaction patterns + # + # @param family [Family] The family to generate data for + # @param options [Hash] Additional options (unused in this scenario) + def generate_family_data!(family, **options) + create_foundational_data!(family) + create_all_account_types!(family) + create_realistic_transaction_patterns!(family) + create_account_transfers!(family) + end + + # Create rules, tags, categories, and merchants for the family + def create_foundational_data!(family) + @generators[:rule_generator].create_rules!(family) + @generators[:rule_generator].create_tags!(family) + @generators[:rule_generator].create_categories!(family) + @generators[:rule_generator].create_merchants!(family) + puts " - Rules, categories, and merchants created" + end + + # Create one of each major account type to demonstrate full feature set + def create_all_account_types!(family) + @generators[:account_generator].create_credit_card_accounts!(family) + @generators[:account_generator].create_checking_accounts!(family) + @generators[:account_generator].create_savings_accounts!(family) + @generators[:account_generator].create_investment_accounts!(family) + @generators[:account_generator].create_properties_and_mortgages!(family) + @generators[:account_generator].create_vehicles_and_loans!(family) + @generators[:account_generator].create_other_accounts!(family) + puts " - All #{TARGET_ACCOUNTS_PER_FAMILY} account types created" + end + + # Generate realistic transaction patterns across all accounts + def create_realistic_transaction_patterns!(family) + @generators[:transaction_generator].create_realistic_transactions!(family) + puts " - Realistic transaction patterns created (~#{TARGET_TRANSACTIONS_PER_FAMILY} transactions)" + end + + # Create transfer patterns between accounts (credit card payments, investments, etc.) + def create_account_transfers!(family) + @generators[:transfer_generator].create_transfer_transactions!(family) + puts " - Account transfer patterns created" + end + + def scenario_name + SCENARIO_NAME + end +end diff --git a/app/models/demo/scenarios/multi_currency.rb b/app/models/demo/scenarios/multi_currency.rb new file mode 100644 index 00000000..229eb458 --- /dev/null +++ b/app/models/demo/scenarios/multi_currency.rb @@ -0,0 +1,241 @@ +# Multi-currency scenario - international financial management demonstration +# +# This scenario creates accounts and transactions in multiple currencies to showcase +# Maybe's multi-currency capabilities. Demonstrates currency conversion, international +# transactions, and mixed-currency portfolio management. Ideal for: +# - International users and use cases +# - Currency conversion feature testing +# - Multi-region financial management demos +# - Exchange rate and conversion testing +# +# Primary currency is EUR with additional USD and GBP accounts and transactions. +# +class Demo::Scenarios::MultiCurrency < Demo::BaseScenario + include Demo::DataHelper + + # Scenario characteristics and configuration + SCENARIO_NAME = "Multi-Currency".freeze + PURPOSE = "International financial management with multiple currencies".freeze + PRIMARY_CURRENCY = "EUR".freeze + SUPPORTED_CURRENCIES = %w[EUR USD GBP].freeze + TARGET_ACCOUNTS_PER_FAMILY = 5 # 2 EUR (checking, credit), 1 USD, 1 GBP, 1 multi-currency investment + TARGET_TRANSACTIONS_PER_FAMILY = 10 # Distributed across currencies + INCLUDES_SECURITIES = false # Keep simple for currency focus + INCLUDES_TRANSFERS = true # Minimal transfers to avoid currency complexity + INCLUDES_RULES = false # Focus on currency, not categorization + + private + + # Generate family data with multiple currencies + # Creates accounts in EUR, USD, and GBP with appropriate transactions + # + # @param family [Family] The family to generate data for (should have EUR as primary currency) + # @param options [Hash] Additional options (unused in this scenario) + def generate_family_data!(family, **options) + create_basic_categorization!(family) + create_multi_currency_accounts!(family) + create_international_transactions!(family) + create_minimal_transfers!(family) + end + + # Create basic categories for international transactions + def create_basic_categorization!(family) + @generators[:rule_generator].create_categories!(family) + @generators[:rule_generator].create_merchants!(family) + puts " - Basic categories and merchants created for international transactions" + end + + # Create accounts in multiple currencies to demonstrate international capabilities + def create_multi_currency_accounts!(family) + create_eur_accounts!(family) # Primary currency accounts + create_usd_accounts!(family) # US dollar accounts + create_gbp_accounts!(family) # British pound accounts + create_investment_account!(family) # Multi-currency investment + + puts " - #{TARGET_ACCOUNTS_PER_FAMILY} multi-currency accounts created (#{SUPPORTED_CURRENCIES.join(', ')})" + end + + # Create EUR accounts (primary currency for this scenario) + def create_eur_accounts!(family) + # Create EUR checking account + family.accounts.create!( + accountable: Depository.new, + name: "EUR Checking Account", + balance: 0, # Will be calculated from transactions + currency: "EUR" + ) + + # Create EUR credit card + family.accounts.create!( + accountable: CreditCard.new, + name: "EUR Credit Card", + balance: 0, # Will be calculated from transactions + currency: "EUR" + ) + end + + # Create USD accounts for US-based transactions + def create_usd_accounts!(family) + family.accounts.create!( + accountable: Depository.new, + name: "USD Checking Account", + balance: 0, # Will be calculated from transactions + currency: "USD" + ) + end + + # Create GBP accounts for UK-based transactions + def create_gbp_accounts!(family) + family.accounts.create!( + accountable: Depository.new, + name: "GBP Savings Account", + balance: 0, # Will be calculated from transactions + currency: "GBP", + subtype: "savings" + ) + end + + # Create investment account (uses primary currency) + def create_investment_account!(family) + @generators[:account_generator].create_investment_accounts!(family, count: 1) + end + + # Create transactions in various currencies to demonstrate international usage + def create_international_transactions!(family) + # Create initial valuations for accounts that need them + create_initial_valuations!(family) + + create_eur_transaction_patterns!(family) + create_usd_transaction_patterns!(family) + create_gbp_transaction_patterns!(family) + + # Update account balances to match transaction sums + @generators[:transaction_generator].update_account_balances_from_transactions!(family) + + puts " - International transactions created across #{SUPPORTED_CURRENCIES.length} currencies" + end + + # Create initial valuations for credit cards in this scenario + def create_initial_valuations!(family) + family.accounts.each do |account| + next unless account.accountable_type == "CreditCard" + + Entry.create!( + account: account, + amount: 1000, # Initial credit card debt + name: "Initial creditcard valuation", + date: 2.years.ago.to_date, + currency: account.currency, + entryable_type: "Valuation", + entryable_attributes: {} + ) + end + end + + # Create EUR transactions (primary currency patterns) with both income and expenses + def create_eur_transaction_patterns!(family) + eur_accounts = family.accounts.where(currency: "EUR") + + eur_accounts.each do |account| + next if account.accountable_type == "Investment" + + if account.accountable_type == "CreditCard" + # Credit cards only get purchases (positive amounts) + 5.times do |i| + @generators[:transaction_generator].create_transaction!( + account: account, + amount: random_positive_amount(50, 300), # Purchases (positive) + name: "EUR Purchase #{i + 1}", + date: random_date_within_days(60), + currency: "EUR" + ) + end + else + # Checking accounts get both income and expenses + # Create income transactions (negative amounts) + 2.times do |i| + @generators[:transaction_generator].create_transaction!( + account: account, + amount: -random_positive_amount(2000, 3000), # Higher income to cover transfers + name: "EUR Salary #{i + 1}", + date: random_date_within_days(60), + currency: "EUR" + ) + end + + # Create expense transactions (positive amounts) + 3.times do |i| + @generators[:transaction_generator].create_transaction!( + account: account, + amount: random_positive_amount(20, 200), # Expense (positive) + name: "EUR Purchase #{i + 1}", + date: random_date_within_days(60), + currency: "EUR" + ) + end + end + end + end + + # Create USD transactions (US-based spending patterns) with both income and expenses + def create_usd_transaction_patterns!(family) + usd_accounts = family.accounts.where(currency: "USD") + + usd_accounts.each do |account| + # Create income transaction (negative amount) + @generators[:transaction_generator].create_transaction!( + account: account, + amount: -random_positive_amount(1500, 2500), # Higher income to cover transfers + name: "USD Freelance Payment", + date: random_date_within_days(60), + currency: "USD" + ) + + # Create expense transactions (positive amounts) + 2.times do |i| + @generators[:transaction_generator].create_transaction!( + account: account, + amount: random_positive_amount(30, 150), # Expense (positive) + name: "USD Purchase #{i + 1}", + date: random_date_within_days(60), + currency: "USD" + ) + end + end + end + + # Create GBP transactions (UK-based spending patterns) with both income and expenses + def create_gbp_transaction_patterns!(family) + gbp_accounts = family.accounts.where(currency: "GBP") + + gbp_accounts.each do |account| + # Create income transaction (negative amount) + @generators[:transaction_generator].create_transaction!( + account: account, + amount: -random_positive_amount(500, 800), # Income (negative) + name: "GBP Consulting Payment", + date: random_date_within_days(60), + currency: "GBP" + ) + + # Create expense transaction (positive amount) + @generators[:transaction_generator].create_transaction!( + account: account, + amount: random_positive_amount(25, 100), # Expense (positive) + name: "GBP Purchase", + date: random_date_within_days(60), + currency: "GBP" + ) + end + end + + # Create minimal transfers to keep scenario focused on currency demonstration + def create_minimal_transfers!(family) + @generators[:transfer_generator].create_transfer_transactions!(family, count: 1) + puts " - Minimal account transfers created" + end + + def scenario_name + SCENARIO_NAME + end +end diff --git a/app/models/demo/scenarios/performance_testing.rb b/app/models/demo/scenarios/performance_testing.rb new file mode 100644 index 00000000..1bfff17b --- /dev/null +++ b/app/models/demo/scenarios/performance_testing.rb @@ -0,0 +1,349 @@ +# Performance testing scenario - high-volume data for load testing +# +# This scenario creates large volumes of realistic data to test application +# performance under load. Uses an efficient approach: generates one complete +# realistic family in Ruby, then uses SQL bulk operations to duplicate it +# 499 times for maximum performance. Ideal for: +# - Performance testing and benchmarking +# - Load testing database operations +# - UI performance testing with large datasets +# - Scalability validation at production scale +# + +require "bcrypt" + +class Demo::Scenarios::PerformanceTesting < Demo::BaseScenario + # Scenario characteristics and configuration + SCENARIO_NAME = "Performance Testing".freeze + PURPOSE = "High-volume data generation for performance testing and load validation".freeze + TARGET_FAMILIES = 500 + TARGET_ACCOUNTS_PER_FAMILY = 29 # 3 credit cards, 5 checking, 2 savings, 10 investments, 2 properties+mortgages, 3 vehicles+2 loans, 4 other assets+liabilities + TARGET_TRANSACTIONS_PER_FAMILY = 200 # Reasonable volume for development performance testing + TARGET_TRANSFERS_PER_FAMILY = 10 + SECURITIES_COUNT = 50 # Large number for investment account testing + INCLUDES_SECURITIES = true + INCLUDES_TRANSFERS = true + INCLUDES_RULES = true + + # Override generate! to use our efficient bulk duplication approach + def generate!(families, **options) + puts "Creating performance test data for #{TARGET_FAMILIES} families using efficient bulk duplication..." + + setup(**options) if respond_to?(:setup, true) + + # Step 1: Create one complete realistic family + template_family = create_template_family!(families.first, **options) + + # Step 2: Efficiently duplicate it 499 times using SQL + duplicate_family_data!(template_family, TARGET_FAMILIES - 1) + + puts "Performance test data created successfully with #{TARGET_FAMILIES} families!" + end + + private + + # Load large number of securities before generating family data + def setup(**options) + @generators[:security_generator].load_securities!(count: SECURITIES_COUNT) + puts "#{SECURITIES_COUNT} securities loaded for performance testing" + end + + # Create one complete, realistic family that will serve as our template + def create_template_family!(family_or_name, **options) + # Handle both Family object and family name string + family = if family_or_name.is_a?(Family) + family_or_name + else + Family.find_by(name: family_or_name) + end + + unless family + raise "Template family '#{family_or_name}' not found. Ensure family creation happened first." + end + + puts "Creating template family: #{family.name}..." + generate_family_data!(family, **options) + + puts "Template family created with #{family.accounts.count} accounts and #{family.entries.count} entries" + family + end + + # Efficiently duplicate the template family data using SQL bulk operations + def duplicate_family_data!(template_family, copies_needed) + puts "Duplicating template family #{copies_needed} times using efficient SQL operations..." + + ActiveRecord::Base.transaction do + # Get all related data for the template family + template_data = extract_template_data(template_family) + + # Create family records in batches + create_family_copies(template_family, copies_needed) + + # Bulk duplicate all related data + duplicate_accounts_and_related_data(template_data, copies_needed) + end + + puts "Successfully created #{copies_needed} family copies" + end + + # Extract all data related to the template family for duplication + def extract_template_data(family) + { + accounts: family.accounts.includes(:accountable), + entries: family.entries.includes(:entryable), + categories: family.categories, + merchants: family.merchants, + tags: family.tags, + rules: family.rules, + holdings: family.holdings + } + end + + # Create family and user records efficiently + def create_family_copies(template_family, count) + puts "Creating #{count} family records..." + + families_data = [] + users_data = [] + password_digest = BCrypt::Password.create("password") + + (2..count + 1).each do |i| + family_id = SecureRandom.uuid + family_name = "Performance Family #{i}" + + families_data << { + id: family_id, + name: family_name, + currency: template_family.currency, + locale: template_family.locale, + country: template_family.country, + timezone: template_family.timezone, + date_format: template_family.date_format, + created_at: Time.current, + updated_at: Time.current + } + + # Create admin user + users_data << { + id: SecureRandom.uuid, + family_id: family_id, + email: "user#{i}@maybe.local", + first_name: "Demo", + last_name: "User", + role: "admin", + password_digest: password_digest, + onboarded_at: Time.current, + created_at: Time.current, + updated_at: Time.current + } + + # Create member user + users_data << { + id: SecureRandom.uuid, + family_id: family_id, + email: "member_user#{i}@maybe.local", + first_name: "Demo (member user)", + last_name: "User", + role: "member", + password_digest: password_digest, + onboarded_at: Time.current, + created_at: Time.current, + updated_at: Time.current + } + end + + # Bulk insert families and users + Family.insert_all(families_data) + User.insert_all(users_data) + + puts "Created #{count} families and #{users_data.length} users" + end + + # Efficiently duplicate accounts and all related data using SQL + def duplicate_accounts_and_related_data(template_data, count) + puts "Duplicating accounts and related data for #{count} families..." + + new_families = Family.where("name LIKE 'Performance Family %'") + .where.not(id: template_data[:accounts].first&.family_id) + .limit(count) + + new_families.find_each.with_index do |family, index| + duplicate_family_accounts_bulk(template_data, family) + puts "Completed family #{index + 1}/#{count}" if (index + 1) % 50 == 0 + end + end + + # Duplicate all accounts and related data for a single family using bulk operations + def duplicate_family_accounts_bulk(template_data, target_family) + return if template_data[:accounts].empty? + + account_id_mapping = {} + + # Create accounts one by one to handle accountables properly + template_data[:accounts].each do |template_account| + new_account = target_family.accounts.create!( + accountable: template_account.accountable.dup, + name: template_account.name, + balance: template_account.balance, + currency: template_account.currency, + subtype: template_account.subtype, + is_active: template_account.is_active + ) + account_id_mapping[template_account.id] = new_account.id + end + + # Bulk create other related data + create_bulk_categories(template_data[:categories], target_family) + create_bulk_entries_and_related(template_data, target_family, account_id_mapping) + rescue => e + puts "Error duplicating data for #{target_family.name}: #{e.message}" + # Continue with next family rather than failing completely + end + + # Bulk create categories for a family + def create_bulk_categories(template_categories, target_family) + return if template_categories.empty? + + # Create mapping from old category IDs to new category IDs + category_id_mapping = {} + + # First pass: generate new IDs for all categories + template_categories.each do |template_category| + category_id_mapping[template_category.id] = SecureRandom.uuid + end + + # Second pass: create category data with properly mapped parent_ids + categories_data = template_categories.map do |template_category| + # Map parent_id to the new family's category ID, or nil if no parent + new_parent_id = template_category.parent_id ? category_id_mapping[template_category.parent_id] : nil + + { + id: category_id_mapping[template_category.id], + family_id: target_family.id, + name: template_category.name, + color: template_category.color, + classification: template_category.classification, + parent_id: new_parent_id, + created_at: Time.current, + updated_at: Time.current + } + end + + Category.insert_all(categories_data) + end + + # Bulk create entries and related entryables + def create_bulk_entries_and_related(template_data, target_family, account_id_mapping) + return if template_data[:entries].empty? + + entries_data = [] + transactions_data = [] + trades_data = [] + + template_data[:entries].each do |template_entry| + new_account_id = account_id_mapping[template_entry.account_id] + next unless new_account_id + + new_entry_id = SecureRandom.uuid + new_entryable_id = SecureRandom.uuid + + entries_data << { + id: new_entry_id, + account_id: new_account_id, + entryable_type: template_entry.entryable_type, + entryable_id: new_entryable_id, + name: template_entry.name, + date: template_entry.date, + amount: template_entry.amount, + currency: template_entry.currency, + notes: template_entry.notes, + created_at: Time.current, + updated_at: Time.current + } + + # Create entryable data based on type + case template_entry.entryable_type + when "Transaction" + transactions_data << { + id: new_entryable_id, + created_at: Time.current, + updated_at: Time.current + } + when "Trade" + trades_data << { + id: new_entryable_id, + security_id: template_entry.entryable.security_id, + qty: template_entry.entryable.qty, + price: template_entry.entryable.price, + currency: template_entry.entryable.currency, + created_at: Time.current, + updated_at: Time.current + } + end + end + + # Bulk insert all data + Entry.insert_all(entries_data) if entries_data.any? + Transaction.insert_all(transactions_data) if transactions_data.any? + Trade.insert_all(trades_data) if trades_data.any? + end + + # Generate high-volume family data for the template family + def generate_family_data!(family, **options) + create_foundational_data!(family) + create_high_volume_accounts!(family) + create_performance_transactions!(family) + create_performance_transfers!(family) + end + + # Create rules, tags, categories and merchants for performance testing + def create_foundational_data!(family) + @generators[:rule_generator].create_tags!(family) + @generators[:rule_generator].create_categories!(family) + @generators[:rule_generator].create_merchants!(family) + @generators[:rule_generator].create_rules!(family) + puts " - Foundational data created (tags, categories, merchants, rules)" + end + + # Create large numbers of accounts across all types for performance testing + def create_high_volume_accounts!(family) + @generators[:account_generator].create_credit_card_accounts!(family, count: 3) + puts " - 3 credit card accounts created" + + @generators[:account_generator].create_checking_accounts!(family, count: 5) + puts " - 5 checking accounts created" + + @generators[:account_generator].create_savings_accounts!(family, count: 2) + puts " - 2 savings accounts created" + + @generators[:account_generator].create_investment_accounts!(family, count: 10) + puts " - 10 investment accounts created" + + @generators[:account_generator].create_properties_and_mortgages!(family, count: 2) + puts " - 2 properties and mortgages created" + + @generators[:account_generator].create_vehicles_and_loans!(family, vehicle_count: 3, loan_count: 2) + puts " - 3 vehicles and 2 loans created" + + @generators[:account_generator].create_other_accounts!(family, asset_count: 4, liability_count: 4) + puts " - 4 other assets and 4 other liabilities created" + + puts " - Total: #{TARGET_ACCOUNTS_PER_FAMILY} accounts created for performance testing" + end + + # Create high-volume transactions for performance testing + def create_performance_transactions!(family) + @generators[:transaction_generator].create_performance_transactions!(family) + puts " - High-volume performance transactions created (~#{TARGET_TRANSACTIONS_PER_FAMILY} transactions)" + end + + # Create multiple transfer cycles for performance testing + def create_performance_transfers!(family) + @generators[:transfer_generator].create_transfer_transactions!(family, count: TARGET_TRANSFERS_PER_FAMILY) + puts " - #{TARGET_TRANSFERS_PER_FAMILY} transfer transaction cycles created" + end + + def scenario_name + SCENARIO_NAME + end +end diff --git a/app/models/demo/security_generator.rb b/app/models/demo/security_generator.rb new file mode 100644 index 00000000..eca2d0a8 --- /dev/null +++ b/app/models/demo/security_generator.rb @@ -0,0 +1,76 @@ +class Demo::SecurityGenerator + include Demo::DataHelper + + def load_securities!(count: 6) + if count <= 6 + create_standard_securities!(count) + else + securities = create_standard_securities!(6) + securities.concat(create_performance_securities!(count - 6)) + securities + end + end + + def create_standard_securities!(count) + securities_data = [ + { ticker: "AAPL", name: "Apple Inc.", exchange: "XNAS" }, + { ticker: "GOOGL", name: "Alphabet Inc.", exchange: "XNAS" }, + { ticker: "MSFT", name: "Microsoft Corporation", exchange: "XNAS" }, + { ticker: "AMZN", name: "Amazon.com Inc.", exchange: "XNAS" }, + { ticker: "TSLA", name: "Tesla Inc.", exchange: "XNAS" }, + { ticker: "NVDA", name: "NVIDIA Corporation", exchange: "XNAS" } + ] + + securities = [] + count.times do |i| + data = securities_data[i] + security = create_security!( + ticker: data[:ticker], + name: data[:name], + exchange_operating_mic: data[:exchange] + ) + securities << security + end + securities + end + + def create_performance_securities!(count) + securities = [] + count.times do |i| + security = create_security!( + ticker: "SYM#{i + 7}", + name: "Company #{i + 7}", + exchange_operating_mic: "XNAS" + ) + securities << security + end + securities + end + + def create_security!(ticker:, name:, exchange_operating_mic:) + security = Security.create!(ticker: ticker, name: name, exchange_operating_mic: exchange_operating_mic) + create_price_history!(security) + security + end + + def create_price_history!(security, extended: false) + days_back = extended ? 365 : 90 + price_base = 100.0 + prices = [] + + (0..days_back).each do |i| + date = i.days.ago.to_date + price_change = (rand - 0.5) * 10 + price_base = [ price_base + price_change, 10.0 ].max + + price = security.prices.create!( + date: date, + price: price_base.round(2), + currency: "USD" + ) + prices << price + end + + prices + end +end diff --git a/app/models/demo/transaction_generator.rb b/app/models/demo/transaction_generator.rb new file mode 100644 index 00000000..aa74dadc --- /dev/null +++ b/app/models/demo/transaction_generator.rb @@ -0,0 +1,448 @@ +class Demo::TransactionGenerator + include Demo::DataHelper + + def create_transaction!(attributes = {}) + # Separate entry attributes from transaction attributes + entry_attributes = attributes.extract!(:account, :date, :amount, :currency, :name, :notes) + transaction_attributes = attributes # category, merchant, etc. + + # Set defaults for entry + entry_defaults = { + date: 30.days.ago.to_date, + amount: 100, + currency: "USD", + name: "Demo Transaction" + } + + # Create entry with transaction as entryable + entry = Entry.create!( + entry_defaults.merge(entry_attributes).merge( + entryable_type: "Transaction", + entryable_attributes: transaction_attributes + ) + ) + + entry.entryable # Returns the Transaction + end + + def create_trade!(attributes = {}) + # Separate entry attributes from trade attributes + entry_attributes = attributes.extract!(:account, :date, :amount, :currency, :name, :notes) + trade_attributes = attributes # security, qty, price, etc. + + # Validate required trade attributes + security = trade_attributes[:security] || Security.first + unless security + raise ArgumentError, "Security is required for trade creation. Load securities first." + end + + # Set defaults for entry + entry_defaults = { + date: 30.days.ago.to_date, + currency: "USD", + name: "Demo Trade" + } + + # Set defaults for trade + trade_defaults = { + qty: 10, + price: 100, + currency: "USD" + } + + # Merge defaults with provided attributes + final_entry_attributes = entry_defaults.merge(entry_attributes) + final_trade_attributes = trade_defaults.merge(trade_attributes) + final_trade_attributes[:security] = security + + # Calculate amount if not provided (qty * price) + unless final_entry_attributes[:amount] + final_entry_attributes[:amount] = final_trade_attributes[:qty] * final_trade_attributes[:price] + end + + # Create entry with trade as entryable + entry = Entry.create!( + final_entry_attributes.merge( + entryable_type: "Trade", + entryable_attributes: final_trade_attributes + ) + ) + + entry.entryable # Returns the Trade + end + + def create_realistic_transactions!(family) + categories = family.categories.limit(10) + accounts_by_type = group_accounts_by_type(family) + entries = [] + + # Create initial valuations for accounts before other transactions + entries.concat(create_initial_valuations!(family)) + + accounts_by_type[:checking].each do |account| + entries.concat(create_income_transactions!(account)) + entries.concat(create_expense_transactions!(account, categories)) + end + + accounts_by_type[:credit_cards].each do |account| + entries.concat(create_credit_card_transactions!(account, categories)) + end + + accounts_by_type[:investments].each do |account| + entries.concat(create_investment_trades!(account)) + end + + # Update account balances to match transaction sums + update_account_balances_from_transactions!(family) + + entries + end + + def create_performance_transactions!(family) + categories = family.categories.limit(5) + accounts_by_type = group_accounts_by_type(family) + entries = [] + + # Create initial valuations for accounts before other transactions + entries.concat(create_initial_valuations!(family)) + + accounts_by_type[:checking].each do |account| + entries.concat(create_bulk_transactions!(account, PERFORMANCE_TRANSACTION_COUNTS[:depository_sample], income: true)) + entries.concat(create_bulk_transactions!(account, PERFORMANCE_TRANSACTION_COUNTS[:depository_sample], income: false)) + end + + accounts_by_type[:credit_cards].each do |account| + entries.concat(create_bulk_transactions!(account, PERFORMANCE_TRANSACTION_COUNTS[:credit_card_sample], credit_card: true)) + end + + accounts_by_type[:investments].each do |account| + entries.concat(create_bulk_investment_trades!(account, PERFORMANCE_TRANSACTION_COUNTS[:investment_trades])) + end + + # Update account balances to match transaction sums + update_account_balances_from_transactions!(family) + + entries + end + + # Create initial valuations for accounts to establish realistic starting values + # This is more appropriate than fake transactions + def create_initial_valuations!(family) + entries = [] + + family.accounts.each do |account| + initial_value = case account.accountable_type + when "Loan" + case account.name + when /Mortgage/i then 300000 # Initial mortgage debt + when /Auto/i, /Car/i then 15000 # Initial car loan debt + else 10000 # Other loan debt + end + when "CreditCard" + 5000 # Initial credit card debt + when "Property" + 500000 # Initial property value + when "Vehicle" + 25000 # Initial vehicle value + when "OtherAsset" + 5000 # Initial other asset value + when "OtherLiability" + 2000 # Initial other liability debt + else + next # Skip accounts that don't need initial valuations + end + + # Create valuation entry + entry = Entry.create!( + account: account, + amount: initial_value, + name: "Initial #{account.accountable_type.humanize.downcase} valuation", + date: 2.years.ago.to_date, + currency: account.currency, + entryable_type: "Valuation", + entryable_attributes: {} + ) + entries << entry + end + + entries + end + + # Update account balances to match the sum of their transactions and valuations + # This ensures realistic balances without artificial balancing transactions + def update_account_balances_from_transactions!(family) + family.accounts.each do |account| + transaction_sum = account.entries + .where(entryable_type: [ "Transaction", "Trade", "Valuation" ]) + .sum(:amount) + + # Calculate realistic balance based on transaction sum and account type + # For assets: balance should be positive, so we negate the transaction sum + # For liabilities: balance should reflect debt owed + new_balance = case account.classification + when "asset" + -transaction_sum # Assets: negative transaction sum = positive balance + when "liability" + transaction_sum # Liabilities: positive transaction sum = positive debt balance + else + -transaction_sum + end + + # Ensure minimum realistic balance + new_balance = [ new_balance, minimum_realistic_balance(account) ].max + + account.update!(balance: new_balance) + end + end + + private + + def create_income_transactions!(account) + entries = [] + + 6.times do |i| + transaction = create_transaction!( + account: account, + name: "Salary #{i + 1}", + amount: -income_amount, + date: random_date_within_days(90), + currency: account.currency + ) + entries << transaction.entry + end + + entries + end + + def create_expense_transactions!(account, categories) + entries = [] + expense_types = [ + { name: "Grocery Store", amount_range: [ 50, 200 ] }, + { name: "Gas Station", amount_range: [ 30, 80 ] }, + { name: "Restaurant", amount_range: [ 25, 150 ] }, + { name: "Online Purchase", amount_range: [ 20, 300 ] } + ] + + 20.times do + expense_type = expense_types.sample + transaction = create_transaction!( + account: account, + name: expense_type[:name], + amount: expense_amount(expense_type[:amount_range][0], expense_type[:amount_range][1]), + date: random_date_within_days(90), + currency: account.currency, + category: categories.sample + ) + entries << transaction.entry + end + + entries + end + + def create_credit_card_transactions!(account, categories) + entries = [] + + credit_card_merchants = [ + { name: "Amazon Purchase", amount_range: [ 25, 500 ] }, + { name: "Target", amount_range: [ 30, 150 ] }, + { name: "Coffee Shop", amount_range: [ 5, 15 ] }, + { name: "Department Store", amount_range: [ 50, 300 ] }, + { name: "Subscription Service", amount_range: [ 10, 50 ] } + ] + + 25.times do + merchant_data = credit_card_merchants.sample + transaction = create_transaction!( + account: account, + name: merchant_data[:name], + amount: expense_amount(merchant_data[:amount_range][0], merchant_data[:amount_range][1]), + date: random_date_within_days(90), + currency: account.currency, + category: categories.sample + ) + entries << transaction.entry + end + + entries + end + + def create_investment_trades!(account) + securities = Security.limit(3) + return [] unless securities.any? + + entries = [] + + trade_patterns = [ + { type: "buy", qty_range: [ 1, 50 ] }, + { type: "buy", qty_range: [ 10, 100 ] }, + { type: "sell", qty_range: [ 1, 25 ] } + ] + + 15.times do + security = securities.sample + pattern = trade_patterns.sample + + recent_price = security.prices.order(date: :desc).first&.price || 100.0 + + trade = create_trade!( + account: account, + security: security, + qty: rand(pattern[:qty_range][0]..pattern[:qty_range][1]), + price: recent_price * (0.95 + rand * 0.1), + date: random_date_within_days(90), + currency: account.currency + ) + entries << trade.entry + end + + entries + end + + def create_bulk_investment_trades!(account, count) + securities = Security.limit(5) + return [] unless securities.any? + + entries = [] + + count.times do |i| + security = securities.sample + recent_price = security.prices.order(date: :desc).first&.price || 100.0 + + trade = create_trade!( + account: account, + security: security, + qty: rand(1..100), + price: recent_price * (0.9 + rand * 0.2), + date: random_date_within_days(365), + currency: account.currency, + name: "Bulk Trade #{i + 1}" + ) + entries << trade.entry + end + + entries + end + + def create_bulk_transactions!(account, count, income: false, credit_card: false) + entries = [] + + # Handle credit cards specially to ensure balanced purchases and payments + if account.accountable_type == "CreditCard" + # Create a mix of purchases (positive) and payments (negative) + purchase_count = (count * 0.8).to_i # 80% purchases + payment_count = count - purchase_count # 20% payments + + total_purchases = 0 + + # Create purchases first + purchase_count.times do |i| + amount = expense_amount(10, 200) # Credit card purchases (positive) + total_purchases += amount + + transaction = create_transaction!( + account: account, + name: "Bulk CC Purchase #{i + 1}", + amount: amount, + date: random_date_within_days(365), + currency: account.currency + ) + entries << transaction.entry + end + + # Create reasonable payments (negative amounts) + # Payments should be smaller than total debt available + initial_debt = 5000 # From initial valuation + available_debt = initial_debt + total_purchases + + payment_count.times do |i| + # Payment should be reasonable portion of available debt + max_payment = [ available_debt * 0.1, 500 ].max # 10% of debt or min $500 + amount = -expense_amount(50, max_payment.to_i) # Payment (negative) + + transaction = create_transaction!( + account: account, + name: "Credit card payment #{i + 1}", + amount: amount, + date: random_date_within_days(365), + currency: account.currency + ) + entries << transaction.entry + end + + else + # Regular handling for non-credit card accounts + count.times do |i| + amount = if income + -income_amount # Income (negative) + elsif credit_card + expense_amount(10, 200) # This path shouldn't be reached for actual credit cards + else + expense_amount(5, 500) # Regular expenses (positive) + end + + name_prefix = if income + "Bulk Income" + elsif credit_card + "Bulk CC Purchase" + else + "Bulk Expense" + end + + transaction = create_transaction!( + account: account, + name: "#{name_prefix} #{i + 1}", + amount: amount, + date: random_date_within_days(365), + currency: account.currency + ) + entries << transaction.entry + end + end + + entries + end + + def expense_amount(min_or_range = :small, max = nil) + if min_or_range.is_a?(Symbol) + case min_or_range + when :small then random_amount(10, 200) + when :medium then random_amount(50, 500) + when :large then random_amount(200, 1000) + when :credit_card then random_amount(20, 300) + else random_amount(10, 500) + end + else + max_amount = max || (min_or_range + 100) + random_amount(min_or_range, max_amount) + end + end + + def income_amount(type = :salary) + case type + when :salary then random_amount(3000, 7000) + when :dividend then random_amount(100, 500) + when :interest then random_amount(50, 200) + else random_amount(1000, 5000) + end + end + + # Determine minimum realistic balance for account type + def minimum_realistic_balance(account) + case account.accountable_type + when "Depository" + account.subtype == "savings" ? 1000 : 500 + when "Investment" + 5000 + when "Property" + 100000 + when "Vehicle" + 5000 + when "OtherAsset" + 100 + when "CreditCard", "Loan", "OtherLiability" + 100 # Minimum debt + else + 100 + end + end +end diff --git a/app/models/demo/transfer_generator.rb b/app/models/demo/transfer_generator.rb new file mode 100644 index 00000000..3bd3939c --- /dev/null +++ b/app/models/demo/transfer_generator.rb @@ -0,0 +1,159 @@ +class Demo::TransferGenerator + include Demo::DataHelper + + def initialize + end + + def create_transfer_transactions!(family, count: 1) + accounts_by_type = group_accounts_by_type(family) + created_transfers = [] + + count.times do |i| + suffix = count > 1 ? "_#{i + 1}" : "" + + created_transfers.concat(create_credit_card_payments!(accounts_by_type, suffix: suffix)) + created_transfers.concat(create_investment_contributions!(accounts_by_type, suffix: suffix)) + created_transfers.concat(create_savings_transfers!(accounts_by_type, suffix: suffix)) + created_transfers.concat(create_loan_payments!(accounts_by_type, suffix: suffix)) + end + + created_transfers + end + + def create_transfer!(from_account:, to_account:, amount:, date:, description: "") + transfer = Transfer.from_accounts( + from_account: from_account, + to_account: to_account, + date: date, + amount: amount + ) + + transfer.inflow_transaction.entry.update!( + name: "#{description.presence || 'Transfer'} from #{from_account.name}" + ) + transfer.outflow_transaction.entry.update!( + name: "#{description.presence || 'Transfer'} to #{to_account.name}" + ) + + transfer.status = "confirmed" + transfer.save! + + transfer + end + + private + + def create_credit_card_payments!(accounts_by_type, suffix: "") + checking_accounts = accounts_by_type[:checking] + credit_cards = accounts_by_type[:credit_cards] + transfers = [] + + return transfers unless checking_accounts.any? && credit_cards.any? + + checking = checking_accounts.first + + credit_cards.each_with_index do |credit_card, index| + payment_amount = [ credit_card.balance.abs * 0.3, 500 ].max + payment_date = (7 + index * 3).days.ago.to_date + + transfer = create_transfer!( + from_account: checking, + to_account: credit_card, + amount: payment_amount, + date: payment_date, + description: "Credit card payment#{suffix}" + ) + transfers << transfer + end + + transfers + end + + def create_investment_contributions!(accounts_by_type, suffix: "") + checking_accounts = accounts_by_type[:checking] + investment_accounts = accounts_by_type[:investments] + transfers = [] + + return transfers unless checking_accounts.any? && investment_accounts.any? + + checking = checking_accounts.first + + investment_accounts.each_with_index do |investment, index| + contribution_amount = case investment.name + when /401k/i then 1500 + when /Roth/i then 500 + else 1000 + end + + contribution_date = (14 + index * 7).days.ago.to_date + + transfer = create_transfer!( + from_account: checking, + to_account: investment, + amount: contribution_amount, + date: contribution_date, + description: "Investment contribution#{suffix}" + ) + transfers << transfer + end + + transfers + end + + def create_savings_transfers!(accounts_by_type, suffix: "") + checking_accounts = accounts_by_type[:checking] + savings_accounts = accounts_by_type[:savings] + transfers = [] + + return transfers unless checking_accounts.any? && savings_accounts.any? + + checking = checking_accounts.first + + savings_accounts.each_with_index do |savings, index| + transfer_amount = 1000 + transfer_date = (21 + index * 5).days.ago.to_date + + transfer = create_transfer!( + from_account: checking, + to_account: savings, + amount: transfer_amount, + date: transfer_date, + description: "Savings transfer#{suffix}" + ) + transfers << transfer + end + + transfers + end + + def create_loan_payments!(accounts_by_type, suffix: "") + checking_accounts = accounts_by_type[:checking] + loans = accounts_by_type[:loans] + transfers = [] + + return transfers unless checking_accounts.any? && loans.any? + + checking = checking_accounts.first + + loans.each_with_index do |loan, index| + payment_amount = case loan.name + when /Mortgage/i then 2500 + when /Auto/i, /Car/i then 450 + else 500 + end + + payment_date = (28 + index * 2).days.ago.to_date + + transfer = create_transfer!( + from_account: checking, + to_account: loan, + amount: payment_amount, + date: payment_date, + description: "Loan payment#{suffix}" + ) + transfers << transfer + end + + transfers + end +end diff --git a/lib/tasks/demo_data.rake b/lib/tasks/demo_data.rake index c57a006f..b6ecb6b9 100644 --- a/lib/tasks/demo_data.rake +++ b/lib/tasks/demo_data.rake @@ -1,28 +1,39 @@ namespace :demo_data do - desc "Creates or resets demo data used in development environment" + desc "Creates a new user with no data. Use for testing empty data states." task empty: :environment do families = [ "Demo Family 1" ] Demo::Generator.new.reset_and_clear_data!(families) end + desc "Creates a new user who has to go through onboarding still. Use for testing onboarding flows." task new_user: :environment do families = [ "Demo Family 1" ] Demo::Generator.new.reset_and_clear_data!(families, require_onboarding: true) end + desc "General data reset that loads semi-realistic data" task :reset, [ :count ] => :environment do |t, args| count = (args[:count] || 1).to_i families = count.times.map { |i| "Demo Family #{i + 1}" } Demo::Generator.new.reset_data!(families) end + desc "Use this when you need to test multi-currency features of the app with a minimal setup" task multi_currency: :environment do families = [ "Demo Family 1", "Demo Family 2" ] Demo::Generator.new.generate_multi_currency_data!(families) end + desc "Use this when you want realistic budget data" task basic_budget: :environment do families = [ "Demo Family 1" ] Demo::Generator.new.generate_basic_budget_data!(families) end + + # DO NOT RUN THIS unless you're testing performance locally. It will take a long time to load/clear. Easiest to clear with a db:reset + desc "Generates realistic data for 500 families for performance testing. Creates 1 family with Ruby, then efficiently duplicates it 499 times using SQL bulk operations." + task performance_testing: :environment do + families = [ "Performance Family 1" ] + Demo::Generator.new.generate_performance_testing_data!(families) + end end -- 2.53.0 From cdad31812a273ec96e63aaeecc263e6bec237db8 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Quang <39359294+quanghuynguyen1902@users.noreply.github.com> Date: Thu, 12 Jun 2025 08:26:31 +0700 Subject: [PATCH 16/20] Fix user deletion foreign key constraint with invitations (#2357) --- app/models/user.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/user.rb b/app/models/user.rb index 19a17c87..398e4f0f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,6 +5,7 @@ class User < ApplicationRecord belongs_to :last_viewed_chat, class_name: "Chat", optional: true has_many :sessions, dependent: :destroy has_many :chats, dependent: :destroy + has_many :invitations, foreign_key: :inviter_id, dependent: :destroy has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy accepts_nested_attributes_for :family, update_only: true -- 2.53.0 From 84b2426e54d60a714a40e7f538e20ae4eb243550 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sat, 14 Jun 2025 11:53:53 -0400 Subject: [PATCH 17/20] Benchmarking setup (#2366) * Benchmarking setup * Get demo data working in benchmark scenario * Finalize default demo scenario * Finalize benchmarking setup --- .gitignore | 3 +- Gemfile | 8 +- Gemfile.lock | 30 + README.md | 2 +- app/models/demo/account_generator.rb | 238 --- app/models/demo/base_scenario.rb | 30 - app/models/demo/data_cleaner.rb | 3 - app/models/demo/data_helper.rb | 85 -- app/models/demo/generator.rb | 1289 +++++++++++++++-- app/models/demo/rule_generator.rb | 79 - app/models/demo/scenarios/basic_budget.rb | 129 -- app/models/demo/scenarios/clean_slate.rb | 126 -- app/models/demo/scenarios/default.rb | 77 - app/models/demo/scenarios/multi_currency.rb | 241 --- .../demo/scenarios/performance_testing.rb | 349 ----- app/models/demo/security_generator.rb | 76 - app/models/demo/transaction_generator.rb | 448 ------ app/models/demo/transfer_generator.rb | 159 -- db/schema.rb | 2 +- db/seeds.rb | 2 +- lib/tasks/benchmarking.rake | 154 ++ lib/tasks/demo_data.rake | 76 +- perf.rake | 37 + 23 files changed, 1477 insertions(+), 2166 deletions(-) delete mode 100644 app/models/demo/account_generator.rb delete mode 100644 app/models/demo/base_scenario.rb delete mode 100644 app/models/demo/data_helper.rb delete mode 100644 app/models/demo/rule_generator.rb delete mode 100644 app/models/demo/scenarios/basic_budget.rb delete mode 100644 app/models/demo/scenarios/clean_slate.rb delete mode 100644 app/models/demo/scenarios/default.rb delete mode 100644 app/models/demo/scenarios/multi_currency.rb delete mode 100644 app/models/demo/scenarios/performance_testing.rb delete mode 100644 app/models/demo/security_generator.rb delete mode 100644 app/models/demo/transaction_generator.rb delete mode 100644 app/models/demo/transfer_generator.rb create mode 100644 lib/tasks/benchmarking.rake create mode 100644 perf.rake diff --git a/.gitignore b/.gitignore index ca3ce84a..a37966ee 100644 --- a/.gitignore +++ b/.gitignore @@ -98,7 +98,8 @@ node_modules/ .taskmaster/config.json .taskmaster/templates tasks.json -tasks/ +.taskmaster/tasks/ +.taskmaster/reports/ *.mcp.json scripts/ .cursor/mcp.json diff --git a/Gemfile b/Gemfile index c06ca145..7d1fb988 100644 --- a/Gemfile +++ b/Gemfile @@ -37,7 +37,7 @@ gem "sentry-ruby" gem "sentry-rails" gem "sentry-sidekiq" gem "logtail-rails" -gem "skylight" +gem "skylight", groups: [ :production ] # Active Storage gem "aws-sdk-s3", "~> 1.177.0", require: false @@ -80,6 +80,10 @@ group :development, :test do gem "dotenv-rails" end +if ENV["BENCHMARKING_ENABLED"] + gem "dotenv-rails", groups: [ :production ] +end + group :development do gem "hotwire-livereload" gem "letter_opener" @@ -87,6 +91,8 @@ group :development do gem "web-console" gem "faker" gem "benchmark-ips" + gem "stackprof" + gem "derailed_benchmarks" gem "foreman" end diff --git a/Gemfile.lock b/Gemfile.lock index c9be7d74..2cc4b245 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -154,6 +154,24 @@ GEM debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) + derailed_benchmarks (2.2.1) + base64 + benchmark-ips (~> 2) + bigdecimal + drb + get_process_mem + heapy (~> 0) + logger + memory_profiler (>= 0, < 2) + mini_histogram (>= 0.3.0) + mutex_m + ostruct + rack (>= 1) + rack-test + rake (> 10, < 14) + ruby-statistics (>= 4.0.1) + ruby2_keywords + thor (>= 0.19, < 2) docile (1.4.1) dotenv (3.1.8) dotenv-rails (3.1.8) @@ -196,9 +214,14 @@ GEM fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) + get_process_mem (1.0.0) + bigdecimal (>= 2.0) + ffi (~> 1.0) globalid (1.2.1) activesupport (>= 6.1) hashdiff (1.2.0) + heapy (0.2.0) + thor highline (3.1.2) reline hotwire-livereload (2.0.0) @@ -292,7 +315,9 @@ GEM net-smtp marcel (1.0.4) matrix (0.4.2) + memory_profiler (1.1.0) method_source (1.1.0) + mini_histogram (0.3.1) mini_magick (5.2.0) benchmark logger @@ -302,6 +327,7 @@ GEM ruby2_keywords (>= 0.0.5) msgpack (1.8.0) multipart-post (2.4.1) + mutex_m (0.3.0) net-http (0.6.0) uri net-imap (0.5.8) @@ -473,6 +499,7 @@ GEM faraday (>= 1) faraday-multipart (>= 1) ruby-progressbar (1.13.0) + ruby-statistics (4.1.0) ruby-vips (2.2.4) ffi (~> 1.12) logger @@ -518,6 +545,7 @@ GEM activesupport (>= 5.2.0) smart_properties (1.17.0) sorbet-runtime (0.5.12163) + stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.7) @@ -596,6 +624,7 @@ DEPENDENCIES climate_control csv debug + derailed_benchmarks dotenv-rails erb_lint faker @@ -641,6 +670,7 @@ DEPENDENCIES sidekiq-cron simplecov skylight + stackprof stimulus-rails stripe tailwindcss-rails diff --git a/README.md b/README.md index 524f6f40..18602345 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ bin/setup bin/dev # Optionally, load demo data -rake demo_data:reset +rake demo_data:default ``` And visit http://localhost:3000 to see the app. You can use the following diff --git a/app/models/demo/account_generator.rb b/app/models/demo/account_generator.rb deleted file mode 100644 index 4516bea0..00000000 --- a/app/models/demo/account_generator.rb +++ /dev/null @@ -1,238 +0,0 @@ -class Demo::AccountGenerator - include Demo::DataHelper - - def create_credit_card_accounts!(family, count: 1) - accounts = [] - count.times do |i| - account = family.accounts.create!( - accountable: CreditCard.new, - name: account_name("Chase Credit Card", i, count), - balance: 0, - currency: "USD" - ) - accounts << account - end - accounts - end - - def create_checking_accounts!(family, count: 1) - accounts = [] - count.times do |i| - account = family.accounts.create!( - accountable: Depository.new, - name: account_name("Chase Checking", i, count), - balance: 0, - currency: "USD" - ) - accounts << account - end - accounts - end - - def create_savings_accounts!(family, count: 1) - accounts = [] - count.times do |i| - account = family.accounts.create!( - accountable: Depository.new, - name: account_name("Demo Savings", i, count), - balance: 0, - currency: "USD", - subtype: "savings" - ) - accounts << account - end - accounts - end - - def create_properties_and_mortgages!(family, count: 1) - accounts = [] - count.times do |i| - property = family.accounts.create!( - accountable: Property.new, - name: account_name("123 Maybe Way", i, count), - balance: 0, - currency: "USD" - ) - accounts << property - - mortgage = family.accounts.create!( - accountable: Loan.new, - name: account_name("Mortgage", i, count), - balance: 0, - currency: "USD" - ) - accounts << mortgage - end - accounts - end - - def create_vehicles_and_loans!(family, vehicle_count: 1, loan_count: 1) - accounts = [] - - vehicle_count.times do |i| - vehicle = family.accounts.create!( - accountable: Vehicle.new, - name: account_name("Honda Accord", i, vehicle_count), - balance: 0, - currency: "USD" - ) - accounts << vehicle - end - - loan_count.times do |i| - loan = family.accounts.create!( - accountable: Loan.new, - name: account_name("Car Loan", i, loan_count), - balance: 0, - currency: "USD" - ) - accounts << loan - end - - accounts - end - - def create_other_accounts!(family, asset_count: 1, liability_count: 1) - accounts = [] - - asset_count.times do |i| - asset = family.accounts.create!( - accountable: OtherAsset.new, - name: account_name("Other Asset", i, asset_count), - balance: 0, - currency: "USD" - ) - accounts << asset - end - - liability_count.times do |i| - liability = family.accounts.create!( - accountable: OtherLiability.new, - name: account_name("Other Liability", i, liability_count), - balance: 0, - currency: "USD" - ) - accounts << liability - end - - accounts - end - - def create_investment_accounts!(family, count: 3) - accounts = [] - - if count <= 3 - account_configs = [ - { name: "401(k)", balance: 0 }, - { name: "Roth IRA", balance: 0 }, - { name: "Taxable Brokerage", balance: 0 } - ] - - count.times do |i| - config = account_configs[i] || { - name: "Investment Account #{i + 1}", - balance: 0 - } - - account = family.accounts.create!( - accountable: Investment.new, - name: config[:name], - balance: config[:balance], - currency: "USD" - ) - accounts << account - end - else - count.times do |i| - account = family.accounts.create!( - accountable: Investment.new, - name: "Investment Account #{i + 1}", - balance: 0, - currency: "USD" - ) - accounts << account - end - end - - accounts - end - - private - - def realistic_balance(type, count = 1) - return send("realistic_#{type}_balance") if count == 1 - send("random_#{type}_balance") - end - def realistic_credit_card_balance - 2300 - end - - def realistic_checking_balance - 15000 - end - - def realistic_savings_balance - 40000 - end - - def realistic_property_balance - 560000 - end - - def realistic_mortgage_balance - 495000 - end - - def realistic_vehicle_balance - 18000 - end - - def realistic_car_loan_balance - 8000 - end - - def realistic_other_asset_balance - 10000 - end - - def realistic_other_liability_balance - 5000 - end - - - def random_credit_card_balance - random_positive_amount(1000, 5000) - end - - def random_checking_balance - random_positive_amount(10000, 50000) - end - - def random_savings_balance - random_positive_amount(50000, 200000) - end - - def random_property_balance - random_positive_amount(400000, 800000) - end - - def random_mortgage_balance - random_positive_amount(200000, 600000) - end - - def random_vehicle_balance - random_positive_amount(15000, 50000) - end - - def random_car_loan_balance - random_positive_amount(5000, 25000) - end - - def random_other_asset_balance - random_positive_amount(5000, 50000) - end - - def random_other_liability_balance - random_positive_amount(2000, 20000) - end -end diff --git a/app/models/demo/base_scenario.rb b/app/models/demo/base_scenario.rb deleted file mode 100644 index 27d9995a..00000000 --- a/app/models/demo/base_scenario.rb +++ /dev/null @@ -1,30 +0,0 @@ -# Base class for demo scenario handlers - subclasses must implement generate_family_data! -class Demo::BaseScenario - def initialize(generators) - @generators = generators - end - - def generate!(families, **options) - setup(**options) if respond_to?(:setup, true) - - families.each do |family| - ActiveRecord::Base.transaction do - generate_family_data!(family, **options) - end - puts "#{scenario_name} data created for #{family.name}" - end - end - - private - - def setup(**options) - end - - def generate_family_data!(family, **options) - raise NotImplementedError, "Subclasses must implement generate_family_data!(family, **options)" - end - - def scenario_name - self.class.name.split("::").last.downcase.gsub(/([a-z])([A-Z])/, '\1 \2') - end -end diff --git a/app/models/demo/data_cleaner.rb b/app/models/demo/data_cleaner.rb index 215de352..fbcf08e2 100644 --- a/app/models/demo/data_cleaner.rb +++ b/app/models/demo/data_cleaner.rb @@ -8,9 +8,6 @@ class Demo::DataCleaner # Main entry point for destroying all demo data def destroy_everything! - puts "Clearing existing data..." - - # Rails associations handle cascading deletes Family.destroy_all Setting.destroy_all InviteCode.destroy_all diff --git a/app/models/demo/data_helper.rb b/app/models/demo/data_helper.rb deleted file mode 100644 index 6a10d76e..00000000 --- a/app/models/demo/data_helper.rb +++ /dev/null @@ -1,85 +0,0 @@ -module Demo::DataHelper - COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a].freeze - - PERFORMANCE_TRANSACTION_COUNTS = { - depository_sample: 75, - credit_card_sample: 75, - investment_trades: 35, - investment_transactions: 35, - other_account_sample: 20 - }.freeze - - module_function - - def random_date_within_days(max_days_ago) - Faker::Number.between(from: 0, to: max_days_ago).days.ago.to_date - end - - def random_amount(min, max) - Faker::Number.between(from: min, to: max) - end - - def random_positive_amount(min, max) - Faker::Number.positive(from: min, to: max) - end - - def group_accounts_by_type(family) - accounts = family.accounts.includes(:accountable) - - { - checking: filter_checking_accounts(accounts), - savings: filter_savings_accounts(accounts), - credit_cards: filter_credit_card_accounts(accounts), - investments: filter_investment_accounts(accounts), - loans: filter_loan_accounts(accounts), - properties: filter_property_accounts(accounts), - vehicles: filter_vehicle_accounts(accounts), - other_assets: filter_other_asset_accounts(accounts), - other_liabilities: filter_other_liability_accounts(accounts) - } - end - - def filter_checking_accounts(accounts) - accounts.select { |a| a.accountable_type == "Depository" && (a.subtype != "savings" || a.name.include?("Checking")) } - end - - def filter_savings_accounts(accounts) - accounts.select { |a| a.accountable_type == "Depository" && (a.subtype == "savings" || a.name.include?("Savings")) } - end - - def filter_credit_card_accounts(accounts) - accounts.select { |a| a.accountable_type == "CreditCard" } - end - - def filter_investment_accounts(accounts) - accounts.select { |a| a.accountable_type == "Investment" } - end - - def filter_loan_accounts(accounts) - accounts.select { |a| a.accountable_type == "Loan" } - end - - def filter_property_accounts(accounts) - accounts.select { |a| a.accountable_type == "Property" } - end - - def filter_vehicle_accounts(accounts) - accounts.select { |a| a.accountable_type == "Vehicle" } - end - - def filter_other_asset_accounts(accounts) - accounts.select { |a| a.accountable_type == "OtherAsset" } - end - - def filter_other_liability_accounts(accounts) - accounts.select { |a| a.accountable_type == "OtherLiability" } - end - - def random_color - COLORS.sample - end - - def account_name(base_name, index, count = 1) - count == 1 ? base_name : "#{base_name} #{index + 1}" - end -end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 369b7d95..ee9cc14e 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -1,134 +1,1233 @@ class Demo::Generator - include Demo::DataHelper + # @param seed [Integer, String, nil] Seed value used to initialise the internal PRNG. If nil, the ENV variable DEMO_DATA_SEED will + # be honoured and default to a random seed when not present. + # + # Initialising an explicit PRNG gives us repeatable demo datasets while still + # allowing truly random data when the caller does not care about + # determinism. The global `Kernel.rand` and helpers like `Array#sample` + # will also be seeded so that *all* random behaviour inside this object – + # including library helpers that rely on Ruby's global RNG – follow the + # same deterministic sequence. + def initialize(seed: ENV.fetch("DEMO_DATA_SEED", nil)) + # Convert the seed to an Integer if one was provided, otherwise fall back + # to a random, but memoised, seed so the generator instance can report it + # back to callers when needed (e.g. for debugging a specific run). + @seed = seed.present? ? seed.to_i : Random.new_seed - # Public API - these methods are called by rake tasks and must be preserved - def reset_and_clear_data!(family_names, require_onboarding: false) - generate_for_scenario(:clean_slate, family_names, require_onboarding: require_onboarding) + # Internal PRNG instance – use this instead of the global RNG wherever we + # explicitly call `rand` inside the class. We override `rand` below so + # existing method bodies automatically delegate here without requiring + # widespread refactors. + @rng = Random.new(@seed) + + # Also seed Ruby's global RNG so helpers that rely on it (e.g. + # Array#sample, Kernel.rand in invoked libraries, etc.) remain + # deterministic for the lifetime of this generator instance. + srand(@seed) end - def reset_data!(family_names) - generate_for_scenario(:default, family_names) - end + # Expose the seed so callers can reproduce a run if necessary. + attr_reader :seed - def generate_performance_testing_data!(family_names) - generate_for_scenario(:performance_testing, family_names) - end - - def generate_basic_budget_data!(family_names) - generate_for_scenario(:basic_budget, family_names) - end - - def generate_multi_currency_data!(family_names) - generate_for_scenario(:multi_currency, family_names) - end + # --------------------------------------------------------------------------- + # Performance helpers + # --------------------------------------------------------------------------- private - # Registry pattern for clean scenario lookup and easy extensibility - def scenario_registry - @scenario_registry ||= { - clean_slate: Demo::Scenarios::CleanSlate, - default: Demo::Scenarios::Default, - basic_budget: Demo::Scenarios::BasicBudget, - multi_currency: Demo::Scenarios::MultiCurrency, - performance_testing: Demo::Scenarios::PerformanceTesting - }.freeze - end + # Simple timing helper. Pass a descriptive label and a block; the runtime + # will be printed automatically when the block completes. + # If max_seconds is provided, raise RuntimeError when the block exceeds that + # duration. Useful to keep CI/dev machines honest about demo-data perf. + def with_timing(label, max_seconds: nil) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + result = yield + duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + puts "⏱️ #{label} completed in #{duration.round(2)}s" - def generators - @generators ||= { - data_cleaner: Demo::DataCleaner.new, - rule_generator: Demo::RuleGenerator.new, - account_generator: Demo::AccountGenerator.new, - transaction_generator: Demo::TransactionGenerator.new, - security_generator: Demo::SecurityGenerator.new, - transfer_generator: Demo::TransferGenerator.new - } - end - - def generate_for_scenario(scenario_key, family_names, **options) - raise ArgumentError, "Scenario key is required" if scenario_key.nil? - raise ArgumentError, "Family names must be provided" if family_names.nil? || family_names.empty? - - scenario_class = scenario_registry[scenario_key] - unless scenario_class - raise ArgumentError, "Unknown scenario: #{scenario_key}. Available: #{scenario_registry.keys.join(', ')}" + if max_seconds && duration > max_seconds + raise "Demo::Generator ##{label} exceeded #{max_seconds}s (#{duration.round(2)}s)" end - puts "Starting #{scenario_key} scenario generation for #{family_names.length} families..." + result + end - clear_all_data! - create_families_and_users!(family_names, **options) - families = family_names.map { |name| Family.find_by(name: name) } + # Override Kernel#rand so *all* `rand` calls inside this instance (including + # those already present in the file) are routed through the seeded PRNG. + def rand(*args) + @rng.rand(*args) + end - scenario = scenario_class.new(generators) - scenario.generate!(families, **options) - - # Sync families after generation (except for performance testing) - unless scenario_key == :performance_testing - puts "Running account sync for generated data..." - families.each do |family| - family.accounts.each do |account| - sync = Sync.create!(syncable: account) - sync.perform - end - puts " - #{family.name} accounts synced (#{family.accounts.count} accounts)" + # Generate empty family - no financial data + def generate_empty_data!(skip_clear: false) + with_timing(__method__) do + unless skip_clear + puts "🧹 Clearing existing data..." + clear_all_data! end + + puts "πŸ‘₯ Creating empty family..." + create_family_and_users!("Demo Family", "user@maybe.local", onboarded: true, subscribed: true) + + puts "βœ… Empty demo data loaded successfully!" + end + end + + # Generate new user family - no financial data, needs onboarding + def generate_new_user_data!(skip_clear: false) + with_timing(__method__) do + unless skip_clear + puts "🧹 Clearing existing data..." + clear_all_data! + end + + puts "πŸ‘₯ Creating new user family..." + create_family_and_users!("Demo Family", "user@maybe.local", onboarded: false, subscribed: false) + + puts "βœ… New user demo data loaded successfully!" + end + end + + # Generate comprehensive realistic demo data with multi-currency + def generate_default_data!(skip_clear: false, email: "user@maybe.local") + if skip_clear + puts "⏭️ Skipping data clearing (appending new family)..." + else + puts "🧹 Clearing existing data..." + clear_all_data! end - puts "Demo data loaded successfully!" + with_timing(__method__, max_seconds: 1000) do + puts "πŸ‘₯ Creating demo family..." + family = create_family_and_users!("Demo Family", email, onboarded: true, subscribed: true) + + puts "πŸ“Š Creating realistic financial data..." + create_realistic_categories!(family) + create_realistic_accounts!(family) + create_realistic_transactions!(family) + # Auto-fill current-month budget based on recent spending averages + generate_budget_auto_fill!(family) + + puts "βœ… Realistic demo data loaded successfully!" + end + end + + # Multi-currency support (keeping existing functionality) + def generate_multi_currency_data!(family_names) + with_timing(__method__) do + generate_for_scenario(:multi_currency, family_names) + end end def clear_all_data! family_count = Family.count - - if family_count > 200 - raise "Too much data to clear efficiently (#{family_count} families found). " \ - "Please run 'bundle exec rails db:reset' instead to quickly reset the database, " \ - "then re-run your demo data task." + if family_count > 50 + raise "Too much data to clear efficiently (#{family_count} families). Run 'rails db:reset' instead." end - - generators[:data_cleaner].destroy_everything! + Demo::DataCleaner.new.destroy_everything! end - def create_families_and_users!(family_names, require_onboarding: false, currency: "USD") - family_names.each_with_index do |family_name, index| - create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", - currency: currency, require_onboarding: require_onboarding) - end - puts "Users reset" - end - - def create_family_and_user!(family_name, user_email, currency: "USD", require_onboarding: false) - base_uuid = "d99e3c6e-d513-4452-8f24-dc263f8528c0" - id = Digest::UUID.uuid_v5(base_uuid, family_name) - + def create_family_and_users!(family_name, email, onboarded:, subscribed:) family = Family.create!( - id: id, name: family_name, - currency: currency, + currency: "USD", locale: "en", country: "US", timezone: "America/New_York", date_format: "%m-%d-%Y" ) - family.start_subscription!("sub_1234567890") + family.start_subscription!("sub_demo_123") if subscribed - family.users.create! \ - email: user_email, - first_name: "Demo", - last_name: "User", + # Admin user + family.users.create!( + email: email, + first_name: "Demo (admin)", + last_name: "Maybe", role: "admin", password: "password", - onboarded_at: require_onboarding ? nil : Time.current + onboarded_at: onboarded ? Time.current : nil + ) - family.users.create! \ - email: "member_#{user_email}", - first_name: "Demo (member user)", - last_name: "User", + # Member user + family.users.create!( + email: "partner_#{email}", + first_name: "Demo (member)", + last_name: "Maybe", role: "member", password: "password", - onboarded_at: require_onboarding ? nil : Time.current + onboarded_at: onboarded ? Time.current : nil + ) + + family + end + + def create_realistic_categories!(family) + # Income categories (3 total) + @salary_cat = family.categories.create!(name: "Salary", color: "#10b981", classification: "income") + @freelance_cat = family.categories.create!(name: "Freelance", color: "#059669", classification: "income") + @investment_income_cat = family.categories.create!(name: "Investment Income", color: "#047857", classification: "income") + + # Expense categories with subcategories (12 total) + @housing_cat = family.categories.create!(name: "Housing", color: "#dc2626", classification: "expense") + @rent_cat = family.categories.create!(name: "Rent/Mortgage", parent: @housing_cat, color: "#b91c1c", classification: "expense") + @utilities_cat = family.categories.create!(name: "Utilities", parent: @housing_cat, color: "#991b1b", classification: "expense") + + @food_cat = family.categories.create!(name: "Food & Dining", color: "#ea580c", classification: "expense") + @groceries_cat = family.categories.create!(name: "Groceries", parent: @food_cat, color: "#c2410c", classification: "expense") + @restaurants_cat = family.categories.create!(name: "Restaurants", parent: @food_cat, color: "#9a3412", classification: "expense") + @coffee_cat = family.categories.create!(name: "Coffee & Takeout", parent: @food_cat, color: "#7c2d12", classification: "expense") + + @transportation_cat = family.categories.create!(name: "Transportation", color: "#2563eb", classification: "expense") + @gas_cat = family.categories.create!(name: "Gas", parent: @transportation_cat, color: "#1d4ed8", classification: "expense") + @car_payment_cat = family.categories.create!(name: "Car Payment", parent: @transportation_cat, color: "#1e40af", classification: "expense") + + @entertainment_cat = family.categories.create!(name: "Entertainment", color: "#7c3aed", classification: "expense") + @healthcare_cat = family.categories.create!(name: "Healthcare", color: "#db2777", classification: "expense") + @shopping_cat = family.categories.create!(name: "Shopping", color: "#059669", classification: "expense") + @travel_cat = family.categories.create!(name: "Travel", color: "#0891b2", classification: "expense") + @personal_care_cat = family.categories.create!(name: "Personal Care", color: "#be185d", classification: "expense") + + # Additional high-level expense categories to reach 13 top-level items + @insurance_cat = family.categories.create!(name: "Insurance", color: "#6366f1", classification: "expense") + @misc_cat = family.categories.create!(name: "Miscellaneous", color: "#6b7280", classification: "expense") + + # Interest expense bucket + @interest_cat = family.categories.create!(name: "Loan Interest", color: "#475569", classification: "expense") + end + + def create_realistic_accounts!(family) + # Checking accounts (USD) + @chase_checking = family.accounts.create!(accountable: Depository.new, name: "Chase Premier Checking", balance: 0, currency: "USD") + @ally_checking = family.accounts.create!(accountable: Depository.new, name: "Ally Online Checking", balance: 0, currency: "USD") + + # Savings account (USD) + @marcus_savings = family.accounts.create!(accountable: Depository.new, name: "Marcus High-Yield Savings", balance: 0, currency: "USD") + + # EUR checking (EUR) + @eu_checking = family.accounts.create!(accountable: Depository.new, name: "Deutsche Bank EUR Account", balance: 0, currency: "EUR") + + # Credit cards (USD) + @amex_gold = family.accounts.create!(accountable: CreditCard.new, name: "Amex Gold Card", balance: 0, currency: "USD") + @chase_sapphire = family.accounts.create!(accountable: CreditCard.new, name: "Chase Sapphire Reserve", balance: 0, currency: "USD") + + # Investment accounts (USD + GBP) + @vanguard_401k = family.accounts.create!(accountable: Investment.new, name: "Vanguard 401(k)", balance: 0, currency: "USD") + @schwab_brokerage = family.accounts.create!(accountable: Investment.new, name: "Charles Schwab Brokerage", balance: 0, currency: "USD") + @fidelity_roth_ira = family.accounts.create!(accountable: Investment.new, name: "Fidelity Roth IRA", balance: 0, currency: "USD") + @hsa_investment = family.accounts.create!(accountable: Investment.new, name: "Fidelity HSA Investment", balance: 0, currency: "USD") + @uk_isa = family.accounts.create!(accountable: Investment.new, name: "Vanguard UK ISA", balance: 0, currency: "GBP") + + # Property (USD) + @home = family.accounts.create!(accountable: Property.new, name: "Primary Residence", balance: 0, currency: "USD") + + # Vehicles (USD) + @honda_accord = family.accounts.create!(accountable: Vehicle.new, name: "2016 Honda Accord", balance: 0, currency: "USD") + @tesla_model3 = family.accounts.create!(accountable: Vehicle.new, name: "2021 Tesla Model 3", balance: 0, currency: "USD") + + # Crypto (USD) + @coinbase_usdc = family.accounts.create!(accountable: Crypto.new, name: "Coinbase USDC", balance: 0, currency: "USD") + + # Loans / Liabilities (USD) + @mortgage = family.accounts.create!(accountable: Loan.new, name: "Home Mortgage", balance: 0, currency: "USD") + @car_loan = family.accounts.create!(accountable: Loan.new, name: "Car Loan", balance: 0, currency: "USD") + @student_loan = family.accounts.create!(accountable: Loan.new, name: "Student Loan", balance: 0, currency: "USD") + + @personal_loc = family.accounts.create!(accountable: OtherLiability.new, name: "Personal Line of Credit", balance: 0, currency: "USD") + + # Other asset (USD) + @jewelry = family.accounts.create!(accountable: OtherAsset.new, name: "Jewelry Collection", balance: 0, currency: "USD") + end + + def create_realistic_transactions!(family) + load_securities! + + puts " πŸ“ˆ Generating salary history (12 years)..." + generate_salary_history! + + puts " 🏠 Generating housing transactions..." + generate_housing_transactions! + + puts " πŸ• Generating food & dining transactions..." + generate_food_transactions! + + puts " πŸš— Generating transportation transactions..." + generate_transportation_transactions! + + puts " 🎬 Generating entertainment transactions..." + generate_entertainment_transactions! + + puts " πŸ›’ Generating shopping transactions..." + generate_shopping_transactions! + + puts " βš•οΈ Generating healthcare transactions..." + generate_healthcare_transactions! + + puts " ✈️ Generating travel transactions..." + generate_travel_transactions! + + puts " πŸ’… Generating personal care transactions..." + generate_personal_care_transactions! + + puts " πŸ’° Generating investment transactions..." + generate_investment_transactions! + + puts " 🏑 Generating major purchases..." + generate_major_purchases! + + puts " πŸ’³ Generating transfers and payments..." + generate_transfers_and_payments! + + puts " 🏦 Generating loan payments..." + generate_loan_payments! + + puts " 🧾 Generating regular expense baseline..." + generate_regular_expenses! + + puts " πŸ—„οΈ Generating legacy historical data..." + generate_legacy_transactions! + + puts " πŸ”’ Generating crypto & misc asset transactions..." + generate_crypto_and_misc_assets! + + puts " βœ… Reconciling balances to target snapshot..." + reconcile_balances!(family) + + puts " πŸ“Š Generated approximately #{Entry.joins(:account).where(accounts: { family_id: family.id }).count} transactions" + + puts "πŸ”„ Final sync to calculate adjusted balances..." + sync_family_accounts!(family) + end + + # Auto-fill current-month budget based on recent spending averages + def generate_budget_auto_fill!(family) + current_month = Date.current.beginning_of_month + analysis_start = (current_month - 3.months).beginning_of_month + analysis_period = analysis_start..(current_month - 1.day) + + # Fetch expense transactions in the analysis period + txns = Entry.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id") + .joins("INNER JOIN categories ON categories.id = transactions.category_id") + .where(entries: { entryable_type: "Transaction", date: analysis_period }) + .where(categories: { classification: "expense" }) + + spend_per_cat = txns.group("categories.id").sum("entries.amount") + + budget = family.budgets.where(start_date: current_month).first_or_initialize + budget.update!( + end_date: current_month.end_of_month, + currency: "USD", + budgeted_spending: spend_per_cat.values.sum / 3.0, # placeholder, refine below + expected_income: 0 # Could compute similarly if desired + ) + + spend_per_cat.each do |cat_id, total| + avg = total / 3.0 + rounded = ((avg / 25.0).round) * 25 + category = Category.find(cat_id) + budget.budget_categories.find_or_create_by!(category: category) do |bc| + bc.budgeted_spending = rounded + bc.currency = "USD" + end + end + + # Update aggregate budgeted_spending to sum of categories + budget.update!(budgeted_spending: budget.budget_categories.sum(:budgeted_spending)) + end + + # Helper method to get weighted random date (favoring recent years) + def weighted_random_date + # Focus on last 3 years for transaction generation + rand(3.years.ago.to_date..Date.current) + end + + # Helper method to get random accounts for transactions + def random_checking_account + [ @chase_checking, @ally_checking ].sample + end + + # --------------------------------------------------------------------------- + # Payroll system β€” 156 deterministic deposits (bi-weekly, six years) + # --------------------------------------------------------------------------- + def generate_salary_history! + deposit_amount = 8_500 # Increased from 4,200 to ~$200k annually + total_deposits = 78 # Reduced from 156 (only 3 years instead of 6) + + # Find first Friday β‰₯ 3.years.ago so the cadence remains bi-weekly. + first_date = 3.years.ago.to_date + first_date += 1 until first_date.friday? + + total_deposits.times do |i| + date = first_date + (14 * i) + break if date > Date.current # safety + + amount = -jitter(deposit_amount, 0.02).round # negative inflow per conventions + create_transaction!(@chase_checking, amount, "Acme Corp Payroll", @salary_cat, date) + + # 10 % automated savings transfer to Marcus Savings same day + savings_amount = (-amount * 0.10).round + create_transfer!(@chase_checking, @marcus_savings, savings_amount, "Auto-Save 10% of Paycheck", date) + end + + # Add freelance income to help balance expenses + 15.times do + date = weighted_random_date + amount = -rand(1500..4000) # Negative for income + create_transaction!(@chase_checking, amount, "Freelance Project", @freelance_cat, date) + end + + # Add quarterly investment dividends + (3.years.ago.to_date..Date.current).each do |date| + next unless date.day == 15 && [ 3, 6, 9, 12 ].include?(date.month) # Quarterly + dividend_amount = -rand(800..1500) # Negative for income + create_transaction!(@chase_checking, dividend_amount, "Investment Dividends", @investment_income_cat, date) + end + + # Add more regular freelance income to maintain positive checking balance + 40.times do # Increased from 15 + date = weighted_random_date + amount = -rand(800..2500) # More frequent, smaller freelance income + create_transaction!(@chase_checking, amount, "Freelance Payment", @freelance_cat, date) + end + + # Add side income streams + 25.times do + date = weighted_random_date + amount = -rand(200..800) + income_types = [ "Cash Tips", "Selling Items", "Refund", "Rebate", "Gift Card Cash Out" ] + create_transaction!(@chase_checking, amount, income_types.sample, @freelance_cat, date) + end + end + + def generate_housing_transactions! + start_date = 3.years.ago.to_date # Reduced from 12 years + base_rent = 2500 # Higher starting amount for higher income family + + # Monthly rent/mortgage payments + (start_date..Date.current).each do |date| + next unless date.day == 1 # First of month + + # Mortgage payment from checking account (positive expense) + create_transaction!(@chase_checking, 2800, "Mortgage Payment", @rent_cat, date) + # Principal payment reduces mortgage debt (negative transaction) + principal_payment = 800 # ~$800 goes to principal + create_transaction!(@mortgage, -principal_payment, "Principal Payment", nil, date) + end + + # Monthly utilities (reduced frequency) + utilities = [ + { name: "ConEd Electric", range: 150..300 }, + { name: "Verizon Internet", range: 85..105 }, + { name: "Water & Sewer", range: 60..90 }, + { name: "Gas Bill", range: 80..220 } + ] + + utilities.each do |utility| + (start_date..Date.current).each do |date| + next unless date.day.between?(5, 15) && rand < 0.9 # Monthly with higher frequency + amount = rand(utility[:range]) + create_transaction!(@chase_checking, amount, utility[:name], @utilities_cat, date) + end + end + end + + def generate_food_transactions! + # Weekly groceries (increased volume but kept amounts reasonable) + 120.times do # Increased from 60 + date = weighted_random_date + amount = rand(60..180) # Reduced max from 220 + stores = [ "Whole Foods", "Trader Joe's", "Safeway", "Stop & Shop", "Fresh Market" ] + create_transaction!(@chase_checking, amount, "#{stores.sample} Market", @groceries_cat, date) + end + + # Restaurant dining (increased volume) + 100.times do # Increased from 50 + date = weighted_random_date + amount = rand(25..65) # Reduced max from 80 + restaurants = [ "Pizza Corner", "Sushi Place", "Italian Kitchen", "Mexican Grill", "Greek Taverna" ] + create_transaction!(@chase_checking, amount, restaurants.sample, @restaurants_cat, date) + end + + # Coffee & takeout (increased volume) + 80.times do # Increased from 40 + date = weighted_random_date + amount = rand(8..20) # Reduced from 10-25 + places = [ "Local Coffee", "Dunkin'", "Corner Deli", "Food Truck" ] + create_transaction!(@chase_checking, amount, places.sample, @coffee_cat, date) + end + end + + def generate_transportation_transactions! + # Gas stations (checking account only) + 60.times do + date = weighted_random_date + amount = rand(35..75) + stations = [ "Shell", "Exxon", "BP", "Chevron", "Mobil", "Sunoco" ] + create_transaction!(@chase_checking, amount, "#{stations.sample} Gas", @gas_cat, date) + end + + # Car payment (monthly for 6 years) + car_payment_start = 6.years.ago.to_date + car_payment_end = 1.year.ago.to_date + + (car_payment_start..car_payment_end).each do |date| + next unless date.day == 15 # 15th of month + create_transaction!(@chase_checking, 385, "Auto Loan Payment", @car_payment_cat, date) + end + end + + def generate_entertainment_transactions! + # Monthly subscriptions (increased timeframe) + subscriptions = [ + { name: "Netflix", amount: 15 }, + { name: "Spotify Premium", amount: 12 }, + { name: "Disney+", amount: 8 }, + { name: "HBO Max", amount: 16 }, + { name: "Amazon Prime", amount: 14 } + ] + + subscriptions.each do |sub| + (3.years.ago.to_date..Date.current).each do |date| # Reduced from 12 years + next unless date.day == rand(1..28) && rand < 0.9 # Higher frequency for active subscriptions + create_transaction!(@chase_checking, sub[:amount], sub[:name], @entertainment_cat, date) + end + end + + # Random entertainment (increased volume) + 60.times do # Increased from 25 + date = weighted_random_date + amount = rand(15..60) # Reduced from 20-80 + activities = [ "Movie Theater", "Sports Game", "Museum", "Comedy Club", "Bowling", "Mini Golf", "Arcade" ] + create_transaction!(@chase_checking, amount, activities.sample, @entertainment_cat, date) + end + end + + def generate_shopping_transactions! + # Online shopping (increased volume) + 80.times do # Increased from 40 + date = weighted_random_date + amount = rand(30..90) # Reduced max from 120 + stores = [ "Target.com", "Walmart", "Costco" ] + create_transaction!(@chase_checking, amount, "#{stores.sample} Purchase", @shopping_cat, date) + end + + # In-store shopping (increased volume) + 60.times do # Increased from 25 + date = weighted_random_date + amount = rand(35..80) # Reduced max from 100 + stores = [ "Target", "REI", "Barnes & Noble", "GameStop" ] + create_transaction!(@chase_checking, amount, stores.sample, @shopping_cat, date) + end + end + + def generate_healthcare_transactions! + # Doctor visits (increased volume) + 45.times do # Increased from 25 + date = weighted_random_date + amount = rand(150..350) # Reduced from 180-450 + providers = [ "Dr. Smith", "Dr. Johnson", "Dr. Williams", "Specialist Visit", "Urgent Care" ] + create_transaction!(@chase_checking, amount, providers.sample, @healthcare_cat, date) + end + + # Pharmacy (increased volume) + 80.times do # Increased from 40 + date = weighted_random_date + amount = rand(12..65) # Reduced from 15-85 + pharmacies = [ "CVS Pharmacy", "Walgreens", "Rite Aid", "Local Pharmacy" ] + create_transaction!(@chase_checking, amount, pharmacies.sample, @healthcare_cat, date) + end + end + + def generate_travel_transactions! + # Major vacations (reduced count - premium travel handled in credit card cycles) + 8.times do + date = weighted_random_date + + # Smaller local trips from checking + hotel_amount = rand(200..500) + hotels = [ "Local Hotel", "B&B", "Nearby Resort" ] + if rand < 0.3 && date > 3.years.ago.to_date # Some EUR transactions + create_transaction!(@eu_checking, hotel_amount, hotels.sample, @travel_cat, date) + else + create_transaction!(@chase_checking, hotel_amount, hotels.sample, @travel_cat, date) + end + + # Domestic flights (smaller amounts) + flight_amount = rand(200..400) + create_transaction!(@chase_checking, flight_amount, "Domestic Flight", @travel_cat, date + rand(1..5).days) + + # Local activities + activity_amount = rand(50..150) + activities = [ "Local Tour", "Museum Tickets", "Activity Pass" ] + create_transaction!(@chase_checking, activity_amount, activities.sample, @travel_cat, date + rand(1..7).days) + end + end + + def generate_personal_care_transactions! + # Gym membership + (12.years.ago.to_date..Date.current).each do |date| + next unless date.day == 1 && rand < 0.8 # Monthly + create_transaction!(@chase_checking, 45, "Gym Membership", @personal_care_cat, date) + end + + # Beauty/grooming (checking account only) + 40.times do + date = weighted_random_date + amount = rand(25..80) + services = [ "Hair Salon", "Barber Shop", "Nail Salon" ] + create_transaction!(@chase_checking, amount, services.sample, @personal_care_cat, date) + end + end + + def generate_investment_transactions! + security = Security.first || Security.create!(ticker: "VTI", name: "Vanguard Total Stock Market ETF", country_code: "US") + + generate_401k_trades!(security) + generate_brokerage_trades!(security) + generate_roth_trades!(security) + generate_uk_isa_trades!(security) + end + + # ---------------------------------------------------- 401k (180 trades) -- + def generate_401k_trades!(security) + payroll_dates = collect_payroll_dates.first(90) # 90 paydays β‡’ 180 trades + + payroll_dates.each do |date| + # Employee contribution $1 200 + create_trade_for(@vanguard_401k, security, 1_200, date, "401k Employee") + + # Employer match $300 + create_trade_for(@vanguard_401k, security, 300, date, "401k Employer Match") + end + end + + # -------------------------------------------- Brokerage (144 trades) ----- + def generate_brokerage_trades!(security) + date_cursor = 36.months.ago.beginning_of_month + while date_cursor <= Date.current + 4.times do |i| + trade_date = date_cursor + i * 7.days # roughly spread within month + create_trade_for(@schwab_brokerage, security, rand(400..1_000), trade_date, "Brokerage Purchase") + end + date_cursor = date_cursor.next_month.beginning_of_month + end + end + + # ----------------------------------------------- Roth IRA (108 trades) --- + def generate_roth_trades!(security) + date_cursor = 36.months.ago.beginning_of_month + while date_cursor <= Date.current + # Split $500 monthly across 3 staggered trades + 3.times do |i| + trade_date = date_cursor + i * 10.days + create_trade_for(@fidelity_roth_ira, security, (500 / 3.0), trade_date, "Roth IRA Contribution") + end + date_cursor = date_cursor.next_month.beginning_of_month + end + end + + # ------------------------------------------------- UK ISA (108 trades) ---- + def generate_uk_isa_trades!(security) + date_cursor = 36.months.ago.beginning_of_month + while date_cursor <= Date.current + 3.times do |i| + trade_date = date_cursor + i * 10.days + create_trade_for(@uk_isa, security, (400 / 3.0), trade_date, "ISA Investment", price_range: 60..150) + end + date_cursor = date_cursor.next_month.beginning_of_month + end + end + + # --------------------------- Helpers for investment trade generation ----- + def collect_payroll_dates + dates = [] + d = 36.months.ago.to_date + d += 1 until d.friday? + while d <= Date.current + dates << d if d.cweek.even? + d += 14 # next bi-weekly + end + dates + end + + def create_trade_for(account, security, investment_amount, date, memo, price_range: 80..200) + price = rand(price_range) + qty = (investment_amount.to_f / price).round(2) + create_investment_transaction!(account, security, qty, price, date, memo) + end + + def generate_major_purchases! + # Home purchase (5 years ago) - only record the down payment, not full value + # Property value will be set by valuation in reconcile_balances! + home_date = 5.years.ago.to_date + create_transaction!(@chase_checking, 70_000, "Home Down Payment", @housing_cat, home_date) + create_transaction!(@mortgage, 320_000, "Mortgage Principal", nil, home_date) # Initial mortgage debt + + # Initial account funding (realistic amounts) + create_transaction!(@chase_checking, -5_000, "Initial Deposit", @salary_cat, 12.years.ago.to_date) + create_transaction!(@ally_checking, -2_000, "Initial Deposit", @salary_cat, 12.years.ago.to_date) + create_transaction!(@marcus_savings, -10_000, "Initial Savings", @salary_cat, 12.years.ago.to_date) + create_transaction!(@eu_checking, -5_000, "EUR Account Opening", nil, 4.years.ago.to_date) + + # Car purchases (realistic amounts) + create_transaction!(@chase_checking, 3_000, "Car Down Payment", @transportation_cat, 6.years.ago.to_date) + create_transaction!(@chase_checking, 2_500, "Second Car Down Payment", @transportation_cat, 8.years.ago.to_date) + + # Major but realistic expenses + create_transaction!(@chase_checking, 8_000, "Kitchen Renovation", @utilities_cat, 2.years.ago.to_date) + create_transaction!(@chase_checking, 5_000, "Bathroom Remodel", @utilities_cat, 1.year.ago.to_date) + create_transaction!(@chase_checking, 12_000, "Roof Replacement", @utilities_cat, 3.years.ago.to_date) + create_transaction!(@chase_checking, 8_000, "Family Emergency", @healthcare_cat, 4.years.ago.to_date) + create_transaction!(@chase_checking, 15_000, "Wedding Expenses", @entertainment_cat, 9.years.ago.to_date) + end + + def generate_transfers_and_payments! + generate_credit_card_cycles! + + generate_monthly_ally_transfers! + generate_quarterly_fx_transfers! + generate_additional_savings_transfers! + end + + # Additional savings transfers to improve income/expense balance + def generate_additional_savings_transfers! + # Monthly extra savings transfers + (3.years.ago.to_date..Date.current).each do |date| + next unless date.day == 15 && rand < 0.7 # Semi-monthly savings + amount = rand(500..1500) + create_transfer!(@chase_checking, @marcus_savings, amount, "Extra Savings Transfer", date) + end + + # Quarterly HSA contributions + (3.years.ago.to_date..Date.current).each do |date| + next unless date.day == 1 && [ 1, 4, 7, 10 ].include?(date.month) # Quarterly + amount = rand(1000..2000) + create_transfer!(@chase_checking, @hsa_investment, amount, "HSA Contribution", date) + end + + # Occasional windfalls (tax refunds, bonuses, etc.) + 8.times do + date = weighted_random_date + amount = rand(2000..8000) + create_transaction!(@chase_checking, -amount, "Tax Refund/Bonus", @salary_cat, date) + end + + # CRITICAL: Regular transfers FROM savings TO checking to maintain positive balance + # This is realistic - people move money from savings to checking regularly + (3.years.ago.to_date..Date.current).each do |date| + next unless date.day == rand(20..28) && rand < 0.8 # Monthly transfers from savings + amount = rand(2000..5000) + create_transfer!(@marcus_savings, @chase_checking, amount, "Transfer from Savings", date) + end + + # Weekly smaller transfers from savings for cash flow + (3.years.ago.to_date..Date.current).each do |date| + next unless date.wday == 1 && rand < 0.4 # Some Mondays + amount = rand(500..1200) + create_transfer!(@marcus_savings, @chase_checking, amount, "Weekly Cash Flow", date) + end + end + + # $300 from Chase Checking to Ally Checking on the first business day of each + # month for the past 36 months. + def generate_monthly_ally_transfers! + date_cursor = 36.months.ago.beginning_of_month + while date_cursor <= Date.current + transfer_date = first_business_day(date_cursor) + create_transfer!(@chase_checking, @ally_checking, 300, "Monthly Ally Transfer", transfer_date) + date_cursor = date_cursor.next_month.beginning_of_month + end + end + + # Quarterly $2 000 FX transfer from Chase Checking to EUR account + def generate_quarterly_fx_transfers! + date_cursor = 36.months.ago.beginning_of_quarter + while date_cursor <= Date.current + transfer_date = date_cursor + 2.days # arbitrary within quarter start + create_transfer!(@chase_checking, @eu_checking, 2_000, "Quarterly FX Transfer", transfer_date) + date_cursor = date_cursor.next_quarter.beginning_of_quarter + end + end + + # Returns the first weekday (Mon-Fri) of the month containing +date+. + def first_business_day(date) + d = date.beginning_of_month + d += 1.day while d.saturday? || d.sunday? + d + end + + def generate_credit_card_cycles! + # REDUCED: 30-45 charges per month across both cards for 36 months (β‰ˆ1,400 total) + # This is still significant but more realistic than 80-120/month + # Pay 90-95 % of new balance 5 days post-cycle; final balances should + # be ~$2 500 (Amex) and ~$4 200 (Sapphire). + + start_date = 36.months.ago.beginning_of_month + end_date = Date.current.end_of_month + + amex_balance = 0 + sapphire_balance = 0 + + charges_this_run = 0 + payments_this_run = 0 + + date_cursor = start_date + while date_cursor <= end_date + # --- Charge generation (REDUCED FOR BALANCE) ------------------------- + month_charge_target = rand(30..45) # Reduced from 80-120 to 30-45 + # Split roughly evenly but add a little variance. + amex_count = (month_charge_target * rand(0.45..0.55)).to_i + sapphire_count = month_charge_target - amex_count + + amex_total = generate_credit_card_charges(@amex_gold, date_cursor, amex_count) + sapphire_total = generate_credit_card_charges(@chase_sapphire, date_cursor, sapphire_count) + + amex_balance += amex_total + sapphire_balance += sapphire_total + + charges_this_run += (amex_count + sapphire_count) + + # --- Monthly payments (5 days after month end) ------------------------ + payment_date = (date_cursor.end_of_month + 5.days) + + if amex_total.positive? + amex_payment = (amex_total * rand(0.90..0.95)).round + create_transfer!(@chase_checking, @amex_gold, amex_payment, "Amex Payment", payment_date) + amex_balance -= amex_payment + payments_this_run += 1 + end + + if sapphire_total.positive? + sapphire_payment = (sapphire_total * rand(0.90..0.95)).round + create_transfer!(@chase_checking, @chase_sapphire, sapphire_payment, "Sapphire Payment", payment_date) + sapphire_balance -= sapphire_payment + payments_this_run += 1 + end + + date_cursor = date_cursor.next_month.beginning_of_month + end + + # ----------------------------------------------------------------------- + # Re-balance to hit target ending balances (tolerance Β±$250) + # ----------------------------------------------------------------------- + target_amex = 2_500 + target_sapphire = 4_200 + + diff_amex = amex_balance - target_amex + diff_sapphire = sapphire_balance - target_sapphire + + if diff_amex.abs > 250 + adjust_payment = diff_amex.positive? ? diff_amex : 0 + create_transfer!(@chase_checking, @amex_gold, adjust_payment, "Amex Balance Adjust", Date.current) + amex_balance -= adjust_payment + end + + if diff_sapphire.abs > 250 + adjust_payment = diff_sapphire.positive? ? diff_sapphire : 0 + create_transfer!(@chase_checking, @chase_sapphire, adjust_payment, "Sapphire Balance Adjust", Date.current) + sapphire_balance -= adjust_payment + end + + puts " πŸ’³ Charges generated: #{charges_this_run} | Payments: #{payments_this_run}" + puts " πŸ’³ Final Amex balance: ~$#{amex_balance} | target ~$#{target_amex}" + puts " πŸ’³ Final Sapphire balance: ~$#{sapphire_balance} | target ~$#{target_sapphire}" + end + + # Generate exactly +count+ charges on +account+ within the month of +base_date+. + # Returns total charge amount. + def generate_credit_card_charges(account, base_date, count) + total = 0 + + count.times do + charge_date = base_date + rand(0..27).days + + amount = rand(15..80) # Reduced from 25..150 due to higher frequency + # bias amounts to achieve reasonable monthly totals + amount = jitter(amount, 0.15).round + + merchant = if account == @amex_gold + pick(%w[WholeFoods Starbucks UberEats Netflix LocalBistro AirBnB]) + else + pick([ "Delta Airlines", "Hilton Hotels", "Expedia", "Apple", "BestBuy", "Amazon" ]) + end + + create_transaction!(account, amount, merchant, random_expense_category, charge_date) + total += amount + end + + total + end + + def random_expense_category + [ @food_cat, @entertainment_cat, @shopping_cat, @travel_cat, @transportation_cat ].sample + end + + def create_transaction!(account, amount, name, category, date) + # For credit cards (liabilities), positive amounts = charges (increase debt) + # For checking accounts (assets), positive amounts = expenses (decrease balance) + # The amount is already signed correctly by the caller + account.entries.create!( + entryable: Transaction.new(category: category), + amount: amount, + name: name, + currency: account.currency, + date: date + ) + end + + def create_investment_transaction!(account, security, qty, price, date, name) + account.entries.create!( + entryable: Trade.new(security: security, qty: qty, price: price, currency: account.currency), + amount: -(qty * price), + name: name, + currency: account.currency, + date: date + ) + end + + def create_transfer!(from_account, to_account, amount, name, date) + outflow = from_account.entries.create!( + entryable: Transaction.new, + amount: amount, + name: name, + currency: from_account.currency, + date: date + ) + inflow = to_account.entries.create!( + entryable: Transaction.new, + amount: -amount, + name: name, + currency: to_account.currency, + date: date + ) + Transfer.create!(inflow_transaction: inflow.entryable, outflow_transaction: outflow.entryable) + end + + def load_securities! + return if Security.exists? + + Security.create!([ + { ticker: "VTI", name: "Vanguard Total Stock Market ETF", country_code: "US" }, + { ticker: "VXUS", name: "Vanguard Total International Stock ETF", country_code: "US" }, + { ticker: "BND", name: "Vanguard Total Bond Market ETF", country_code: "US" } + ]) + end + + def sync_family_accounts!(family) + family.accounts.each do |account| + sync = Sync.create!(syncable: account) + sync.perform + end + end + + # --------------------------------------------------------------------------- + # Deterministic helper methods + # --------------------------------------------------------------------------- + + # Deterministically walk through the elements of +array+, returning the next + # element each time it is called with the *same* array instance. + # + # Example: + # colours = %w[red green blue] + # 4.times.map { pick(colours) } #=> ["red", "green", "blue", "red"] + def pick(array) + @pick_indices ||= Hash.new(0) + idx = @pick_indices[array.object_id] + @pick_indices[array.object_id] += 1 + array[idx % array.length] + end + + # Adds a small random variation (Β±pct, default 3%) to +num+. Useful for + # making otherwise deterministic amounts look more natural while retaining + # overall reproducibility via the seeded RNG. + def jitter(num, pct = 0.03) + variation = num * pct * (rand * 2 - 1) # rand(-pct..pct) + (num + variation).round(2) + end + + # --------------------------------------------------------------------------- + # Loan payments (Task 8) + # --------------------------------------------------------------------------- + def generate_loan_payments! + date_cursor = 36.months.ago.beginning_of_month + while date_cursor <= Date.current + payment_date = first_business_day(date_cursor) + + # Mortgage + make_loan_payment!( + principal_account: @mortgage, + principal_amount: 600, + interest_amount: 1_100, + interest_category: @housing_cat, + date: payment_date, + memo: "Mortgage Payment" + ) + + # Student loan + make_loan_payment!( + principal_account: @student_loan, + principal_amount: 350, + interest_amount: 100, + interest_category: @interest_cat, + date: payment_date, + memo: "Student Loan Payment" + ) + + # Car loan – assume 300 principal / 130 interest + make_loan_payment!( + principal_account: @car_loan, + principal_amount: 300, + interest_amount: 130, + interest_category: @transportation_cat, + date: payment_date, + memo: "Auto Loan Payment" + ) + + date_cursor = date_cursor.next_month.beginning_of_month + end + end + + def make_loan_payment!(principal_account:, principal_amount:, interest_amount:, interest_category:, date:, memo:) + # Principal portion – transfer from checking to loan account + create_transfer!(@chase_checking, principal_account, principal_amount, memo, date) + + # Interest portion – expense from checking + create_transaction!(@chase_checking, interest_amount, "#{memo} Interest", interest_category, date) + end + + # Generate additional baseline expenses to reach 8k-12k transaction target + def generate_regular_expenses! + expense_generators = [ + ->(date) { create_transaction!(@chase_checking, jitter(rand(150..220), 0.05).round, pick([ "ConEd Electric", "National Grid", "Gas & Power" ]), @utilities_cat, date) }, + ->(date) { create_transaction!(@chase_checking, jitter(rand(10..20), 0.1).round, pick([ "Spotify", "Netflix", "Hulu", "Apple One" ]), @entertainment_cat, date) }, + ->(date) { create_transaction!(@chase_checking, jitter(rand(45..90), 0.1).round, pick([ "Whole Foods", "Trader Joe's", "Safeway" ])+" Market", @groceries_cat, date) }, + ->(date) { create_transaction!(@chase_checking, jitter(rand(25..50), 0.1).round, pick([ "Shell Gas", "BP Gas", "Exxon" ]), @gas_cat, date) }, + ->(date) { create_transaction!(@chase_checking, jitter(rand(15..40), 0.1).round, pick([ "Movie Streaming", "Book Purchase", "Mobile Game" ]), @entertainment_cat, date) } + ] + + desired = 600 # Increased from 300 to help reach 8k + current = Entry.joins(:account).where(accounts: { id: [ @chase_checking.id ] }, entryable_type: "Transaction").count + to_create = [ desired - current, 0 ].max + + to_create.times do + date = weighted_random_date + expense_generators.sample.call(date) + end + + # Add high-volume, low-impact transactions to reach 8k minimum + generate_micro_transactions! + end + + # Generate many small transactions to reach volume target + def generate_micro_transactions! + # ATM withdrawals and fees (reduced) + 120.times do # Reduced from 200 + date = weighted_random_date + amount = rand(20..60) + create_transaction!(@chase_checking, amount, "ATM Withdrawal", @misc_cat, date) + # Small ATM fee + create_transaction!(@chase_checking, rand(2..4), "ATM Fee", @misc_cat, date) + end + + # Small convenience store purchases (reduced) + 200.times do # Reduced from 300 + date = weighted_random_date + amount = rand(3..15) + stores = [ "7-Eleven", "Wawa", "Circle K", "Quick Stop", "Corner Store" ] + create_transaction!(@chase_checking, amount, stores.sample, @shopping_cat, date) + end + + # Small digital purchases (reduced) + 120.times do # Reduced from 200 + date = weighted_random_date + amount = rand(1..10) + items = [ "App Store", "Google Play", "iTunes", "Steam", "Kindle Book" ] + create_transaction!(@chase_checking, amount, items.sample, @entertainment_cat, date) + end + + # Parking meters and tolls (reduced) + 100.times do # Reduced from 150 + date = weighted_random_date + amount = rand(2..8) + create_transaction!(@chase_checking, amount, pick([ "Parking Meter", "Bridge Toll", "Tunnel Toll" ]), @transportation_cat, date) + end + + # Small cash transactions (reduced) + 150.times do # Reduced from 250 + date = weighted_random_date + amount = rand(5..25) + vendors = [ "Food Truck", "Farmer's Market", "Street Vendor", "Tip", "Donation" ] + create_transaction!(@chase_checking, amount, vendors.sample, @misc_cat, date) + end + + # Vending machine purchases (reduced) + 60.times do # Reduced from 100 + date = weighted_random_date + amount = rand(1..5) + create_transaction!(@chase_checking, amount, "Vending Machine", @shopping_cat, date) + end + + # Public transportation (reduced) + 120.times do # Reduced from 180 + date = weighted_random_date + amount = rand(2..8) + transit = [ "Metro Card", "Bus Fare", "Train Ticket", "Uber/Lyft" ] + create_transaction!(@chase_checking, amount, transit.sample, @transportation_cat, date) + end + + # Additional small transactions to ensure we reach 8k minimum (reduced) + 400.times do # Reduced from 600 + date = weighted_random_date + amount = rand(1..12) + merchants = [ + "Newsstand", "Coffee Cart", "Tip Jar", "Donation Box", "Laundromat", + "Car Wash", "Redbox", "PayPhone", "Photo Booth", "Arcade Game", + "Postage", "Newspaper", "Lottery Ticket", "Gumball Machine", "Ice Cream Truck" + ] + create_transaction!(@chase_checking, amount, merchants.sample, @misc_cat, date) + end + + # Extra small transactions to ensure 8k+ volume + 500.times do + date = weighted_random_date + amount = rand(1..8) + tiny_merchants = [ + "Candy Machine", "Sticker Machine", "Penny Scale", "Charity Donation", + "Busker Tip", "Church Offering", "Lemonade Stand", "Girl Scout Cookies", + "Raffle Ticket", "Bake Sale", "Car Wash Tip", "Street Performer" + ] + create_transaction!(@chase_checking, amount, tiny_merchants.sample, @misc_cat, date) + end + end + + # --------------------------------------------------------------------------- + # Legacy historical transactions (Task 11) + # --------------------------------------------------------------------------- + def generate_legacy_transactions! + # Small recent legacy transactions (3-6 years ago) + count = rand(40..60) # Increased from 20-30 + count.times do + years_ago = rand(3..6) + date = years_ago.years.ago.to_date - rand(0..364).days + + base_amount = rand(12..45) # Reduced from 15-60 + discount = (1 - 0.02 * [ years_ago - 3, 0 ].max) + amount = (base_amount * discount).round + + account = [ @chase_checking, @ally_checking ].sample + category = pick([ @groceries_cat, @utilities_cat, @gas_cat, @restaurants_cat, @shopping_cat ]) + + merchant = case category + when @groceries_cat then pick(%w[Walmart Kroger Safeway]) + " Market" + when @utilities_cat then pick([ "Local Electric", "City Water", "Gas Co." ]) + when @gas_cat then pick(%w[Shell Exxon BP]) + when @restaurants_cat then pick([ "Diner", "Burger Grill", "Pizza Place" ]) + else pick([ "General Store", "Department Shop", "Outlet" ]) + end + + create_transaction!(account, amount, merchant, category, date) + end + + # Very old transactions (7-15 years ago) - just scattered outliers + count = rand(25..40) # Increased from 15-25 + count.times do + years_ago = rand(7..15) + date = years_ago.years.ago.to_date - rand(0..364).days + + base_amount = rand(8..30) # Reduced from 10-40 + discount = (1 - 0.03 * [ years_ago - 7, 0 ].max) # More discount for very old + amount = (base_amount * discount).round.clamp(5, 25) # Reduced max from 35 + + account = @chase_checking # Just use main checking for simplicity + category = pick([ @groceries_cat, @gas_cat, @restaurants_cat ]) + + merchant = case category + when @groceries_cat then pick(%w[Walmart Kroger]) + " Market" + when @gas_cat then pick(%w[Shell Exxon]) + else pick([ "Old Diner", "Local Restaurant" ]) + end + + create_transaction!(account, amount, "#{merchant} (#{years_ago}y ago)", category, date) + end + + # Additional small transactions to reach 8k minimum if needed + additional_needed = [ 400, 0 ].max # Increased from 200 + additional_needed.times do + years_ago = rand(4..12) + date = years_ago.years.ago.to_date - rand(0..364).days + amount = rand(6..20) # Reduced from 8-25 + + account = [ @chase_checking, @ally_checking ].sample + category = pick([ @groceries_cat, @gas_cat, @utilities_cat ]) + + merchant = "Legacy #{pick(%w[Store Gas Electric])}" + create_transaction!(account, amount, merchant, category, date) + end + end + + # --------------------------------------------------------------------------- + # Crypto & misc assets (Task 12) + # --------------------------------------------------------------------------- + def generate_crypto_and_misc_assets! + # One-time USDC deposit 18 months ago + deposit_date = 18.months.ago.to_date + create_transaction!(@coinbase_usdc, -3_500, "Initial USDC Deposit", nil, deposit_date) + end + + # --------------------------------------------------------------------------- + # Balance Reconciliation (Task 14) + # --------------------------------------------------------------------------- + def reconcile_balances!(family) + # Use valuations only for property/vehicle accounts that should have specific values + # All other accounts should reach target balances through natural transaction flow + + # Property valuations (these accounts are valued, not transaction-driven) + @home.entries.create!( + entryable: Valuation.new, + amount: 350_000, + name: "Current Market Value", + currency: "USD", + date: Date.current + ) + + # Vehicle valuations (these depreciate over time) + @honda_accord.entries.create!( + entryable: Valuation.new, + amount: 18_000, + name: "Current Market Value", + currency: "USD", + date: Date.current + ) + + @tesla_model3.entries.create!( + entryable: Valuation.new, + amount: 4_500, + name: "Current Market Value", + currency: "USD", + date: Date.current + ) + + @jewelry.entries.create!( + entryable: Valuation.new, + amount: 2000, + name: "Current Market Value", + currency: "USD", + date: 90.days.ago.to_date + ) + + @personal_loc.entries.create!( + entryable: Valuation.new, + amount: 800, + name: "Owed", + currency: "USD", + date: 120.days.ago.to_date + ) + + puts " βœ… Set property and vehicle valuations" end end + +# Expose public API after full class definition +Demo::Generator.public_instance_methods.include?(:generate_default_data!) or Demo::Generator.class_eval do + public :generate_empty_data!, :generate_new_user_data!, :generate_default_data!, :generate_multi_currency_data! +end diff --git a/app/models/demo/rule_generator.rb b/app/models/demo/rule_generator.rb deleted file mode 100644 index 794dc45f..00000000 --- a/app/models/demo/rule_generator.rb +++ /dev/null @@ -1,79 +0,0 @@ -class Demo::RuleGenerator - include Demo::DataHelper - - def create_rules!(family) - tags = create_tags!(family) - categories = create_categories!(family) - merchants = create_merchants!(family) - - rules = [] - - if merchants.any? && categories.any? - rule = family.rules.create!( - name: "Auto-categorize Grocery Purchases", - resource_type: "Transaction", - conditions: [ - Rule::Condition.new(condition_type: "merchant_name", operator: "contains", value: "Whole Foods") - ], - actions: [ - Rule::Action.new(action_type: "category_id", value: categories.first.id.to_s) - ] - ) - rules << rule - end - - rules - end - - def create_tags!(family) - tag_names = [ "Business", "Tax Deductible", "Recurring", "Emergency" ] - tags = [] - - tag_names.each do |name| - tag = family.tags.find_or_create_by!(name: name) do |t| - t.color = random_color - end - tags << tag - end - - tags - end - - def create_categories!(family) - category_data = [ - { name: "Groceries", color: random_color }, - { name: "Transportation", color: random_color }, - { name: "Entertainment", color: random_color }, - { name: "Utilities", color: random_color }, - { name: "Healthcare", color: random_color } - ] - - categories = [] - category_data.each do |data| - category = family.categories.find_or_create_by!(name: data[:name]) do |c| - c.color = data[:color] - end - categories << category - end - - categories - end - - def create_merchants!(family) - merchant_names = [ - "Whole Foods Market", - "Shell Gas Station", - "Netflix", - "Electric Company", - "Local Coffee Shop" - ] - - merchants = [] - merchant_names.each do |name| - merchant = family.merchants.find_or_create_by!(name: name) - merchants << merchant - end - - merchants - end -end diff --git a/app/models/demo/scenarios/basic_budget.rb b/app/models/demo/scenarios/basic_budget.rb deleted file mode 100644 index f215bceb..00000000 --- a/app/models/demo/scenarios/basic_budget.rb +++ /dev/null @@ -1,129 +0,0 @@ -# Basic budget scenario - minimal budgeting demonstration with categories -# -# This scenario creates a simple budget demonstration with parent/child categories -# and one transaction per category. Designed to showcase basic budgeting features -# without overwhelming complexity. Ideal for: -# - Basic budgeting feature demos -# - Category hierarchy demonstrations -# - Simple transaction categorization examples -# - Lightweight testing environments -# -class Demo::Scenarios::BasicBudget < Demo::BaseScenario - include Demo::DataHelper - - # Scenario characteristics and configuration - SCENARIO_NAME = "Basic Budget".freeze - PURPOSE = "Simple budget demonstration with category hierarchy".freeze - TARGET_ACCOUNTS_PER_FAMILY = 1 # Single checking account - TARGET_TRANSACTIONS_PER_FAMILY = 4 # One income, three expenses - TARGET_CATEGORIES = 4 # Income + 3 expense categories (with one subcategory) - INCLUDES_SECURITIES = false - INCLUDES_TRANSFERS = false - INCLUDES_RULES = false - - private - - # Generate basic budget demonstration data - # Creates simple category hierarchy and one transaction per category - # - # @param family [Family] The family to generate data for - # @param options [Hash] Additional options (unused in this scenario) - def generate_family_data!(family, **options) - create_category_hierarchy!(family) - create_demo_checking_account!(family) - create_sample_categorized_transactions!(family) - end - - # Create parent categories with one subcategory example - def create_category_hierarchy!(family) - # Create parent categories - @food_category = family.categories.create!( - name: "Food & Drink", - color: random_color, - classification: "expense" - ) - - @transport_category = family.categories.create!( - name: "Transportation", - color: random_color, - classification: "expense" - ) - - # Create subcategory to demonstrate hierarchy - @restaurants_category = family.categories.create!( - name: "Restaurants", - parent: @food_category, - color: random_color, - classification: "expense" - ) - - puts " - #{TARGET_CATEGORIES} categories created (with parent/child hierarchy)" - end - - # Create single checking account for budget demonstration - def create_demo_checking_account!(family) - @checking_account = family.accounts.create!( - accountable: Depository.new, - name: "Demo Checking", - balance: 0, # Will be calculated from transactions - currency: "USD" - ) - - puts " - #{TARGET_ACCOUNTS_PER_FAMILY} demo checking account created" - end - - # Create one transaction for each category to demonstrate categorization - def create_sample_categorized_transactions!(family) - # Create income category and transaction first - income_category = family.categories.create!( - name: "Income", - color: random_color, - classification: "income" - ) - - # Add income transaction (negative amount = inflow) - @generators[:transaction_generator].create_transaction!( - account: @checking_account, - amount: -500, # Income (negative) - name: "Salary", - category: income_category, - date: 5.days.ago - ) - - # Grocery transaction (parent category) - @generators[:transaction_generator].create_transaction!( - account: @checking_account, - amount: 100, - name: "Grocery Store", - category: @food_category, - date: 2.days.ago - ) - - # Restaurant transaction (subcategory) - @generators[:transaction_generator].create_transaction!( - account: @checking_account, - amount: 50, - name: "Restaurant Meal", - category: @restaurants_category, - date: 1.day.ago - ) - - # Transportation transaction - @generators[:transaction_generator].create_transaction!( - account: @checking_account, - amount: 20, - name: "Gas Station", - category: @transport_category, - date: Date.current - ) - - # Update account balance to match transaction sum - @generators[:transaction_generator].update_account_balances_from_transactions!(family) - - puts " - #{TARGET_TRANSACTIONS_PER_FAMILY + 1} categorized transactions created (including income)" - end - - def scenario_name - SCENARIO_NAME - end -end diff --git a/app/models/demo/scenarios/clean_slate.rb b/app/models/demo/scenarios/clean_slate.rb deleted file mode 100644 index b37514d8..00000000 --- a/app/models/demo/scenarios/clean_slate.rb +++ /dev/null @@ -1,126 +0,0 @@ -# Clean slate scenario - minimal starter data for new user onboarding -# -# This scenario creates the absolute minimum data needed to help new users -# understand Maybe's core features without overwhelming them. Ideal for: -# - New user onboarding flows -# - Tutorial walkthroughs -# - Clean development environments -# - User acceptance testing with minimal data -# -# The scenario only generates data when explicitly requested via with_minimal_data: true, -# otherwise it creates no data at all (true "clean slate"). -# -# @example Minimal data generation -# scenario = Demo::Scenarios::CleanSlate.new(generators) -# scenario.generate!(families, with_minimal_data: true) -# -# @example True clean slate (no data) -# scenario = Demo::Scenarios::CleanSlate.new(generators) -# scenario.generate!(families) # Creates nothing -# -class Demo::Scenarios::CleanSlate < Demo::BaseScenario - # Scenario characteristics and configuration - SCENARIO_NAME = "Clean Slate".freeze - PURPOSE = "Minimal starter data for new user onboarding and tutorials".freeze - TARGET_ACCOUNTS_PER_FAMILY = 1 # Single checking account only - TARGET_TRANSACTIONS_PER_FAMILY = 3 # Just enough to show transaction history - INCLUDES_SECURITIES = false - INCLUDES_TRANSFERS = false - INCLUDES_RULES = false - MINIMAL_CATEGORIES = 2 # Essential expense and income categories only - - # Override the base generate! method to handle the special with_minimal_data option - # Only generates data when explicitly requested to avoid accidental data creation - # - # @param families [Array] Families to generate data for - # @param options [Hash] Options hash that may contain with_minimal_data or require_onboarding - def generate!(families, **options) - # For "empty" task, don't generate any data - # For "new_user" task, generate minimal data for onboarding users - with_minimal_data = options[:with_minimal_data] || options[:require_onboarding] - return unless with_minimal_data - - super(families, **options) - end - - private - - # Generate minimal family data for getting started - # Creates only essential accounts and transactions to demonstrate core features - # - # @param family [Family] The family to generate data for - # @param options [Hash] Additional options (with_minimal_data used for validation) - def generate_family_data!(family, **options) - create_essential_categories!(family) - create_primary_checking_account!(family) - create_sample_transaction_history!(family) - end - - # Create only the most essential categories for basic expense tracking - def create_essential_categories!(family) - @food_category = family.categories.create!( - name: "Food & Drink", - color: "#4da568", - classification: "expense" - ) - - @income_category = family.categories.create!( - name: "Income", - color: "#6471eb", - classification: "income" - ) - - puts " - #{MINIMAL_CATEGORIES} essential categories created" - end - - # Create a single primary checking account with a reasonable starting balance - def create_primary_checking_account!(family) - @checking_account = family.accounts.create!( - accountable: Depository.new, - name: "Main Checking", - balance: 0, # Will be calculated from transactions - currency: "USD" - ) - - puts " - #{TARGET_ACCOUNTS_PER_FAMILY} primary checking account created" - end - - # Create minimal transaction history showing income and expense patterns - def create_sample_transaction_history!(family) - # Recent salary deposit - @generators[:transaction_generator].create_transaction!( - account: @checking_account, - amount: -3000, # Income (negative = inflow) - name: "Salary", - category: @income_category, - date: 15.days.ago - ) - - # Recent grocery purchase - @generators[:transaction_generator].create_transaction!( - account: @checking_account, - amount: 75, # Expense (positive = outflow) - name: "Grocery Store", - category: @food_category, - date: 5.days.ago - ) - - # Recent restaurant expense - @generators[:transaction_generator].create_transaction!( - account: @checking_account, - amount: 45, # Expense - name: "Restaurant", - category: @food_category, - date: 2.days.ago - ) - - # Update account balance to match transaction sum - @generators[:transaction_generator].update_account_balances_from_transactions!(family) - - puts " - #{TARGET_TRANSACTIONS_PER_FAMILY} sample transactions created" - end - - def scenario_name - SCENARIO_NAME - end -end diff --git a/app/models/demo/scenarios/default.rb b/app/models/demo/scenarios/default.rb deleted file mode 100644 index 78eb6a8f..00000000 --- a/app/models/demo/scenarios/default.rb +++ /dev/null @@ -1,77 +0,0 @@ -# Default demo scenario - comprehensive realistic data for product demonstrations -# -# This scenario creates a complete, realistic demo environment that showcases -# all of Maybe's features with believable data patterns. Ideal for: -# - Product demonstrations to potential users -# - UI/UX testing with realistic data volumes -# - Feature development with complete data sets -# - Screenshots and marketing materials -# -class Demo::Scenarios::Default < Demo::BaseScenario - # Scenario characteristics and configuration - SCENARIO_NAME = "Comprehensive Demo".freeze - PURPOSE = "Complete realistic demo environment showcasing all Maybe features".freeze - TARGET_ACCOUNTS_PER_FAMILY = 7 # 1 each: checking, savings, credit card, 3 investments, 1 property+mortgage - TARGET_TRANSACTIONS_PER_FAMILY = 50 # Realistic 3-month transaction history - INCLUDES_SECURITIES = true - INCLUDES_TRANSFERS = true - INCLUDES_RULES = true - - private - - # Load securities before generating family data - # Securities are needed for investment account trades - def setup(**options) - @generators[:security_generator].load_securities! - puts "Securities loaded for investment accounts" - end - - # Generate complete family financial data - # Creates all account types with realistic balances and transaction patterns - # - # @param family [Family] The family to generate data for - # @param options [Hash] Additional options (unused in this scenario) - def generate_family_data!(family, **options) - create_foundational_data!(family) - create_all_account_types!(family) - create_realistic_transaction_patterns!(family) - create_account_transfers!(family) - end - - # Create rules, tags, categories, and merchants for the family - def create_foundational_data!(family) - @generators[:rule_generator].create_rules!(family) - @generators[:rule_generator].create_tags!(family) - @generators[:rule_generator].create_categories!(family) - @generators[:rule_generator].create_merchants!(family) - puts " - Rules, categories, and merchants created" - end - - # Create one of each major account type to demonstrate full feature set - def create_all_account_types!(family) - @generators[:account_generator].create_credit_card_accounts!(family) - @generators[:account_generator].create_checking_accounts!(family) - @generators[:account_generator].create_savings_accounts!(family) - @generators[:account_generator].create_investment_accounts!(family) - @generators[:account_generator].create_properties_and_mortgages!(family) - @generators[:account_generator].create_vehicles_and_loans!(family) - @generators[:account_generator].create_other_accounts!(family) - puts " - All #{TARGET_ACCOUNTS_PER_FAMILY} account types created" - end - - # Generate realistic transaction patterns across all accounts - def create_realistic_transaction_patterns!(family) - @generators[:transaction_generator].create_realistic_transactions!(family) - puts " - Realistic transaction patterns created (~#{TARGET_TRANSACTIONS_PER_FAMILY} transactions)" - end - - # Create transfer patterns between accounts (credit card payments, investments, etc.) - def create_account_transfers!(family) - @generators[:transfer_generator].create_transfer_transactions!(family) - puts " - Account transfer patterns created" - end - - def scenario_name - SCENARIO_NAME - end -end diff --git a/app/models/demo/scenarios/multi_currency.rb b/app/models/demo/scenarios/multi_currency.rb deleted file mode 100644 index 229eb458..00000000 --- a/app/models/demo/scenarios/multi_currency.rb +++ /dev/null @@ -1,241 +0,0 @@ -# Multi-currency scenario - international financial management demonstration -# -# This scenario creates accounts and transactions in multiple currencies to showcase -# Maybe's multi-currency capabilities. Demonstrates currency conversion, international -# transactions, and mixed-currency portfolio management. Ideal for: -# - International users and use cases -# - Currency conversion feature testing -# - Multi-region financial management demos -# - Exchange rate and conversion testing -# -# Primary currency is EUR with additional USD and GBP accounts and transactions. -# -class Demo::Scenarios::MultiCurrency < Demo::BaseScenario - include Demo::DataHelper - - # Scenario characteristics and configuration - SCENARIO_NAME = "Multi-Currency".freeze - PURPOSE = "International financial management with multiple currencies".freeze - PRIMARY_CURRENCY = "EUR".freeze - SUPPORTED_CURRENCIES = %w[EUR USD GBP].freeze - TARGET_ACCOUNTS_PER_FAMILY = 5 # 2 EUR (checking, credit), 1 USD, 1 GBP, 1 multi-currency investment - TARGET_TRANSACTIONS_PER_FAMILY = 10 # Distributed across currencies - INCLUDES_SECURITIES = false # Keep simple for currency focus - INCLUDES_TRANSFERS = true # Minimal transfers to avoid currency complexity - INCLUDES_RULES = false # Focus on currency, not categorization - - private - - # Generate family data with multiple currencies - # Creates accounts in EUR, USD, and GBP with appropriate transactions - # - # @param family [Family] The family to generate data for (should have EUR as primary currency) - # @param options [Hash] Additional options (unused in this scenario) - def generate_family_data!(family, **options) - create_basic_categorization!(family) - create_multi_currency_accounts!(family) - create_international_transactions!(family) - create_minimal_transfers!(family) - end - - # Create basic categories for international transactions - def create_basic_categorization!(family) - @generators[:rule_generator].create_categories!(family) - @generators[:rule_generator].create_merchants!(family) - puts " - Basic categories and merchants created for international transactions" - end - - # Create accounts in multiple currencies to demonstrate international capabilities - def create_multi_currency_accounts!(family) - create_eur_accounts!(family) # Primary currency accounts - create_usd_accounts!(family) # US dollar accounts - create_gbp_accounts!(family) # British pound accounts - create_investment_account!(family) # Multi-currency investment - - puts " - #{TARGET_ACCOUNTS_PER_FAMILY} multi-currency accounts created (#{SUPPORTED_CURRENCIES.join(', ')})" - end - - # Create EUR accounts (primary currency for this scenario) - def create_eur_accounts!(family) - # Create EUR checking account - family.accounts.create!( - accountable: Depository.new, - name: "EUR Checking Account", - balance: 0, # Will be calculated from transactions - currency: "EUR" - ) - - # Create EUR credit card - family.accounts.create!( - accountable: CreditCard.new, - name: "EUR Credit Card", - balance: 0, # Will be calculated from transactions - currency: "EUR" - ) - end - - # Create USD accounts for US-based transactions - def create_usd_accounts!(family) - family.accounts.create!( - accountable: Depository.new, - name: "USD Checking Account", - balance: 0, # Will be calculated from transactions - currency: "USD" - ) - end - - # Create GBP accounts for UK-based transactions - def create_gbp_accounts!(family) - family.accounts.create!( - accountable: Depository.new, - name: "GBP Savings Account", - balance: 0, # Will be calculated from transactions - currency: "GBP", - subtype: "savings" - ) - end - - # Create investment account (uses primary currency) - def create_investment_account!(family) - @generators[:account_generator].create_investment_accounts!(family, count: 1) - end - - # Create transactions in various currencies to demonstrate international usage - def create_international_transactions!(family) - # Create initial valuations for accounts that need them - create_initial_valuations!(family) - - create_eur_transaction_patterns!(family) - create_usd_transaction_patterns!(family) - create_gbp_transaction_patterns!(family) - - # Update account balances to match transaction sums - @generators[:transaction_generator].update_account_balances_from_transactions!(family) - - puts " - International transactions created across #{SUPPORTED_CURRENCIES.length} currencies" - end - - # Create initial valuations for credit cards in this scenario - def create_initial_valuations!(family) - family.accounts.each do |account| - next unless account.accountable_type == "CreditCard" - - Entry.create!( - account: account, - amount: 1000, # Initial credit card debt - name: "Initial creditcard valuation", - date: 2.years.ago.to_date, - currency: account.currency, - entryable_type: "Valuation", - entryable_attributes: {} - ) - end - end - - # Create EUR transactions (primary currency patterns) with both income and expenses - def create_eur_transaction_patterns!(family) - eur_accounts = family.accounts.where(currency: "EUR") - - eur_accounts.each do |account| - next if account.accountable_type == "Investment" - - if account.accountable_type == "CreditCard" - # Credit cards only get purchases (positive amounts) - 5.times do |i| - @generators[:transaction_generator].create_transaction!( - account: account, - amount: random_positive_amount(50, 300), # Purchases (positive) - name: "EUR Purchase #{i + 1}", - date: random_date_within_days(60), - currency: "EUR" - ) - end - else - # Checking accounts get both income and expenses - # Create income transactions (negative amounts) - 2.times do |i| - @generators[:transaction_generator].create_transaction!( - account: account, - amount: -random_positive_amount(2000, 3000), # Higher income to cover transfers - name: "EUR Salary #{i + 1}", - date: random_date_within_days(60), - currency: "EUR" - ) - end - - # Create expense transactions (positive amounts) - 3.times do |i| - @generators[:transaction_generator].create_transaction!( - account: account, - amount: random_positive_amount(20, 200), # Expense (positive) - name: "EUR Purchase #{i + 1}", - date: random_date_within_days(60), - currency: "EUR" - ) - end - end - end - end - - # Create USD transactions (US-based spending patterns) with both income and expenses - def create_usd_transaction_patterns!(family) - usd_accounts = family.accounts.where(currency: "USD") - - usd_accounts.each do |account| - # Create income transaction (negative amount) - @generators[:transaction_generator].create_transaction!( - account: account, - amount: -random_positive_amount(1500, 2500), # Higher income to cover transfers - name: "USD Freelance Payment", - date: random_date_within_days(60), - currency: "USD" - ) - - # Create expense transactions (positive amounts) - 2.times do |i| - @generators[:transaction_generator].create_transaction!( - account: account, - amount: random_positive_amount(30, 150), # Expense (positive) - name: "USD Purchase #{i + 1}", - date: random_date_within_days(60), - currency: "USD" - ) - end - end - end - - # Create GBP transactions (UK-based spending patterns) with both income and expenses - def create_gbp_transaction_patterns!(family) - gbp_accounts = family.accounts.where(currency: "GBP") - - gbp_accounts.each do |account| - # Create income transaction (negative amount) - @generators[:transaction_generator].create_transaction!( - account: account, - amount: -random_positive_amount(500, 800), # Income (negative) - name: "GBP Consulting Payment", - date: random_date_within_days(60), - currency: "GBP" - ) - - # Create expense transaction (positive amount) - @generators[:transaction_generator].create_transaction!( - account: account, - amount: random_positive_amount(25, 100), # Expense (positive) - name: "GBP Purchase", - date: random_date_within_days(60), - currency: "GBP" - ) - end - end - - # Create minimal transfers to keep scenario focused on currency demonstration - def create_minimal_transfers!(family) - @generators[:transfer_generator].create_transfer_transactions!(family, count: 1) - puts " - Minimal account transfers created" - end - - def scenario_name - SCENARIO_NAME - end -end diff --git a/app/models/demo/scenarios/performance_testing.rb b/app/models/demo/scenarios/performance_testing.rb deleted file mode 100644 index 1bfff17b..00000000 --- a/app/models/demo/scenarios/performance_testing.rb +++ /dev/null @@ -1,349 +0,0 @@ -# Performance testing scenario - high-volume data for load testing -# -# This scenario creates large volumes of realistic data to test application -# performance under load. Uses an efficient approach: generates one complete -# realistic family in Ruby, then uses SQL bulk operations to duplicate it -# 499 times for maximum performance. Ideal for: -# - Performance testing and benchmarking -# - Load testing database operations -# - UI performance testing with large datasets -# - Scalability validation at production scale -# - -require "bcrypt" - -class Demo::Scenarios::PerformanceTesting < Demo::BaseScenario - # Scenario characteristics and configuration - SCENARIO_NAME = "Performance Testing".freeze - PURPOSE = "High-volume data generation for performance testing and load validation".freeze - TARGET_FAMILIES = 500 - TARGET_ACCOUNTS_PER_FAMILY = 29 # 3 credit cards, 5 checking, 2 savings, 10 investments, 2 properties+mortgages, 3 vehicles+2 loans, 4 other assets+liabilities - TARGET_TRANSACTIONS_PER_FAMILY = 200 # Reasonable volume for development performance testing - TARGET_TRANSFERS_PER_FAMILY = 10 - SECURITIES_COUNT = 50 # Large number for investment account testing - INCLUDES_SECURITIES = true - INCLUDES_TRANSFERS = true - INCLUDES_RULES = true - - # Override generate! to use our efficient bulk duplication approach - def generate!(families, **options) - puts "Creating performance test data for #{TARGET_FAMILIES} families using efficient bulk duplication..." - - setup(**options) if respond_to?(:setup, true) - - # Step 1: Create one complete realistic family - template_family = create_template_family!(families.first, **options) - - # Step 2: Efficiently duplicate it 499 times using SQL - duplicate_family_data!(template_family, TARGET_FAMILIES - 1) - - puts "Performance test data created successfully with #{TARGET_FAMILIES} families!" - end - - private - - # Load large number of securities before generating family data - def setup(**options) - @generators[:security_generator].load_securities!(count: SECURITIES_COUNT) - puts "#{SECURITIES_COUNT} securities loaded for performance testing" - end - - # Create one complete, realistic family that will serve as our template - def create_template_family!(family_or_name, **options) - # Handle both Family object and family name string - family = if family_or_name.is_a?(Family) - family_or_name - else - Family.find_by(name: family_or_name) - end - - unless family - raise "Template family '#{family_or_name}' not found. Ensure family creation happened first." - end - - puts "Creating template family: #{family.name}..." - generate_family_data!(family, **options) - - puts "Template family created with #{family.accounts.count} accounts and #{family.entries.count} entries" - family - end - - # Efficiently duplicate the template family data using SQL bulk operations - def duplicate_family_data!(template_family, copies_needed) - puts "Duplicating template family #{copies_needed} times using efficient SQL operations..." - - ActiveRecord::Base.transaction do - # Get all related data for the template family - template_data = extract_template_data(template_family) - - # Create family records in batches - create_family_copies(template_family, copies_needed) - - # Bulk duplicate all related data - duplicate_accounts_and_related_data(template_data, copies_needed) - end - - puts "Successfully created #{copies_needed} family copies" - end - - # Extract all data related to the template family for duplication - def extract_template_data(family) - { - accounts: family.accounts.includes(:accountable), - entries: family.entries.includes(:entryable), - categories: family.categories, - merchants: family.merchants, - tags: family.tags, - rules: family.rules, - holdings: family.holdings - } - end - - # Create family and user records efficiently - def create_family_copies(template_family, count) - puts "Creating #{count} family records..." - - families_data = [] - users_data = [] - password_digest = BCrypt::Password.create("password") - - (2..count + 1).each do |i| - family_id = SecureRandom.uuid - family_name = "Performance Family #{i}" - - families_data << { - id: family_id, - name: family_name, - currency: template_family.currency, - locale: template_family.locale, - country: template_family.country, - timezone: template_family.timezone, - date_format: template_family.date_format, - created_at: Time.current, - updated_at: Time.current - } - - # Create admin user - users_data << { - id: SecureRandom.uuid, - family_id: family_id, - email: "user#{i}@maybe.local", - first_name: "Demo", - last_name: "User", - role: "admin", - password_digest: password_digest, - onboarded_at: Time.current, - created_at: Time.current, - updated_at: Time.current - } - - # Create member user - users_data << { - id: SecureRandom.uuid, - family_id: family_id, - email: "member_user#{i}@maybe.local", - first_name: "Demo (member user)", - last_name: "User", - role: "member", - password_digest: password_digest, - onboarded_at: Time.current, - created_at: Time.current, - updated_at: Time.current - } - end - - # Bulk insert families and users - Family.insert_all(families_data) - User.insert_all(users_data) - - puts "Created #{count} families and #{users_data.length} users" - end - - # Efficiently duplicate accounts and all related data using SQL - def duplicate_accounts_and_related_data(template_data, count) - puts "Duplicating accounts and related data for #{count} families..." - - new_families = Family.where("name LIKE 'Performance Family %'") - .where.not(id: template_data[:accounts].first&.family_id) - .limit(count) - - new_families.find_each.with_index do |family, index| - duplicate_family_accounts_bulk(template_data, family) - puts "Completed family #{index + 1}/#{count}" if (index + 1) % 50 == 0 - end - end - - # Duplicate all accounts and related data for a single family using bulk operations - def duplicate_family_accounts_bulk(template_data, target_family) - return if template_data[:accounts].empty? - - account_id_mapping = {} - - # Create accounts one by one to handle accountables properly - template_data[:accounts].each do |template_account| - new_account = target_family.accounts.create!( - accountable: template_account.accountable.dup, - name: template_account.name, - balance: template_account.balance, - currency: template_account.currency, - subtype: template_account.subtype, - is_active: template_account.is_active - ) - account_id_mapping[template_account.id] = new_account.id - end - - # Bulk create other related data - create_bulk_categories(template_data[:categories], target_family) - create_bulk_entries_and_related(template_data, target_family, account_id_mapping) - rescue => e - puts "Error duplicating data for #{target_family.name}: #{e.message}" - # Continue with next family rather than failing completely - end - - # Bulk create categories for a family - def create_bulk_categories(template_categories, target_family) - return if template_categories.empty? - - # Create mapping from old category IDs to new category IDs - category_id_mapping = {} - - # First pass: generate new IDs for all categories - template_categories.each do |template_category| - category_id_mapping[template_category.id] = SecureRandom.uuid - end - - # Second pass: create category data with properly mapped parent_ids - categories_data = template_categories.map do |template_category| - # Map parent_id to the new family's category ID, or nil if no parent - new_parent_id = template_category.parent_id ? category_id_mapping[template_category.parent_id] : nil - - { - id: category_id_mapping[template_category.id], - family_id: target_family.id, - name: template_category.name, - color: template_category.color, - classification: template_category.classification, - parent_id: new_parent_id, - created_at: Time.current, - updated_at: Time.current - } - end - - Category.insert_all(categories_data) - end - - # Bulk create entries and related entryables - def create_bulk_entries_and_related(template_data, target_family, account_id_mapping) - return if template_data[:entries].empty? - - entries_data = [] - transactions_data = [] - trades_data = [] - - template_data[:entries].each do |template_entry| - new_account_id = account_id_mapping[template_entry.account_id] - next unless new_account_id - - new_entry_id = SecureRandom.uuid - new_entryable_id = SecureRandom.uuid - - entries_data << { - id: new_entry_id, - account_id: new_account_id, - entryable_type: template_entry.entryable_type, - entryable_id: new_entryable_id, - name: template_entry.name, - date: template_entry.date, - amount: template_entry.amount, - currency: template_entry.currency, - notes: template_entry.notes, - created_at: Time.current, - updated_at: Time.current - } - - # Create entryable data based on type - case template_entry.entryable_type - when "Transaction" - transactions_data << { - id: new_entryable_id, - created_at: Time.current, - updated_at: Time.current - } - when "Trade" - trades_data << { - id: new_entryable_id, - security_id: template_entry.entryable.security_id, - qty: template_entry.entryable.qty, - price: template_entry.entryable.price, - currency: template_entry.entryable.currency, - created_at: Time.current, - updated_at: Time.current - } - end - end - - # Bulk insert all data - Entry.insert_all(entries_data) if entries_data.any? - Transaction.insert_all(transactions_data) if transactions_data.any? - Trade.insert_all(trades_data) if trades_data.any? - end - - # Generate high-volume family data for the template family - def generate_family_data!(family, **options) - create_foundational_data!(family) - create_high_volume_accounts!(family) - create_performance_transactions!(family) - create_performance_transfers!(family) - end - - # Create rules, tags, categories and merchants for performance testing - def create_foundational_data!(family) - @generators[:rule_generator].create_tags!(family) - @generators[:rule_generator].create_categories!(family) - @generators[:rule_generator].create_merchants!(family) - @generators[:rule_generator].create_rules!(family) - puts " - Foundational data created (tags, categories, merchants, rules)" - end - - # Create large numbers of accounts across all types for performance testing - def create_high_volume_accounts!(family) - @generators[:account_generator].create_credit_card_accounts!(family, count: 3) - puts " - 3 credit card accounts created" - - @generators[:account_generator].create_checking_accounts!(family, count: 5) - puts " - 5 checking accounts created" - - @generators[:account_generator].create_savings_accounts!(family, count: 2) - puts " - 2 savings accounts created" - - @generators[:account_generator].create_investment_accounts!(family, count: 10) - puts " - 10 investment accounts created" - - @generators[:account_generator].create_properties_and_mortgages!(family, count: 2) - puts " - 2 properties and mortgages created" - - @generators[:account_generator].create_vehicles_and_loans!(family, vehicle_count: 3, loan_count: 2) - puts " - 3 vehicles and 2 loans created" - - @generators[:account_generator].create_other_accounts!(family, asset_count: 4, liability_count: 4) - puts " - 4 other assets and 4 other liabilities created" - - puts " - Total: #{TARGET_ACCOUNTS_PER_FAMILY} accounts created for performance testing" - end - - # Create high-volume transactions for performance testing - def create_performance_transactions!(family) - @generators[:transaction_generator].create_performance_transactions!(family) - puts " - High-volume performance transactions created (~#{TARGET_TRANSACTIONS_PER_FAMILY} transactions)" - end - - # Create multiple transfer cycles for performance testing - def create_performance_transfers!(family) - @generators[:transfer_generator].create_transfer_transactions!(family, count: TARGET_TRANSFERS_PER_FAMILY) - puts " - #{TARGET_TRANSFERS_PER_FAMILY} transfer transaction cycles created" - end - - def scenario_name - SCENARIO_NAME - end -end diff --git a/app/models/demo/security_generator.rb b/app/models/demo/security_generator.rb deleted file mode 100644 index eca2d0a8..00000000 --- a/app/models/demo/security_generator.rb +++ /dev/null @@ -1,76 +0,0 @@ -class Demo::SecurityGenerator - include Demo::DataHelper - - def load_securities!(count: 6) - if count <= 6 - create_standard_securities!(count) - else - securities = create_standard_securities!(6) - securities.concat(create_performance_securities!(count - 6)) - securities - end - end - - def create_standard_securities!(count) - securities_data = [ - { ticker: "AAPL", name: "Apple Inc.", exchange: "XNAS" }, - { ticker: "GOOGL", name: "Alphabet Inc.", exchange: "XNAS" }, - { ticker: "MSFT", name: "Microsoft Corporation", exchange: "XNAS" }, - { ticker: "AMZN", name: "Amazon.com Inc.", exchange: "XNAS" }, - { ticker: "TSLA", name: "Tesla Inc.", exchange: "XNAS" }, - { ticker: "NVDA", name: "NVIDIA Corporation", exchange: "XNAS" } - ] - - securities = [] - count.times do |i| - data = securities_data[i] - security = create_security!( - ticker: data[:ticker], - name: data[:name], - exchange_operating_mic: data[:exchange] - ) - securities << security - end - securities - end - - def create_performance_securities!(count) - securities = [] - count.times do |i| - security = create_security!( - ticker: "SYM#{i + 7}", - name: "Company #{i + 7}", - exchange_operating_mic: "XNAS" - ) - securities << security - end - securities - end - - def create_security!(ticker:, name:, exchange_operating_mic:) - security = Security.create!(ticker: ticker, name: name, exchange_operating_mic: exchange_operating_mic) - create_price_history!(security) - security - end - - def create_price_history!(security, extended: false) - days_back = extended ? 365 : 90 - price_base = 100.0 - prices = [] - - (0..days_back).each do |i| - date = i.days.ago.to_date - price_change = (rand - 0.5) * 10 - price_base = [ price_base + price_change, 10.0 ].max - - price = security.prices.create!( - date: date, - price: price_base.round(2), - currency: "USD" - ) - prices << price - end - - prices - end -end diff --git a/app/models/demo/transaction_generator.rb b/app/models/demo/transaction_generator.rb deleted file mode 100644 index aa74dadc..00000000 --- a/app/models/demo/transaction_generator.rb +++ /dev/null @@ -1,448 +0,0 @@ -class Demo::TransactionGenerator - include Demo::DataHelper - - def create_transaction!(attributes = {}) - # Separate entry attributes from transaction attributes - entry_attributes = attributes.extract!(:account, :date, :amount, :currency, :name, :notes) - transaction_attributes = attributes # category, merchant, etc. - - # Set defaults for entry - entry_defaults = { - date: 30.days.ago.to_date, - amount: 100, - currency: "USD", - name: "Demo Transaction" - } - - # Create entry with transaction as entryable - entry = Entry.create!( - entry_defaults.merge(entry_attributes).merge( - entryable_type: "Transaction", - entryable_attributes: transaction_attributes - ) - ) - - entry.entryable # Returns the Transaction - end - - def create_trade!(attributes = {}) - # Separate entry attributes from trade attributes - entry_attributes = attributes.extract!(:account, :date, :amount, :currency, :name, :notes) - trade_attributes = attributes # security, qty, price, etc. - - # Validate required trade attributes - security = trade_attributes[:security] || Security.first - unless security - raise ArgumentError, "Security is required for trade creation. Load securities first." - end - - # Set defaults for entry - entry_defaults = { - date: 30.days.ago.to_date, - currency: "USD", - name: "Demo Trade" - } - - # Set defaults for trade - trade_defaults = { - qty: 10, - price: 100, - currency: "USD" - } - - # Merge defaults with provided attributes - final_entry_attributes = entry_defaults.merge(entry_attributes) - final_trade_attributes = trade_defaults.merge(trade_attributes) - final_trade_attributes[:security] = security - - # Calculate amount if not provided (qty * price) - unless final_entry_attributes[:amount] - final_entry_attributes[:amount] = final_trade_attributes[:qty] * final_trade_attributes[:price] - end - - # Create entry with trade as entryable - entry = Entry.create!( - final_entry_attributes.merge( - entryable_type: "Trade", - entryable_attributes: final_trade_attributes - ) - ) - - entry.entryable # Returns the Trade - end - - def create_realistic_transactions!(family) - categories = family.categories.limit(10) - accounts_by_type = group_accounts_by_type(family) - entries = [] - - # Create initial valuations for accounts before other transactions - entries.concat(create_initial_valuations!(family)) - - accounts_by_type[:checking].each do |account| - entries.concat(create_income_transactions!(account)) - entries.concat(create_expense_transactions!(account, categories)) - end - - accounts_by_type[:credit_cards].each do |account| - entries.concat(create_credit_card_transactions!(account, categories)) - end - - accounts_by_type[:investments].each do |account| - entries.concat(create_investment_trades!(account)) - end - - # Update account balances to match transaction sums - update_account_balances_from_transactions!(family) - - entries - end - - def create_performance_transactions!(family) - categories = family.categories.limit(5) - accounts_by_type = group_accounts_by_type(family) - entries = [] - - # Create initial valuations for accounts before other transactions - entries.concat(create_initial_valuations!(family)) - - accounts_by_type[:checking].each do |account| - entries.concat(create_bulk_transactions!(account, PERFORMANCE_TRANSACTION_COUNTS[:depository_sample], income: true)) - entries.concat(create_bulk_transactions!(account, PERFORMANCE_TRANSACTION_COUNTS[:depository_sample], income: false)) - end - - accounts_by_type[:credit_cards].each do |account| - entries.concat(create_bulk_transactions!(account, PERFORMANCE_TRANSACTION_COUNTS[:credit_card_sample], credit_card: true)) - end - - accounts_by_type[:investments].each do |account| - entries.concat(create_bulk_investment_trades!(account, PERFORMANCE_TRANSACTION_COUNTS[:investment_trades])) - end - - # Update account balances to match transaction sums - update_account_balances_from_transactions!(family) - - entries - end - - # Create initial valuations for accounts to establish realistic starting values - # This is more appropriate than fake transactions - def create_initial_valuations!(family) - entries = [] - - family.accounts.each do |account| - initial_value = case account.accountable_type - when "Loan" - case account.name - when /Mortgage/i then 300000 # Initial mortgage debt - when /Auto/i, /Car/i then 15000 # Initial car loan debt - else 10000 # Other loan debt - end - when "CreditCard" - 5000 # Initial credit card debt - when "Property" - 500000 # Initial property value - when "Vehicle" - 25000 # Initial vehicle value - when "OtherAsset" - 5000 # Initial other asset value - when "OtherLiability" - 2000 # Initial other liability debt - else - next # Skip accounts that don't need initial valuations - end - - # Create valuation entry - entry = Entry.create!( - account: account, - amount: initial_value, - name: "Initial #{account.accountable_type.humanize.downcase} valuation", - date: 2.years.ago.to_date, - currency: account.currency, - entryable_type: "Valuation", - entryable_attributes: {} - ) - entries << entry - end - - entries - end - - # Update account balances to match the sum of their transactions and valuations - # This ensures realistic balances without artificial balancing transactions - def update_account_balances_from_transactions!(family) - family.accounts.each do |account| - transaction_sum = account.entries - .where(entryable_type: [ "Transaction", "Trade", "Valuation" ]) - .sum(:amount) - - # Calculate realistic balance based on transaction sum and account type - # For assets: balance should be positive, so we negate the transaction sum - # For liabilities: balance should reflect debt owed - new_balance = case account.classification - when "asset" - -transaction_sum # Assets: negative transaction sum = positive balance - when "liability" - transaction_sum # Liabilities: positive transaction sum = positive debt balance - else - -transaction_sum - end - - # Ensure minimum realistic balance - new_balance = [ new_balance, minimum_realistic_balance(account) ].max - - account.update!(balance: new_balance) - end - end - - private - - def create_income_transactions!(account) - entries = [] - - 6.times do |i| - transaction = create_transaction!( - account: account, - name: "Salary #{i + 1}", - amount: -income_amount, - date: random_date_within_days(90), - currency: account.currency - ) - entries << transaction.entry - end - - entries - end - - def create_expense_transactions!(account, categories) - entries = [] - expense_types = [ - { name: "Grocery Store", amount_range: [ 50, 200 ] }, - { name: "Gas Station", amount_range: [ 30, 80 ] }, - { name: "Restaurant", amount_range: [ 25, 150 ] }, - { name: "Online Purchase", amount_range: [ 20, 300 ] } - ] - - 20.times do - expense_type = expense_types.sample - transaction = create_transaction!( - account: account, - name: expense_type[:name], - amount: expense_amount(expense_type[:amount_range][0], expense_type[:amount_range][1]), - date: random_date_within_days(90), - currency: account.currency, - category: categories.sample - ) - entries << transaction.entry - end - - entries - end - - def create_credit_card_transactions!(account, categories) - entries = [] - - credit_card_merchants = [ - { name: "Amazon Purchase", amount_range: [ 25, 500 ] }, - { name: "Target", amount_range: [ 30, 150 ] }, - { name: "Coffee Shop", amount_range: [ 5, 15 ] }, - { name: "Department Store", amount_range: [ 50, 300 ] }, - { name: "Subscription Service", amount_range: [ 10, 50 ] } - ] - - 25.times do - merchant_data = credit_card_merchants.sample - transaction = create_transaction!( - account: account, - name: merchant_data[:name], - amount: expense_amount(merchant_data[:amount_range][0], merchant_data[:amount_range][1]), - date: random_date_within_days(90), - currency: account.currency, - category: categories.sample - ) - entries << transaction.entry - end - - entries - end - - def create_investment_trades!(account) - securities = Security.limit(3) - return [] unless securities.any? - - entries = [] - - trade_patterns = [ - { type: "buy", qty_range: [ 1, 50 ] }, - { type: "buy", qty_range: [ 10, 100 ] }, - { type: "sell", qty_range: [ 1, 25 ] } - ] - - 15.times do - security = securities.sample - pattern = trade_patterns.sample - - recent_price = security.prices.order(date: :desc).first&.price || 100.0 - - trade = create_trade!( - account: account, - security: security, - qty: rand(pattern[:qty_range][0]..pattern[:qty_range][1]), - price: recent_price * (0.95 + rand * 0.1), - date: random_date_within_days(90), - currency: account.currency - ) - entries << trade.entry - end - - entries - end - - def create_bulk_investment_trades!(account, count) - securities = Security.limit(5) - return [] unless securities.any? - - entries = [] - - count.times do |i| - security = securities.sample - recent_price = security.prices.order(date: :desc).first&.price || 100.0 - - trade = create_trade!( - account: account, - security: security, - qty: rand(1..100), - price: recent_price * (0.9 + rand * 0.2), - date: random_date_within_days(365), - currency: account.currency, - name: "Bulk Trade #{i + 1}" - ) - entries << trade.entry - end - - entries - end - - def create_bulk_transactions!(account, count, income: false, credit_card: false) - entries = [] - - # Handle credit cards specially to ensure balanced purchases and payments - if account.accountable_type == "CreditCard" - # Create a mix of purchases (positive) and payments (negative) - purchase_count = (count * 0.8).to_i # 80% purchases - payment_count = count - purchase_count # 20% payments - - total_purchases = 0 - - # Create purchases first - purchase_count.times do |i| - amount = expense_amount(10, 200) # Credit card purchases (positive) - total_purchases += amount - - transaction = create_transaction!( - account: account, - name: "Bulk CC Purchase #{i + 1}", - amount: amount, - date: random_date_within_days(365), - currency: account.currency - ) - entries << transaction.entry - end - - # Create reasonable payments (negative amounts) - # Payments should be smaller than total debt available - initial_debt = 5000 # From initial valuation - available_debt = initial_debt + total_purchases - - payment_count.times do |i| - # Payment should be reasonable portion of available debt - max_payment = [ available_debt * 0.1, 500 ].max # 10% of debt or min $500 - amount = -expense_amount(50, max_payment.to_i) # Payment (negative) - - transaction = create_transaction!( - account: account, - name: "Credit card payment #{i + 1}", - amount: amount, - date: random_date_within_days(365), - currency: account.currency - ) - entries << transaction.entry - end - - else - # Regular handling for non-credit card accounts - count.times do |i| - amount = if income - -income_amount # Income (negative) - elsif credit_card - expense_amount(10, 200) # This path shouldn't be reached for actual credit cards - else - expense_amount(5, 500) # Regular expenses (positive) - end - - name_prefix = if income - "Bulk Income" - elsif credit_card - "Bulk CC Purchase" - else - "Bulk Expense" - end - - transaction = create_transaction!( - account: account, - name: "#{name_prefix} #{i + 1}", - amount: amount, - date: random_date_within_days(365), - currency: account.currency - ) - entries << transaction.entry - end - end - - entries - end - - def expense_amount(min_or_range = :small, max = nil) - if min_or_range.is_a?(Symbol) - case min_or_range - when :small then random_amount(10, 200) - when :medium then random_amount(50, 500) - when :large then random_amount(200, 1000) - when :credit_card then random_amount(20, 300) - else random_amount(10, 500) - end - else - max_amount = max || (min_or_range + 100) - random_amount(min_or_range, max_amount) - end - end - - def income_amount(type = :salary) - case type - when :salary then random_amount(3000, 7000) - when :dividend then random_amount(100, 500) - when :interest then random_amount(50, 200) - else random_amount(1000, 5000) - end - end - - # Determine minimum realistic balance for account type - def minimum_realistic_balance(account) - case account.accountable_type - when "Depository" - account.subtype == "savings" ? 1000 : 500 - when "Investment" - 5000 - when "Property" - 100000 - when "Vehicle" - 5000 - when "OtherAsset" - 100 - when "CreditCard", "Loan", "OtherLiability" - 100 # Minimum debt - else - 100 - end - end -end diff --git a/app/models/demo/transfer_generator.rb b/app/models/demo/transfer_generator.rb deleted file mode 100644 index 3bd3939c..00000000 --- a/app/models/demo/transfer_generator.rb +++ /dev/null @@ -1,159 +0,0 @@ -class Demo::TransferGenerator - include Demo::DataHelper - - def initialize - end - - def create_transfer_transactions!(family, count: 1) - accounts_by_type = group_accounts_by_type(family) - created_transfers = [] - - count.times do |i| - suffix = count > 1 ? "_#{i + 1}" : "" - - created_transfers.concat(create_credit_card_payments!(accounts_by_type, suffix: suffix)) - created_transfers.concat(create_investment_contributions!(accounts_by_type, suffix: suffix)) - created_transfers.concat(create_savings_transfers!(accounts_by_type, suffix: suffix)) - created_transfers.concat(create_loan_payments!(accounts_by_type, suffix: suffix)) - end - - created_transfers - end - - def create_transfer!(from_account:, to_account:, amount:, date:, description: "") - transfer = Transfer.from_accounts( - from_account: from_account, - to_account: to_account, - date: date, - amount: amount - ) - - transfer.inflow_transaction.entry.update!( - name: "#{description.presence || 'Transfer'} from #{from_account.name}" - ) - transfer.outflow_transaction.entry.update!( - name: "#{description.presence || 'Transfer'} to #{to_account.name}" - ) - - transfer.status = "confirmed" - transfer.save! - - transfer - end - - private - - def create_credit_card_payments!(accounts_by_type, suffix: "") - checking_accounts = accounts_by_type[:checking] - credit_cards = accounts_by_type[:credit_cards] - transfers = [] - - return transfers unless checking_accounts.any? && credit_cards.any? - - checking = checking_accounts.first - - credit_cards.each_with_index do |credit_card, index| - payment_amount = [ credit_card.balance.abs * 0.3, 500 ].max - payment_date = (7 + index * 3).days.ago.to_date - - transfer = create_transfer!( - from_account: checking, - to_account: credit_card, - amount: payment_amount, - date: payment_date, - description: "Credit card payment#{suffix}" - ) - transfers << transfer - end - - transfers - end - - def create_investment_contributions!(accounts_by_type, suffix: "") - checking_accounts = accounts_by_type[:checking] - investment_accounts = accounts_by_type[:investments] - transfers = [] - - return transfers unless checking_accounts.any? && investment_accounts.any? - - checking = checking_accounts.first - - investment_accounts.each_with_index do |investment, index| - contribution_amount = case investment.name - when /401k/i then 1500 - when /Roth/i then 500 - else 1000 - end - - contribution_date = (14 + index * 7).days.ago.to_date - - transfer = create_transfer!( - from_account: checking, - to_account: investment, - amount: contribution_amount, - date: contribution_date, - description: "Investment contribution#{suffix}" - ) - transfers << transfer - end - - transfers - end - - def create_savings_transfers!(accounts_by_type, suffix: "") - checking_accounts = accounts_by_type[:checking] - savings_accounts = accounts_by_type[:savings] - transfers = [] - - return transfers unless checking_accounts.any? && savings_accounts.any? - - checking = checking_accounts.first - - savings_accounts.each_with_index do |savings, index| - transfer_amount = 1000 - transfer_date = (21 + index * 5).days.ago.to_date - - transfer = create_transfer!( - from_account: checking, - to_account: savings, - amount: transfer_amount, - date: transfer_date, - description: "Savings transfer#{suffix}" - ) - transfers << transfer - end - - transfers - end - - def create_loan_payments!(accounts_by_type, suffix: "") - checking_accounts = accounts_by_type[:checking] - loans = accounts_by_type[:loans] - transfers = [] - - return transfers unless checking_accounts.any? && loans.any? - - checking = checking_accounts.first - - loans.each_with_index do |loan, index| - payment_amount = case loan.name - when /Mortgage/i then 2500 - when /Auto/i, /Car/i then 450 - else 500 - end - - payment_date = (28 + index * 2).days.ago.to_date - - transfer = create_transfer!( - from_account: checking, - to_account: loan, - amount: payment_amount, - date: payment_date, - description: "Loan payment#{suffix}" - ) - transfers << transfer - end - - transfers - end -end diff --git a/db/schema.rb b/db/schema.rb index 9c10112c..33074c58 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -30,7 +30,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_10_181219) do t.decimal "balance", precision: 19, scale: 4 t.string "currency" t.boolean "is_active", default: true, null: false - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" t.uuid "plaid_account_id" t.boolean "scheduled_for_deletion", default: false diff --git a/db/seeds.rb b/db/seeds.rb index 948b2395..3de4ad5e 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -2,7 +2,7 @@ # development, test). The code here should be idempotent so that it can be executed at any point in every environment. # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). -puts 'Run the following command to create demo data: `rake demo_data:reset`' if Rails.env.development? +puts 'Run the following command to create demo data: `rake demo_data:default`' if Rails.env.development? Dir[Rails.root.join('db', 'seeds', '*.rb')].sort.each do |file| puts "Loading seed file: #{File.basename(file)}" diff --git a/lib/tasks/benchmarking.rake b/lib/tasks/benchmarking.rake new file mode 100644 index 00000000..531ed7b7 --- /dev/null +++ b/lib/tasks/benchmarking.rake @@ -0,0 +1,154 @@ +# Benchmarking requires a production-like data sample, so requires some up-front setup. +# +# 1. Load a scrubbed production-like slice of data into maybe_benchmarking DB locally +# 2. Setup .env.production so that the Rails app can boot with RAILS_ENV=production and connect to local maybe_benchmarking DB +# 3. Run `rake benchmark_dump:06_setup_bench_user` +# 4. Run locally, find endpoint needed +# 5. Run an endpoint, example: `ENDPOINT=/budgets/jun-2025/budget_categories/245637cb-129f-4612-b0a8-1de57559372b RAILS_ENV=production BENCHMARKING_ENABLED=true RAILS_LOG_LEVEL=debug rake benchmarking:ips` +namespace :benchmarking do + # When to use: Track overall endpoint speed improvements over time (recommended, most practical test) + desc "Run cold & warm performance benchmarks and append to history" + task ips: :environment do + path = ENV.fetch("ENDPOINT", "/") + + # 🚫 Fail fast unless the benchmark is run in production mode + unless Rails.env.production? + raise "benchmark:ips must be run with RAILS_ENV=production (current: #{Rails.env})" + end + + # --------------------------------------------------------------------------- + # Tunable parameters – override with environment variables if needed + # --------------------------------------------------------------------------- + cold_warmup = Integer(ENV.fetch("COLD_WARMUP", 0)) # seconds to warm up before *cold* timing (0 == true cold) + cold_iterations = Integer(ENV.fetch("COLD_ITERATIONS", 1)) # requests to measure for the cold run + + warm_warmup = Integer(ENV.fetch("WARM_WARMUP", 5)) # seconds benchmark-ips uses to stabilise JIT/caches + warm_time = Integer(ENV.fetch("WARM_TIME", 30)) # seconds benchmark-ips samples for warm statistics + # --------------------------------------------------------------------------- + + setup_benchmark_env(path) + FileUtils.mkdir_p("tmp/benchmarks") + + timestamp = Time.current.strftime("%Y-%m-%d %H:%M:%S") + commit_sha = `git rev-parse --short HEAD 2>/dev/null`.strip rescue "unknown" + puts "πŸ•’ Starting benchmark run at #{timestamp} (#{commit_sha})" + + # 🚿 Flush application caches so the first request is a *true* cold hit + Rails.cache&.clear if defined?(Rails) + + # --------------------------- + # 1️⃣ Cold measurement + # --------------------------- + puts "❄️ Running cold benchmark for #{path} (#{cold_iterations} iteration)..." + cold_cmd = "IPS_WARMUP=#{cold_warmup} IPS_TIME=0 IPS_ITERATIONS=#{cold_iterations} " \ + "bundle exec derailed exec perf:ips" + cold_output = `#{cold_cmd} 2>&1` + + puts "Cold output:" + puts cold_output + + cold_result = extract_clean_results(cold_output) + + # --------------------------- + # 2️⃣ Warm measurement + # --------------------------- + puts "πŸ”₯ Running warm benchmark for #{path} (#{warm_time}s sample)..." + warm_cmd = "IPS_WARMUP=#{warm_warmup} IPS_TIME=#{warm_time} " \ + "bundle exec derailed exec perf:ips" + warm_output = `#{warm_cmd} 2>&1` + + puts "Warm output:" + puts warm_output + + warm_result = extract_clean_results(warm_output) + + # --------------------------------------------------------------------------- + # Persist results + # --------------------------------------------------------------------------- + separator = "\n" + "=" * 70 + "\n" + timestamp_header = "#{separator}πŸ“Š BENCHMARK RUN - #{timestamp} (#{commit_sha})#{separator}" + + # Table header + table_header = "| Type | IPS | Deviation | Time/Iteration | Iterations | Total Time |\n" + table_separator = "|------|-----|-----------|----------------|------------|------------|\n" + + cold_row = format_table_row("COLD", cold_result) + warm_row = format_table_row("WARM", warm_result) + + combined_result = table_header + table_separator + cold_row + warm_row + "\n" + + File.open(benchmark_file(path), "a") { |f| f.write(timestamp_header + combined_result) } + + puts "βœ… Results saved to #{benchmark_file(path)}" + end + + private + def setup_benchmark_env(path) + ENV["USE_AUTH"] = "true" + ENV["USE_SERVER"] = "puma" + ENV["PATH_TO_HIT"] = path + ENV["HTTP_METHOD"] = "GET" + ENV["RAILS_LOG_LEVEL"] ||= "error" # keep output clean + end + + def benchmark_file(path) + filename = case path + when "/" then "dashboard" + else + path.gsub("/", "_").gsub(/^_+/, "") + end + "tmp/benchmarks/#{filename}.txt" + end + + def extract_clean_results(output) + lines = output.split("\n") + + # Example benchmark-ips output line: + # " SomeLabel 14.416k (Β± 3.8%) i/s - 72.000k in 5.004618s" + result_line = lines.find { |line| line.match(/\d[\d\.kM]*\s+\(Β±\s*[0-9\.]+%\)\s+i\/s/) } + + if result_line + if (match = result_line.match(/(\d[\d\.kM]*)\s+\(Β±\s*([0-9\.]+)%\)\s+i\/s\s+(?:\(([^)]+)\)\s+)?-\s+(\d[\d\.kM]*)\s+in\s+(\d+\.\d+)s/)) + ips_value = match[1] + deviation_percent = match[2].to_f + time_per_iteration = match[3] || "-" + iterations = match[4] + total_time = "#{match[5]}s" + + { + ips: ips_value, + deviation: "Β± %.2f%%" % deviation_percent, + time_per_iteration: time_per_iteration, + iterations: iterations, + total_time: total_time + } + else + no_data_hash + end + else + no_data_hash("No results") + end + end + + def format_table_row(type, data) + # Wider deviation column accommodates strings like "Β± 0.12%" + "| %-4s | %-5s | %-11s | %-14s | %-10s | %-10s |\n" % [ + type, + data[:ips], + data[:deviation], + data[:time_per_iteration], + data[:iterations], + data[:total_time] + ] + end + + def no_data_hash(ips_msg = "No data") + { + ips: ips_msg, + deviation: "-", + time_per_iteration: "-", + iterations: "-", + total_time: "-" + } + end +end diff --git a/lib/tasks/demo_data.rake b/lib/tasks/demo_data.rake index b6ecb6b9..a194d3fb 100644 --- a/lib/tasks/demo_data.rake +++ b/lib/tasks/demo_data.rake @@ -1,39 +1,63 @@ namespace :demo_data do - desc "Creates a new user with no data. Use for testing empty data states." + desc "Load empty demo dataset (no financial data)" task empty: :environment do - families = [ "Demo Family 1" ] - Demo::Generator.new.reset_and_clear_data!(families) + start = Time.now + puts "πŸš€ Loading EMPTY demo data…" + + Demo::Generator.new.generate_empty_data! + + puts "βœ… Done in #{(Time.now - start).round(2)}s" end - desc "Creates a new user who has to go through onboarding still. Use for testing onboarding flows." + desc "Load new-user demo dataset (family created but not onboarded)" task new_user: :environment do - families = [ "Demo Family 1" ] - Demo::Generator.new.reset_and_clear_data!(families, require_onboarding: true) + start = Time.now + puts "πŸš€ Loading NEW-USER demo data…" + + Demo::Generator.new.generate_new_user_data! + + puts "βœ… Done in #{(Time.now - start).round(2)}s" end - desc "General data reset that loads semi-realistic data" - task :reset, [ :count ] => :environment do |t, args| - count = (args[:count] || 1).to_i - families = count.times.map { |i| "Demo Family #{i + 1}" } - Demo::Generator.new.reset_data!(families) + desc "Load full realistic demo dataset" + task default: :environment do + start = Time.now + seed = ENV.fetch("SEED", Random.new_seed) + puts "πŸš€ Loading FULL demo data (seed=#{seed})…" + + generator = Demo::Generator.new(seed: seed) + generator.generate_default_data! + + validate_demo_data! + + elapsed = Time.now - start + puts "πŸŽ‰ Demo data ready in #{elapsed.round(2)}s" end - desc "Use this when you need to test multi-currency features of the app with a minimal setup" - task multi_currency: :environment do - families = [ "Demo Family 1", "Demo Family 2" ] - Demo::Generator.new.generate_multi_currency_data!(families) - end + # --------------------------------------------------------------------------- + # Validation helpers + # --------------------------------------------------------------------------- + def validate_demo_data! + total_entries = Entry.count + trade_entries = Entry.where(entryable_type: "Trade").count + categorized_txn = Transaction.joins(:category).count + txn_total = Transaction.count - desc "Use this when you want realistic budget data" - task basic_budget: :environment do - families = [ "Demo Family 1" ] - Demo::Generator.new.generate_basic_budget_data!(families) - end + coverage = ((categorized_txn.to_f / txn_total) * 100).round(1) - # DO NOT RUN THIS unless you're testing performance locally. It will take a long time to load/clear. Easiest to clear with a db:reset - desc "Generates realistic data for 500 families for performance testing. Creates 1 family with Ruby, then efficiently duplicates it 499 times using SQL bulk operations." - task performance_testing: :environment do - families = [ "Performance Family 1" ] - Demo::Generator.new.generate_performance_testing_data!(families) + puts "\nπŸ“Š Validation Summary".ljust(40, "-") + puts "Entries total: #{total_entries}" + puts "Trade entries: #{trade_entries} (#{trade_entries.between?(500, 1000) ? 'βœ…' : '❌'})" + puts "Txn categorization: #{coverage}% (>=75% βœ…)" + + unless total_entries.between?(8_000, 12_000) + raise "Total entries #{total_entries} outside 8k–12k range" + end + unless trade_entries.between?(500, 1000) + raise "Trade entries #{trade_entries} outside 500–1 000 range" + end + unless coverage >= 75 + raise "Categorization coverage below 75%" + end end end diff --git a/perf.rake b/perf.rake new file mode 100644 index 00000000..07344a24 --- /dev/null +++ b/perf.rake @@ -0,0 +1,37 @@ +# Must be in root of repo for derailed_benchmarks to read the benchmark file + +require 'bundler' +Bundler.setup + +require 'derailed_benchmarks' +require 'derailed_benchmarks/tasks' + +# Custom auth helper for Maybe's session-based authentication +class CustomAuth < DerailedBenchmarks::AuthHelper + def setup + # No setup needed + end + + def call(env) + # Make sure this user is created in the DB with realistic data before running benchmarks + user = User.find_by!(email: "user@maybe.local") + + puts "Found user for benchmarking: #{user.email}" + + # Mimic the way Rails handles browser cookies + session = user.sessions.create! + key_generator = Rails.application.key_generator + secret = key_generator.generate_key('signed cookie') + verifier = ActiveSupport::MessageVerifier.new(secret) + signed_value = verifier.generate(session.id) + + env['HTTP_COOKIE'] = "session_token=#{signed_value}" + + puts "Setting up session for user: #{user.email}" + + app.call(env) + end +end + +# Tells derailed_benchmarks to use our custom auth helper +DerailedBenchmarks.auth = CustomAuth.new -- 2.53.0 From a5f1677f60f887aa81fda5027efbac90e3a06c7f Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sun, 15 Jun 2025 10:09:46 -0400 Subject: [PATCH 18/20] perf(income statement): cache income statement queries (#2371) * Leftover cleanup from prior PR * Benchmark convenience task * Change default warm benchmark time * Cache income statement queries * Fix private method access --- app/models/account/syncer.rb | 7 + app/models/demo/generator.rb | 120 ++++++++---------- app/models/income_statement.rb | 36 +++++- .../pages/dashboard/_balance_sheet.html.erb | 2 +- lib/tasks/benchmarking.rake | 11 +- perf.rake | 4 +- 6 files changed, 103 insertions(+), 77 deletions(-) diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index ab198a95..b8a63d41 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -13,6 +13,13 @@ class Account::Syncer def perform_post_sync account.family.auto_match_transfers! + + # Warm IncomeStatement caches so subsequent requests are fast + # TODO: this is a temporary solution to speed up pages. Long term we'll throw a materialized view / pre-computed table + # in for family stats. + income_statement = IncomeStatement.new(account.family) + Rails.logger.info("Warming IncomeStatement caches") + income_statement.warm_caches! end private diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index ee9cc14e..fb64c87d 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -29,9 +29,59 @@ class Demo::Generator # Expose the seed so callers can reproduce a run if necessary. attr_reader :seed - # --------------------------------------------------------------------------- - # Performance helpers - # --------------------------------------------------------------------------- + # Generate empty family - no financial data + def generate_empty_data!(skip_clear: false) + with_timing(__method__) do + unless skip_clear + puts "🧹 Clearing existing data..." + clear_all_data! + end + + puts "πŸ‘₯ Creating empty family..." + create_family_and_users!("Demo Family", "user@maybe.local", onboarded: true, subscribed: true) + + puts "βœ… Empty demo data loaded successfully!" + end + end + + # Generate new user family - no financial data, needs onboarding + def generate_new_user_data!(skip_clear: false) + with_timing(__method__) do + unless skip_clear + puts "🧹 Clearing existing data..." + clear_all_data! + end + + puts "πŸ‘₯ Creating new user family..." + create_family_and_users!("Demo Family", "user@maybe.local", onboarded: false, subscribed: false) + + puts "βœ… New user demo data loaded successfully!" + end + end + + # Generate comprehensive realistic demo data with multi-currency + def generate_default_data!(skip_clear: false, email: "user@maybe.local") + if skip_clear + puts "⏭️ Skipping data clearing (appending new family)..." + else + puts "🧹 Clearing existing data..." + clear_all_data! + end + + with_timing(__method__, max_seconds: 1000) do + puts "πŸ‘₯ Creating demo family..." + family = create_family_and_users!("Demo Family", email, onboarded: true, subscribed: true) + + puts "πŸ“Š Creating realistic financial data..." + create_realistic_categories!(family) + create_realistic_accounts!(family) + create_realistic_transactions!(family) + # Auto-fill current-month budget based on recent spending averages + generate_budget_auto_fill!(family) + + puts "βœ… Realistic demo data loaded successfully!" + end + end private @@ -58,66 +108,7 @@ class Demo::Generator @rng.rand(*args) end - # Generate empty family - no financial data - def generate_empty_data!(skip_clear: false) - with_timing(__method__) do - unless skip_clear - puts "🧹 Clearing existing data..." - clear_all_data! - end - puts "πŸ‘₯ Creating empty family..." - create_family_and_users!("Demo Family", "user@maybe.local", onboarded: true, subscribed: true) - - puts "βœ… Empty demo data loaded successfully!" - end - end - - # Generate new user family - no financial data, needs onboarding - def generate_new_user_data!(skip_clear: false) - with_timing(__method__) do - unless skip_clear - puts "🧹 Clearing existing data..." - clear_all_data! - end - - puts "πŸ‘₯ Creating new user family..." - create_family_and_users!("Demo Family", "user@maybe.local", onboarded: false, subscribed: false) - - puts "βœ… New user demo data loaded successfully!" - end - end - - # Generate comprehensive realistic demo data with multi-currency - def generate_default_data!(skip_clear: false, email: "user@maybe.local") - if skip_clear - puts "⏭️ Skipping data clearing (appending new family)..." - else - puts "🧹 Clearing existing data..." - clear_all_data! - end - - with_timing(__method__, max_seconds: 1000) do - puts "πŸ‘₯ Creating demo family..." - family = create_family_and_users!("Demo Family", email, onboarded: true, subscribed: true) - - puts "πŸ“Š Creating realistic financial data..." - create_realistic_categories!(family) - create_realistic_accounts!(family) - create_realistic_transactions!(family) - # Auto-fill current-month budget based on recent spending averages - generate_budget_auto_fill!(family) - - puts "βœ… Realistic demo data loaded successfully!" - end - end - - # Multi-currency support (keeping existing functionality) - def generate_multi_currency_data!(family_names) - with_timing(__method__) do - generate_for_scenario(:multi_currency, family_names) - end - end def clear_all_data! family_count = Family.count @@ -1226,8 +1217,3 @@ class Demo::Generator puts " βœ… Set property and vehicle valuations" end end - -# Expose public API after full class definition -Demo::Generator.public_instance_methods.include?(:generate_default_data!) or Demo::Generator.class_eval do - public :generate_empty_data!, :generate_new_user_data!, :generate_default_data!, :generate_multi_currency_data! -end diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index fba114e4..dc239ee3 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -53,6 +53,13 @@ class IncomeStatement family_stats(interval: interval).find { |stat| stat.classification == "income" }&.median || 0 end + def warm_caches!(interval: "month") + totals + family_stats(interval: interval) + category_stats(interval: interval) + nil + end + private ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money, :missing_exchange_rates?) PeriodTotal = Data.define(:classification, :total, :currency, :missing_exchange_rates?, :category_totals) @@ -102,21 +109,40 @@ class IncomeStatement def family_stats(interval: "month") @family_stats ||= {} - @family_stats[interval] ||= FamilyStats.new(family, interval:).call + @family_stats[interval] ||= Rails.cache.fetch([ + "income_statement", "family_stats", family.id, interval, entries_cache_version + ]) { FamilyStats.new(family, interval:).call } end def category_stats(interval: "month") @category_stats ||= {} - @category_stats[interval] ||= CategoryStats.new(family, interval:).call + @category_stats[interval] ||= Rails.cache.fetch([ + "income_statement", "category_stats", family.id, interval, entries_cache_version + ]) { CategoryStats.new(family, interval:).call } end def totals_query(transactions_scope:) - @totals_query_cache ||= {} - cache_key = Digest::MD5.hexdigest(transactions_scope.to_sql) - @totals_query_cache[cache_key] ||= Totals.new(family, transactions_scope: transactions_scope).call + sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql) + + Rails.cache.fetch([ + "income_statement", "totals_query", family.id, sql_hash, entries_cache_version + ]) { Totals.new(family, transactions_scope: transactions_scope).call } end def monetizable_currency family.currency end + + # Returns a monotonically increasing integer based on the most recent + # update to any Entry that belongs to the family. Incorporated into cache + # keys so they expire automatically on data changes. + def entries_cache_version + @entries_cache_version ||= begin + ts = Entry.joins(:account) + .where(accounts: { family_id: family.id }) + .maximum(:updated_at) + + ts.present? ? ts.to_i : 0 + end + end end diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index 6d131782..60f7786b 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -91,7 +91,7 @@ # Calculate weight as percentage of classification total classification_total = classification_group.total_money.amount account_weight = classification_total.zero? ? 0 : account.converted_balance / classification_total * 100 - %> + %> <%= render "pages/dashboard/group_weight", weight: account_weight, color: account_group.color %>
diff --git a/lib/tasks/benchmarking.rake b/lib/tasks/benchmarking.rake index 531ed7b7..4943cb5c 100644 --- a/lib/tasks/benchmarking.rake +++ b/lib/tasks/benchmarking.rake @@ -6,9 +6,16 @@ # 4. Run locally, find endpoint needed # 5. Run an endpoint, example: `ENDPOINT=/budgets/jun-2025/budget_categories/245637cb-129f-4612-b0a8-1de57559372b RAILS_ENV=production BENCHMARKING_ENABLED=true RAILS_LOG_LEVEL=debug rake benchmarking:ips` namespace :benchmarking do + desc "Shorthand task for running warm/cold benchmark" + task endpoint: :environment do + system( + "RAILS_ENV=production BENCHMARKING_ENABLED=true ENDPOINT=#{ENV.fetch("ENDPOINT", "/")} rake benchmarking:warm_cold_endpoint_ips" + ) + end + # When to use: Track overall endpoint speed improvements over time (recommended, most practical test) desc "Run cold & warm performance benchmarks and append to history" - task ips: :environment do + task warm_cold_endpoint_ips: :environment do path = ENV.fetch("ENDPOINT", "/") # 🚫 Fail fast unless the benchmark is run in production mode @@ -23,7 +30,7 @@ namespace :benchmarking do cold_iterations = Integer(ENV.fetch("COLD_ITERATIONS", 1)) # requests to measure for the cold run warm_warmup = Integer(ENV.fetch("WARM_WARMUP", 5)) # seconds benchmark-ips uses to stabilise JIT/caches - warm_time = Integer(ENV.fetch("WARM_TIME", 30)) # seconds benchmark-ips samples for warm statistics + warm_time = Integer(ENV.fetch("WARM_TIME", 10)) # seconds benchmark-ips samples for warm statistics # --------------------------------------------------------------------------- setup_benchmark_env(path) diff --git a/perf.rake b/perf.rake index 07344a24..4f7cc67b 100644 --- a/perf.rake +++ b/perf.rake @@ -16,7 +16,7 @@ class CustomAuth < DerailedBenchmarks::AuthHelper # Make sure this user is created in the DB with realistic data before running benchmarks user = User.find_by!(email: "user@maybe.local") - puts "Found user for benchmarking: #{user.email}" + Rails.logger.debug "Found user for benchmarking: #{user.email}" # Mimic the way Rails handles browser cookies session = user.sessions.create! @@ -27,7 +27,7 @@ class CustomAuth < DerailedBenchmarks::AuthHelper env['HTTP_COOKIE'] = "session_token=#{signed_value}" - puts "Setting up session for user: #{user.email}" + Rails.logger.debug "Setting up session for user: #{user.email}" app.call(env) end -- 2.53.0 From 6d9bb7f0eb6b657afe5010db7b23274da3b6c8b5 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sun, 15 Jun 2025 11:36:21 -0400 Subject: [PATCH 19/20] Temporary transactions page performance fix (#2372) * Temporary transactions page performance fix * Fix Cursor bugs * More bugbot bug fixes --- app/controllers/transactions_controller.rb | 106 ++++++++++++++++----- 1 file changed, 82 insertions(+), 24 deletions(-) diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index e0e85f89..ce933234 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -3,6 +3,8 @@ class TransactionsController < ApplicationController before_action :store_params!, only: :index + require "digest/md5" + def new super @income_categories = Current.family.categories.incomes.alphabetically @@ -15,35 +17,91 @@ class TransactionsController < ApplicationController set_focused_record(transactions_query, params[:focused_record_id], default_per_page: 50) - @pagy, @transactions = pagy( - transactions_query.includes( - { entry: :account }, - :category, :merchant, :tags, - transfer_as_outflow: { inflow_transaction: { entry: :account } }, - transfer_as_inflow: { outflow_transaction: { entry: :account } } - ).reverse_chronological, - limit: params[:per_page].presence || default_params[:per_page], - params: ->(params) { params.except(:focused_record_id) } + # ------------------------------------------------------------------ + # Cache the expensive includes & pagination block so the DB work only + # runs when either the query params change *or* any entry has been + # updated for the current family. + # ------------------------------------------------------------------ + + latest_update_ts = Current.family.entries.maximum(:updated_at)&.utc&.to_i || 0 + + items_per_page = (params[:per_page].presence || default_params[:per_page]).to_i + items_per_page = 1 if items_per_page <= 0 + + current_page = (params[:page].presence || default_params[:page]).to_i + current_page = 1 if current_page <= 0 + + # Build a compact cache digest: sanitized filters + page info + a + # token that changes on updates *or* deletions. + entries_changed_token = [ latest_update_ts, Current.family.entries.count ].join(":") + + digest_source = { + q: @q, # processed & sanitised search params + page: current_page, # requested page number + per: items_per_page, # page size + tok: entries_changed_token + }.to_json + + cache_key = Current.family.build_cache_key( + "transactions_idx_#{Digest::MD5.hexdigest(digest_source)}" ) - # ------------------------------------------------------------------- - # Cache totals - # ------------------------------------------------------------------- - # Totals calculation is expensive (heavy SQL with grouping). We cache the - # result keyed by: - # β€’ Family id - # β€’ The family-level cache key that already embeds entries.maximum(:updated_at) - # β€’ A digest of the current search params so each distinct filter set gets - # its own cache entry. - # When any entry is created/updated/deleted, the family cache key changes, - # automatically invalidating all related totals. + cache_data = Rails.cache.fetch(cache_key, expires_in: 30.minutes) do + current_page_i = current_page - params_digest = Digest::MD5.hexdigest(@q.to_json) - cache_key = Current.family.build_cache_key("transactions_totals_#{params_digest}") + # Initial query + offset = (current_page_i - 1) * items_per_page + ids = transactions_query + .reverse_chronological + .limit(items_per_page) + .offset(offset) + .pluck(:id) - @totals = Rails.cache.fetch(cache_key) do - Current.family.income_statement.totals(transactions_scope: transactions_query) + total_count = transactions_query.count + + if ids.empty? && total_count.positive? && current_page_i > 1 + current_page_i = (total_count.to_f / items_per_page).ceil + offset = (current_page_i - 1) * items_per_page + + ids = transactions_query + .reverse_chronological + .limit(items_per_page) + .offset(offset) + .pluck(:id) + end + + { ids: ids, total_count: total_count, current_page: current_page_i } end + + ids = cache_data[:ids] + total_count = cache_data[:total_count] + current_page = cache_data[:current_page] + + # Build Pagy object (this part is cheap – done *after* potential + # page fallback so the pagination UI reflects the adjusted page + # number). + @pagy = Pagy.new( + count: total_count, + page: current_page, + items: items_per_page, + params: ->(p) { p.except(:focused_record_id) } + ) + + # Fetch the transactions in the cached order + @transactions = Current.family.transactions + .active + .where(id: ids) + .includes( + { entry: :account }, + :category, :merchant, :tags, + transfer_as_outflow: { inflow_transaction: { entry: :account } }, + transfer_as_inflow: { outflow_transaction: { entry: :account } } + ) + + # Preserve the order defined by `ids` + @transactions = ids.map { |id| @transactions.detect { |t| t.id == id } }.compact + + @totals = Current.family.income_statement.totals(transactions_scope: transactions_query) end def clear_filter -- 2.53.0 From b3471c0da9440f3fdf90172a7c9d8369cdaff3dd Mon Sep 17 00:00:00 2001 From: matrixlydev Date: Mon, 16 Jun 2025 10:36:59 +0530 Subject: [PATCH 20/20] Replaced Netherlands Antillean Guilder with new recognized currency --- config/currencies.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/config/currencies.yml b/config/currencies.yml index 07d80c39..c8703701 100644 --- a/config/currencies.yml +++ b/config/currencies.yml @@ -1650,13 +1650,13 @@ amd: delimiter: "," default_format: "%n %u" default_precision: 2 -ang: - name: Netherlands Antillean Gulden +xcg: + name: Caribbean Guilder priority: 100 - iso_code: ANG + iso_code: XCG iso_numeric: "532" - html_code: "ƒ" - symbol: Ζ’ + html_code: "" + symbol: Cg minor_unit: Cent minor_unit_conversion: 100 smallest_denomination: 1 -- 2.53.0