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/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_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..37684866 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -1,134 +1,305 @@ class Demo::Generator - include Demo::DataHelper + # Generate empty family - no financial data + def generate_empty_data! + puts "๐Ÿงน Clearing existing data..." + clear_all_data! - # 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) + puts "๐Ÿ‘ฅ Creating empty family..." + create_family_and_users!("Demo Family", "user@maybe.local", onboarded: true, subscribed: true) + + puts "โœ… Empty demo data loaded successfully!" end - def reset_data!(family_names) - generate_for_scenario(:default, family_names) + # Generate new user family - no financial data, needs onboarding + def generate_new_user_data! + puts "๐Ÿงน Clearing existing data..." + clear_all_data! + + 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 - 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) + # Generate comprehensive realistic demo data with multi-currency + def generate_default_data! + puts "๐Ÿงน Clearing existing data..." + clear_all_data! + + puts "๐Ÿ‘ฅ Creating demo family..." + family = create_family_and_users!("Demo Family", "user@maybe.local", onboarded: true, subscribed: true) + + puts "๐Ÿ“Š Creating realistic financial data..." + create_realistic_categories!(family) + create_realistic_accounts!(family) + create_realistic_transactions!(family) + create_realistic_budget!(family) + + puts "๐Ÿ”„ Syncing accounts..." + sync_family_accounts!(family) + + puts "โœ… Realistic demo data loaded successfully!" end + # Multi-currency support (keeping existing functionality) def generate_multi_currency_data!(family_names) generate_for_scenario(:multi_currency, family_names) end 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 - - def generators - @generators ||= { - data_cleaner: Demo::DataCleaner.new, - rule_generator: Demo::RuleGenerator.new, - account_generator: Demo::AccountGenerator.new, - transaction_generator: Demo::TransactionGenerator.new, - security_generator: Demo::SecurityGenerator.new, - transfer_generator: Demo::TransferGenerator.new - } - end - - def generate_for_scenario(scenario_key, family_names, **options) - raise ArgumentError, "Scenario key is required" if scenario_key.nil? - raise ArgumentError, "Family names must be provided" if family_names.nil? || family_names.empty? - - scenario_class = scenario_registry[scenario_key] - unless scenario_class - raise ArgumentError, "Unknown scenario: #{scenario_key}. Available: #{scenario_registry.keys.join(', ')}" - end - - puts "Starting #{scenario_key} scenario generation for #{family_names.length} families..." - - clear_all_data! - create_families_and_users!(family_names, **options) - families = family_names.map { |name| Family.find_by(name: name) } - - scenario = scenario_class.new(generators) - scenario.generate!(families, **options) - - # Sync families after generation (except for performance testing) - unless scenario_key == :performance_testing - puts "Running account sync for generated data..." - families.each do |family| - family.accounts.each do |account| - sync = Sync.create!(syncable: account) - sync.perform - end - puts " - #{family.name} accounts synced (#{family.accounts.count} accounts)" - end - end - - puts "Demo data loaded successfully!" - end - def clear_all_data! family_count = Family.count - - if family_count > 200 - raise "Too much data to clear efficiently (#{family_count} families found). " \ - "Please run 'bundle exec rails db:reset' instead to quickly reset the database, " \ - "then re-run your demo data task." + 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 + @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 + @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") + + @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") + + @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") + 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") + + # 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") + @uk_isa = family.accounts.create!(accountable: Investment.new, name: "Vanguard UK ISA", balance: 0, currency: "GBP") + + # Property and mortgage (USD) + @home = family.accounts.create!(accountable: Property.new, name: "Primary Residence", balance: 0, currency: "USD") + @mortgage = family.accounts.create!(accountable: Loan.new, name: "Home Mortgage", balance: 0, currency: "USD") + + # EUR vacation account + @eu_checking = family.accounts.create!(accountable: Depository.new, name: "Deutsche Bank EUR Account", balance: 0, currency: "EUR") + end + + def create_realistic_transactions!(family) + load_securities! + + # Salary income (bi-weekly) + create_transaction!(@chase_checking, -8500, "Acme Corp Payroll", @salary_cat, 14.days.ago) + create_transaction!(@chase_checking, -8500, "Acme Corp Payroll", @salary_cat, 28.days.ago) + create_transaction!(@chase_checking, -8500, "Acme Corp Payroll", @salary_cat, 42.days.ago) + create_transaction!(@chase_checking, -8500, "Acme Corp Payroll", @salary_cat, 56.days.ago) + create_transaction!(@chase_checking, -8500, "Acme Corp Payroll", @salary_cat, 70.days.ago) + create_transaction!(@chase_checking, -8500, "Acme Corp Payroll", @salary_cat, 84.days.ago) + + # Freelance income + create_transaction!(@ally_checking, -3500, "Design Project Payment", @freelance_cat, 20.days.ago) + create_transaction!(@ally_checking, -2800, "Consulting Fee", @freelance_cat, 45.days.ago) + create_transaction!(@ally_checking, -4200, "Design Retainer Q4", @freelance_cat, 60.days.ago) + + # Investment income + create_transaction!(@schwab_brokerage, -850, "Dividend Payment", @investment_income_cat, 25.days.ago) + create_transaction!(@vanguard_401k, -420, "401k Employer Match", @salary_cat, 28.days.ago) + + # Housing expenses + create_transaction!(@chase_checking, 3200, "Rent Payment", @rent_cat, 1.day.ago) + create_transaction!(@chase_checking, 3200, "Rent Payment", @rent_cat, 32.days.ago) + create_transaction!(@chase_checking, 3200, "Rent Payment", @rent_cat, 63.days.ago) + create_transaction!(@chase_checking, 185, "ConEd Electric", @utilities_cat, 5.days.ago) + create_transaction!(@chase_checking, 95, "Verizon Internet", @utilities_cat, 8.days.ago) + + # Food & dining (reduced amounts) + create_transaction!(@amex_gold, 165, "Whole Foods Market", @groceries_cat, 2.days.ago) + create_transaction!(@amex_gold, 78, "Joe's Pizza", @restaurants_cat, 3.days.ago) + create_transaction!(@amex_gold, 145, "Trader Joe's", @groceries_cat, 6.days.ago) + create_transaction!(@amex_gold, 95, "Blue Hill Restaurant", @restaurants_cat, 7.days.ago) + create_transaction!(@chase_sapphire, 185, "Michelin Star Dinner", @restaurants_cat, 12.days.ago) + + # Transportation + create_transaction!(@chase_checking, 65, "Shell Gas Station", @gas_cat, 4.days.ago) + create_transaction!(@chase_checking, 72, "Mobil Gas", @gas_cat, 18.days.ago) + + # Entertainment & subscriptions + create_transaction!(@amex_gold, 15, "Netflix", @entertainment_cat, 1.day.ago) + create_transaction!(@amex_gold, 12, "Spotify Premium", @entertainment_cat, 3.days.ago) + create_transaction!(@chase_sapphire, 45, "Movie Theater", @entertainment_cat, 9.days.ago) + + # Healthcare + create_transaction!(@chase_checking, 25, "CVS Pharmacy", @healthcare_cat, 11.days.ago) + create_transaction!(@chase_checking, 350, "Dr. Smith Office Visit", @healthcare_cat, 22.days.ago) + + # Shopping + create_transaction!(@amex_gold, 125, "Amazon Purchase", @shopping_cat, 6.days.ago) + create_transaction!(@chase_sapphire, 89, "Target", @shopping_cat, 15.days.ago) + + # European vacation (EUR) + create_transaction!(@eu_checking, 850, "Hotel Paris", @travel_cat, 35.days.ago) + create_transaction!(@eu_checking, 125, "Restaurant Lyon", @restaurants_cat, 36.days.ago) + create_transaction!(@eu_checking, 65, "Train Ticket", @transportation_cat, 37.days.ago) + + # Investment transactions (adjusted for target net worth) + security = Security.first + if security + create_investment_transaction!(@vanguard_401k, security, 150, 150, 25.days.ago, "401k Contribution") + create_investment_transaction!(@vanguard_401k, security, 200, 145, 50.days.ago, "401k Rollover") + create_investment_transaction!(@schwab_brokerage, security, 300, 150, 40.days.ago, "Stock Purchase") + create_investment_transaction!(@schwab_brokerage, security, 150, 155, 65.days.ago, "Additional Investment") + create_investment_transaction!(@uk_isa, security, 60, 120, 55.days.ago, "UK Stock Purchase") # GBP + end + + # Property and debt + create_transaction!(@home, -750000, "Home Purchase", nil, 90.days.ago) + create_transaction!(@mortgage, 450000, "Mortgage Principal", nil, 90.days.ago) + + # Add positive balance to EUR account first + create_transaction!(@eu_checking, -2500, "EUR Account Funding", nil, 40.days.ago) + + # Credit card payments and transfers + create_transfer!(@chase_checking, @amex_gold, 1250, "Amex Payment", 10.days.ago) + create_transfer!(@chase_checking, @chase_sapphire, 850, "Sapphire Payment", 12.days.ago) + create_transfer!(@ally_checking, @marcus_savings, 5000, "Savings Transfer", 15.days.ago) + + # Additional income and transfers to boost net worth + create_transaction!(@chase_checking, -12000, "Year-end Bonus", @salary_cat, 30.days.ago) + create_transaction!(@marcus_savings, -15000, "Tax Refund", @salary_cat, 50.days.ago) + create_transaction!(@ally_checking, -5000, "Stock Sale Proceeds", @investment_income_cat, 35.days.ago) + + # Additional savings transfer + create_transfer!(@chase_checking, @marcus_savings, 10000, "Additional Savings", 25.days.ago) + end + + def create_realistic_budget!(family) + current_month = Date.current.beginning_of_month + end_of_month = current_month.end_of_month + budget = family.budgets.create!( + start_date: current_month, + end_date: end_of_month, + currency: "USD", + budgeted_spending: 7100, + expected_income: 17000 + ) + + # Budget allocations based on realistic spending + budget.budget_categories.create!(category: @housing_cat, budgeted_spending: 3500, currency: "USD") + budget.budget_categories.create!(category: @food_cat, budgeted_spending: 800, currency: "USD") + budget.budget_categories.create!(category: @transportation_cat, budgeted_spending: 400, currency: "USD") + budget.budget_categories.create!(category: @entertainment_cat, budgeted_spending: 300, currency: "USD") + budget.budget_categories.create!(category: @healthcare_cat, budgeted_spending: 500, currency: "USD") + budget.budget_categories.create!(category: @shopping_cat, budgeted_spending: 600, currency: "USD") + budget.budget_categories.create!(category: @travel_cat, budgeted_spending: 1000, currency: "USD") + end + + def create_transaction!(account, amount, name, category, date) + 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 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/lib/tasks/benchmarking.rake b/lib/tasks/benchmarking.rake new file mode 100644 index 00000000..687f954d --- /dev/null +++ b/lib/tasks/benchmarking.rake @@ -0,0 +1,142 @@ +# See perf.rake for details on how to run benchmarks +# Sample command: +# BENCHMARKING_ENABLED=true RAILS_ENV=production ENDPOINT=/ rake benchmark:ips +namespace :benchmark 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` + 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` + 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"] ||= "info" # 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..0b0c7496 100644 --- a/lib/tasks/demo_data.rake +++ b/lib/tasks/demo_data.rake @@ -1,39 +1,16 @@ namespace :demo_data do - desc "Creates a new user with no data. Use for testing empty data states." + desc "Creates a family with no financial data. Use for testing empty data states." task empty: :environment do - families = [ "Demo Family 1" ] - Demo::Generator.new.reset_and_clear_data!(families) + Demo::Generator.new.generate_empty_data! end - desc "Creates a new user who has to go through onboarding still. Use for testing onboarding flows." + desc "Creates a family that needs onboarding. Use for testing onboarding flows." task new_user: :environment do - families = [ "Demo Family 1" ] - Demo::Generator.new.reset_and_clear_data!(families, require_onboarding: true) + Demo::Generator.new.generate_new_user_data! end - desc "General data reset that loads semi-realistic data" - task :reset, [ :count ] => :environment do |t, args| - count = (args[:count] || 1).to_i - families = count.times.map { |i| "Demo Family #{i + 1}" } - Demo::Generator.new.reset_data!(families) - end - - desc "Use this when you need to test multi-currency features of the app with a minimal setup" - task multi_currency: :environment do - families = [ "Demo Family 1", "Demo Family 2" ] - Demo::Generator.new.generate_multi_currency_data!(families) - end - - desc "Use this when you want realistic budget data" - task basic_budget: :environment do - families = [ "Demo Family 1" ] - Demo::Generator.new.generate_basic_budget_data!(families) - end - - # DO NOT RUN THIS unless you're testing performance locally. It will take a long time to load/clear. Easiest to clear with a db:reset - desc "Generates realistic data for 500 families for performance testing. Creates 1 family with Ruby, then efficiently duplicates it 499 times using SQL bulk operations." - task performance_testing: :environment do - families = [ "Performance Family 1" ] - Demo::Generator.new.generate_performance_testing_data!(families) + desc "Creates comprehensive realistic demo data with multi-currency accounts" + task default: :environment do + Demo::Generator.new.generate_default_data! end end diff --git a/perf.rake b/perf.rake new file mode 100644 index 00000000..0eb62dc3 --- /dev/null +++ b/perf.rake @@ -0,0 +1,31 @@ +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: ENV.fetch("BENCHMARK_USER_EMAIL", "user@maybe.local")) + + # 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}" + + app.call(env) + end +end + +# Tells derailed_benchmarks to use our custom auth helper +DerailedBenchmarks.auth = CustomAuth.new