From 84b2426e54d60a714a40e7f538e20ae4eb243550 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sat, 14 Jun 2025 11:53:53 -0400 Subject: [PATCH] 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