diff --git a/.gitignore b/.gitignore index 95c60508..ca3ce84a 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,9 @@ node_modules/ *.roo* # OS specific # Task files +.taskmaster/docs +.taskmaster/config.json +.taskmaster/templates tasks.json tasks/ *.mcp.json diff --git a/app/models/demo/account_generator.rb b/app/models/demo/account_generator.rb new file mode 100644 index 00000000..4516bea0 --- /dev/null +++ b/app/models/demo/account_generator.rb @@ -0,0 +1,238 @@ +class Demo::AccountGenerator + include Demo::DataHelper + + def create_credit_card_accounts!(family, count: 1) + accounts = [] + count.times do |i| + account = family.accounts.create!( + accountable: CreditCard.new, + name: account_name("Chase Credit Card", i, count), + balance: 0, + currency: "USD" + ) + accounts << account + end + accounts + end + + def create_checking_accounts!(family, count: 1) + accounts = [] + count.times do |i| + account = family.accounts.create!( + accountable: Depository.new, + name: account_name("Chase Checking", i, count), + balance: 0, + currency: "USD" + ) + accounts << account + end + accounts + end + + def create_savings_accounts!(family, count: 1) + accounts = [] + count.times do |i| + account = family.accounts.create!( + accountable: Depository.new, + name: account_name("Demo Savings", i, count), + balance: 0, + currency: "USD", + subtype: "savings" + ) + accounts << account + end + accounts + end + + def create_properties_and_mortgages!(family, count: 1) + accounts = [] + count.times do |i| + property = family.accounts.create!( + accountable: Property.new, + name: account_name("123 Maybe Way", i, count), + balance: 0, + currency: "USD" + ) + accounts << property + + mortgage = family.accounts.create!( + accountable: Loan.new, + name: account_name("Mortgage", i, count), + balance: 0, + currency: "USD" + ) + accounts << mortgage + end + accounts + end + + def create_vehicles_and_loans!(family, vehicle_count: 1, loan_count: 1) + accounts = [] + + vehicle_count.times do |i| + vehicle = family.accounts.create!( + accountable: Vehicle.new, + name: account_name("Honda Accord", i, vehicle_count), + balance: 0, + currency: "USD" + ) + accounts << vehicle + end + + loan_count.times do |i| + loan = family.accounts.create!( + accountable: Loan.new, + name: account_name("Car Loan", i, loan_count), + balance: 0, + currency: "USD" + ) + accounts << loan + end + + accounts + end + + def create_other_accounts!(family, asset_count: 1, liability_count: 1) + accounts = [] + + asset_count.times do |i| + asset = family.accounts.create!( + accountable: OtherAsset.new, + name: account_name("Other Asset", i, asset_count), + balance: 0, + currency: "USD" + ) + accounts << asset + end + + liability_count.times do |i| + liability = family.accounts.create!( + accountable: OtherLiability.new, + name: account_name("Other Liability", i, liability_count), + balance: 0, + currency: "USD" + ) + accounts << liability + end + + accounts + end + + def create_investment_accounts!(family, count: 3) + accounts = [] + + if count <= 3 + account_configs = [ + { name: "401(k)", balance: 0 }, + { name: "Roth IRA", balance: 0 }, + { name: "Taxable Brokerage", balance: 0 } + ] + + count.times do |i| + config = account_configs[i] || { + name: "Investment Account #{i + 1}", + balance: 0 + } + + account = family.accounts.create!( + accountable: Investment.new, + name: config[:name], + balance: config[:balance], + currency: "USD" + ) + accounts << account + end + else + count.times do |i| + account = family.accounts.create!( + accountable: Investment.new, + name: "Investment Account #{i + 1}", + balance: 0, + currency: "USD" + ) + accounts << account + end + end + + accounts + end + + private + + def realistic_balance(type, count = 1) + return send("realistic_#{type}_balance") if count == 1 + send("random_#{type}_balance") + end + def realistic_credit_card_balance + 2300 + end + + def realistic_checking_balance + 15000 + end + + def realistic_savings_balance + 40000 + end + + def realistic_property_balance + 560000 + end + + def realistic_mortgage_balance + 495000 + end + + def realistic_vehicle_balance + 18000 + end + + def realistic_car_loan_balance + 8000 + end + + def realistic_other_asset_balance + 10000 + end + + def realistic_other_liability_balance + 5000 + end + + + def random_credit_card_balance + random_positive_amount(1000, 5000) + end + + def random_checking_balance + random_positive_amount(10000, 50000) + end + + def random_savings_balance + random_positive_amount(50000, 200000) + end + + def random_property_balance + random_positive_amount(400000, 800000) + end + + def random_mortgage_balance + random_positive_amount(200000, 600000) + end + + def random_vehicle_balance + random_positive_amount(15000, 50000) + end + + def random_car_loan_balance + random_positive_amount(5000, 25000) + end + + def random_other_asset_balance + random_positive_amount(5000, 50000) + end + + def random_other_liability_balance + random_positive_amount(2000, 20000) + end +end diff --git a/app/models/demo/base_scenario.rb b/app/models/demo/base_scenario.rb new file mode 100644 index 00000000..27d9995a --- /dev/null +++ b/app/models/demo/base_scenario.rb @@ -0,0 +1,30 @@ +# Base class for demo scenario handlers - subclasses must implement generate_family_data! +class Demo::BaseScenario + def initialize(generators) + @generators = generators + end + + def generate!(families, **options) + setup(**options) if respond_to?(:setup, true) + + families.each do |family| + ActiveRecord::Base.transaction do + generate_family_data!(family, **options) + end + puts "#{scenario_name} data created for #{family.name}" + end + end + + private + + def setup(**options) + end + + def generate_family_data!(family, **options) + raise NotImplementedError, "Subclasses must implement generate_family_data!(family, **options)" + end + + def scenario_name + self.class.name.split("::").last.downcase.gsub(/([a-z])([A-Z])/, '\1 \2') + end +end diff --git a/app/models/demo/data_cleaner.rb b/app/models/demo/data_cleaner.rb new file mode 100644 index 00000000..215de352 --- /dev/null +++ b/app/models/demo/data_cleaner.rb @@ -0,0 +1,31 @@ +# SAFETY: Only operates in development/test environments to prevent data loss +class Demo::DataCleaner + SAFE_ENVIRONMENTS = %w[development test] + + def initialize + ensure_safe_environment! + end + + # Main entry point for destroying all demo data + def destroy_everything! + puts "Clearing existing data..." + + # Rails associations handle cascading deletes + Family.destroy_all + Setting.destroy_all + InviteCode.destroy_all + ExchangeRate.destroy_all + Security.destroy_all + Security::Price.destroy_all + + puts "Data cleared" + end + + private + + def ensure_safe_environment! + unless SAFE_ENVIRONMENTS.include?(Rails.env) + raise SecurityError, "Demo::DataCleaner can only be used in #{SAFE_ENVIRONMENTS.join(', ')} environments. Current: #{Rails.env}" + end + end +end diff --git a/app/models/demo/data_helper.rb b/app/models/demo/data_helper.rb new file mode 100644 index 00000000..6a10d76e --- /dev/null +++ b/app/models/demo/data_helper.rb @@ -0,0 +1,85 @@ +module Demo::DataHelper + COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a].freeze + + PERFORMANCE_TRANSACTION_COUNTS = { + depository_sample: 75, + credit_card_sample: 75, + investment_trades: 35, + investment_transactions: 35, + other_account_sample: 20 + }.freeze + + module_function + + def random_date_within_days(max_days_ago) + Faker::Number.between(from: 0, to: max_days_ago).days.ago.to_date + end + + def random_amount(min, max) + Faker::Number.between(from: min, to: max) + end + + def random_positive_amount(min, max) + Faker::Number.positive(from: min, to: max) + end + + def group_accounts_by_type(family) + accounts = family.accounts.includes(:accountable) + + { + checking: filter_checking_accounts(accounts), + savings: filter_savings_accounts(accounts), + credit_cards: filter_credit_card_accounts(accounts), + investments: filter_investment_accounts(accounts), + loans: filter_loan_accounts(accounts), + properties: filter_property_accounts(accounts), + vehicles: filter_vehicle_accounts(accounts), + other_assets: filter_other_asset_accounts(accounts), + other_liabilities: filter_other_liability_accounts(accounts) + } + end + + def filter_checking_accounts(accounts) + accounts.select { |a| a.accountable_type == "Depository" && (a.subtype != "savings" || a.name.include?("Checking")) } + end + + def filter_savings_accounts(accounts) + accounts.select { |a| a.accountable_type == "Depository" && (a.subtype == "savings" || a.name.include?("Savings")) } + end + + def filter_credit_card_accounts(accounts) + accounts.select { |a| a.accountable_type == "CreditCard" } + end + + def filter_investment_accounts(accounts) + accounts.select { |a| a.accountable_type == "Investment" } + end + + def filter_loan_accounts(accounts) + accounts.select { |a| a.accountable_type == "Loan" } + end + + def filter_property_accounts(accounts) + accounts.select { |a| a.accountable_type == "Property" } + end + + def filter_vehicle_accounts(accounts) + accounts.select { |a| a.accountable_type == "Vehicle" } + end + + def filter_other_asset_accounts(accounts) + accounts.select { |a| a.accountable_type == "OtherAsset" } + end + + def filter_other_liability_accounts(accounts) + accounts.select { |a| a.accountable_type == "OtherLiability" } + end + + def random_color + COLORS.sample + end + + def account_name(base_name, index, count = 1) + count == 1 ? base_name : "#{base_name} #{index + 1}" + end +end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index bbcc292c..369b7d95 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -1,155 +1,102 @@ class Demo::Generator - COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] + include Demo::DataHelper - # Builds a semi-realistic mirror of what production data might look like + # Public API - these methods are called by rake tasks and must be preserved def reset_and_clear_data!(family_names, require_onboarding: false) - puts "Clearing existing data..." - - destroy_everything! - - puts "Data cleared" - - family_names.each_with_index do |family_name, index| - create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", require_onboarding: require_onboarding) - end - - puts "Users reset" + generate_for_scenario(:clean_slate, family_names, require_onboarding: require_onboarding) end def reset_data!(family_names) - puts "Clearing existing data..." + generate_for_scenario(:default, family_names) + end - destroy_everything! - - puts "Data cleared" - - family_names.each_with_index do |family_name, index| - create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local") - end - - puts "Users reset" - - load_securities! - - puts "Securities loaded" - - family_names.each do |family_name| - family = Family.find_by(name: family_name) - - ActiveRecord::Base.transaction do - create_tags!(family) - create_categories!(family) - create_merchants!(family) - create_rules!(family) - puts "tags, categories, merchants created for #{family_name}" - - create_credit_card_account!(family) - create_checking_account!(family) - create_savings_account!(family) - - create_investment_account!(family) - create_house_and_mortgage!(family) - create_car_and_loan!(family) - create_other_accounts!(family) - - create_transfer_transactions!(family) - end - - puts "accounts created for #{family_name}" - end - - puts "Demo data loaded successfully!" + def generate_performance_testing_data!(family_names) + generate_for_scenario(:performance_testing, family_names) end def generate_basic_budget_data!(family_names) - puts "Clearing existing data..." - - destroy_everything! - - puts "Data cleared" - - family_names.each_with_index do |family_name, index| - create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local") - end - - puts "Users reset" - - family_names.each do |family_name| - family = Family.find_by(name: family_name) - - ActiveRecord::Base.transaction do - # Create parent categories - food = family.categories.create!(name: "Food & Drink", color: COLORS.sample, classification: "expense") - transport = family.categories.create!(name: "Transportation", color: COLORS.sample, classification: "expense") - - # Create subcategory - restaurants = family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense") - - # Create checking account - checking = family.accounts.create!( - accountable: Depository.new, - name: "Demo Checking", - balance: 3000, - currency: "USD" - ) - - # Create one transaction for each category - create_transaction!(account: checking, amount: 100, name: "Grocery Store", category: food, date: 2.days.ago) - create_transaction!(account: checking, amount: 50, name: "Restaurant Meal", category: restaurants, date: 1.day.ago) - create_transaction!(account: checking, amount: 20, name: "Gas Station", category: transport, date: Date.current) - end - - puts "Basic budget data created for #{family_name}" - end - - puts "Demo data loaded successfully!" + generate_for_scenario(:basic_budget, family_names) end def generate_multi_currency_data!(family_names) - puts "Clearing existing data..." - - destroy_everything! - - puts "Data cleared" - - family_names.each_with_index do |family_name, index| - create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", currency: "EUR") - end - - puts "Users reset" - - family_names.each do |family_name| - puts "Generating demo data for #{family_name}" - family = Family.find_by(name: family_name) - - usd_checking = family.accounts.create!(name: "USD Checking", currency: "USD", balance: 10000, accountable: Depository.new) - eur_checking = family.accounts.create!(name: "EUR Checking", currency: "EUR", balance: 4900, accountable: Depository.new) - eur_credit_card = family.accounts.create!(name: "EUR Credit Card", currency: "EUR", balance: 2300, accountable: CreditCard.new) - - create_transaction!(account: eur_credit_card, amount: 1000, currency: "EUR", name: "EUR cc expense 1") - create_transaction!(account: eur_credit_card, amount: 1000, currency: "EUR", name: "EUR cc expense 2") - create_transaction!(account: eur_credit_card, amount: 300, currency: "EUR", name: "EUR cc expense 3") - - create_transaction!(account: usd_checking, amount: -11000, currency: "USD", name: "USD income Transaction") - create_transaction!(account: usd_checking, amount: 1000, currency: "USD", name: "USD expense Transaction") - create_transaction!(account: usd_checking, amount: 1000, currency: "USD", name: "USD expense Transaction") - create_transaction!(account: eur_checking, amount: -5000, currency: "EUR", name: "EUR income Transaction") - create_transaction!(account: eur_checking, amount: 100, currency: "EUR", name: "EUR expense Transaction") - - puts "Transactions created for #{family_name}" - end - - puts "Demo data loaded successfully!" + generate_for_scenario(:multi_currency, family_names) end private - def destroy_everything! - Family.destroy_all - Setting.destroy_all - InviteCode.destroy_all - ExchangeRate.destroy_all - Security.destroy_all - Security::Price.destroy_all + + # Registry pattern for clean scenario lookup and easy extensibility + def scenario_registry + @scenario_registry ||= { + clean_slate: Demo::Scenarios::CleanSlate, + default: Demo::Scenarios::Default, + basic_budget: Demo::Scenarios::BasicBudget, + multi_currency: Demo::Scenarios::MultiCurrency, + performance_testing: Demo::Scenarios::PerformanceTesting + }.freeze + end + + def generators + @generators ||= { + data_cleaner: Demo::DataCleaner.new, + rule_generator: Demo::RuleGenerator.new, + account_generator: Demo::AccountGenerator.new, + transaction_generator: Demo::TransactionGenerator.new, + security_generator: Demo::SecurityGenerator.new, + transfer_generator: Demo::TransferGenerator.new + } + end + + def generate_for_scenario(scenario_key, family_names, **options) + raise ArgumentError, "Scenario key is required" if scenario_key.nil? + raise ArgumentError, "Family names must be provided" if family_names.nil? || family_names.empty? + + scenario_class = scenario_registry[scenario_key] + unless scenario_class + raise ArgumentError, "Unknown scenario: #{scenario_key}. Available: #{scenario_registry.keys.join(', ')}" + end + + puts "Starting #{scenario_key} scenario generation for #{family_names.length} families..." + + clear_all_data! + create_families_and_users!(family_names, **options) + families = family_names.map { |name| Family.find_by(name: name) } + + scenario = scenario_class.new(generators) + scenario.generate!(families, **options) + + # Sync families after generation (except for performance testing) + unless scenario_key == :performance_testing + puts "Running account sync for generated data..." + families.each do |family| + family.accounts.each do |account| + sync = Sync.create!(syncable: account) + sync.perform + end + puts " - #{family.name} accounts synced (#{family.accounts.count} accounts)" + end + end + + puts "Demo data loaded successfully!" + end + + def clear_all_data! + family_count = Family.count + + if family_count > 200 + raise "Too much data to clear efficiently (#{family_count} families found). " \ + "Please run 'bundle exec rails db:reset' instead to quickly reset the database, " \ + "then re-run your demo data task." + end + + generators[:data_cleaner].destroy_everything! + end + + def create_families_and_users!(family_names, require_onboarding: false, currency: "USD") + family_names.each_with_index do |family_name, index| + create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", + currency: currency, require_onboarding: require_onboarding) + end + puts "Users reset" end def create_family_and_user!(family_name, user_email, currency: "USD", require_onboarding: false) @@ -184,341 +131,4 @@ class Demo::Generator password: "password", onboarded_at: require_onboarding ? nil : Time.current end - - def create_rules!(family) - family.rules.create!( - effective_date: 1.year.ago.to_date, - active: true, - resource_type: "transaction", - conditions: [ - Rule::Condition.new(condition_type: "transaction_merchant", operator: "=", value: "Whole Foods") - ], - actions: [ - Rule::Action.new(action_type: "set_transaction_category", value: "Groceries") - ] - ) - end - - def create_tags!(family) - [ "Trips", "Emergency Fund", "Demo Tag" ].each do |tag| - family.tags.create!(name: tag) - end - end - - def create_categories!(family) - family.categories.bootstrap! - - food = family.categories.find_by(name: "Food & Drink") - family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, lucide_icon: "utensils", classification: "expense") - family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, lucide_icon: "shopping-cart", classification: "expense") - family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, lucide_icon: "beer", classification: "expense") - end - - def create_merchants!(family) - merchants = [ "Amazon", "Starbucks", "McDonald's", "Target", "Costco", - "Home Depot", "Shell", "Whole Foods", "Walgreens", "Nike", - "Uber", "Netflix", "Spotify", "Delta Airlines", "Airbnb", "Sephora" ] - - merchants.each do |merchant| - FamilyMerchant.create!(name: merchant, family: family, color: COLORS.sample) - end - end - - def create_credit_card_account!(family) - cc = family.accounts.create! \ - accountable: CreditCard.new, - name: "Chase Credit Card", - balance: 2300, - currency: "USD" - - 800.times do - merchant = random_family_record(Merchant, family) - create_transaction! \ - account: cc, - name: merchant.name, - amount: Faker::Number.positive(to: 200), - tags: [ tag_for_merchant(merchant, family) ], - category: category_for_merchant(merchant, family), - merchant: merchant - end - - 24.times do - create_transaction! \ - account: cc, - amount: Faker::Number.negative(from: -1000), - name: "CC Payment" - end - end - - def create_checking_account!(family) - checking = family.accounts.create! \ - accountable: Depository.new, - name: "Chase Checking", - balance: 15000, - currency: "USD" - - # First create income transactions to ensure positive balance - 50.times do - create_transaction! \ - account: checking, - amount: Faker::Number.negative(from: -2000, to: -500), - name: "Income", - category: family.categories.find_by(name: "Income") - end - - # Then create expenses that won't exceed the income - 200.times do - create_transaction! \ - account: checking, - name: "Expense", - amount: Faker::Number.positive(from: 8, to: 500) - end - end - - def create_savings_account!(family) - savings = family.accounts.create! \ - accountable: Depository.new, - name: "Demo Savings", - balance: 40000, - currency: "USD", - subtype: "savings" - - # Create larger income deposits first - 100.times do - create_transaction! \ - account: savings, - amount: Faker::Number.negative(from: -3000, to: -1000), - tags: [ family.tags.find_by(name: "Emergency Fund") ], - category: family.categories.find_by(name: "Income"), - name: "Income" - end - - # Add some smaller withdrawals that won't exceed the deposits - 50.times do - create_transaction! \ - account: savings, - amount: Faker::Number.positive(from: 100, to: 1000), - name: "Savings Withdrawal" - end - end - - def create_transfer_transactions!(family) - checking = family.accounts.find_by(name: "Chase Checking") - credit_card = family.accounts.find_by(name: "Chase Credit Card") - investment = family.accounts.find_by(name: "Robinhood") - - create_transaction!( - account: checking, - date: 1.day.ago.to_date, - amount: 100, - name: "Credit Card Payment" - ) - - create_transaction!( - account: credit_card, - date: 1.day.ago.to_date, - amount: -100, - name: "Credit Card Payment" - ) - - create_transaction!( - account: checking, - date: 3.days.ago.to_date, - amount: 500, - name: "Transfer to investment" - ) - - create_transaction!( - account: investment, - date: 2.days.ago.to_date, - amount: -500, - name: "Transfer from checking" - ) - end - - def load_securities! - # Create an unknown security to simulate edge cases - Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock" - - securities = [ - { ticker: "AAPL", exchange_mic: "XNGS", exchange_operating_mic: "XNAS", name: "Apple Inc.", reference_price: 210 }, - { ticker: "TM", exchange_mic: "XNYS", exchange_operating_mic: "XNYS", name: "Toyota Motor Corporation", reference_price: 202 }, - { ticker: "MSFT", exchange_mic: "XNGS", exchange_operating_mic: "XNAS", name: "Microsoft Corporation", reference_price: 455 } - ] - - securities.each do |security_attributes| - security = Security.create! security_attributes.except(:reference_price) - - # Load prices for last 2 years - (730.days.ago.to_date..Date.current).each do |date| - reference = security_attributes[:reference_price] - low_price = reference - 20 - high_price = reference + 20 - Security::Price.create! \ - security: security, - date: date, - price: Faker::Number.positive(from: low_price, to: high_price) - end - end - end - - def create_investment_account!(family) - account = family.accounts.create! \ - accountable: Investment.new, - name: "Robinhood", - balance: 100000, - currency: "USD" - - aapl = Security.find_by(ticker: "AAPL") - tm = Security.find_by(ticker: "TM") - msft = Security.find_by(ticker: "MSFT") - unknown = Security.find_by(ticker: "UNKNOWN") - - # Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices - account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Trade.new(qty: 20, price: 5, security: unknown, currency: "USD") - - trades = [ - { security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 }, - { security: msft, qty: -5 }, { security: tm, qty: 10 }, { security: msft, qty: 5 }, - { security: tm, qty: 10 }, { security: aapl, qty: -5 }, { security: msft, qty: -5 }, - { security: tm, qty: 10 }, { security: msft, qty: 5 }, { security: aapl, qty: -10 } - ] - - trades.each do |trade| - date = Faker::Number.positive(to: 730).days.ago.to_date - security = trade[:security] - qty = trade[:qty] - price = Security::Price.find_by(security: security, date: date)&.price || 1 - name_prefix = qty < 0 ? "Sell " : "Buy " - - account.entries.create! \ - date: date, - amount: qty * price, - currency: "USD", - name: name_prefix + "#{qty} shares of #{security.ticker}", - entryable: Trade.new(qty: qty, price: price, currency: "USD", security: security) - end - end - - def create_house_and_mortgage!(family) - house = family.accounts.create! \ - accountable: Property.new, - name: "123 Maybe Way", - balance: 560000, - currency: "USD" - - create_valuation!(house, 3.years.ago.to_date, 520000) - create_valuation!(house, 2.years.ago.to_date, 540000) - create_valuation!(house, 1.years.ago.to_date, 550000) - - mortgage = family.accounts.create! \ - accountable: Loan.new, - name: "Mortgage", - balance: 495000, - currency: "USD" - - create_valuation!(mortgage, 3.years.ago.to_date, 495000) - create_valuation!(mortgage, 2.years.ago.to_date, 490000) - create_valuation!(mortgage, 1.years.ago.to_date, 485000) - end - - def create_car_and_loan!(family) - vehicle = family.accounts.create! \ - accountable: Vehicle.new, - name: "Honda Accord", - balance: 18000, - currency: "USD" - - create_valuation!(vehicle, 1.year.ago.to_date, 18000) - - loan = family.accounts.create! \ - accountable: Loan.new, - name: "Car Loan", - balance: 8000, - currency: "USD" - - create_valuation!(loan, 1.year.ago.to_date, 8000) - end - - def create_other_accounts!(family) - other_asset = family.accounts.create! \ - accountable: OtherAsset.new, - name: "Other Asset", - balance: 10000, - currency: "USD" - - other_liability = family.accounts.create! \ - accountable: OtherLiability.new, - name: "Other Liability", - balance: 5000, - currency: "USD" - - create_valuation!(other_asset, 1.year.ago.to_date, 10000) - create_valuation!(other_liability, 1.year.ago.to_date, 5000) - end - - def create_transaction!(attributes = {}) - entry_attributes = attributes.except(:category, :tags, :merchant) - transaction_attributes = attributes.slice(:category, :tags, :merchant) - - entry_defaults = { - date: Faker::Number.between(from: 0, to: 730).days.ago.to_date, - currency: "USD", - entryable: Transaction.new(transaction_attributes) - } - - Entry.create! entry_defaults.merge(entry_attributes) - end - - def create_valuation!(account, date, amount) - Entry.create! \ - account: account, - date: date, - amount: amount, - currency: "USD", - name: "Balance update", - entryable: Valuation.new - end - - def random_family_record(model, family) - family_records = model.where(family_id: family.id) - model.offset(rand(family_records.count)).first - end - - def category_for_merchant(merchant, family) - mapping = { - "Amazon" => "Shopping", - "Starbucks" => "Food & Drink", - "McDonald's" => "Food & Drink", - "Target" => "Shopping", - "Costco" => "Food & Drink", - "Home Depot" => "Housing", - "Shell" => "Transportation", - "Whole Foods" => "Food & Drink", - "Walgreens" => "Healthcare", - "Nike" => "Shopping", - "Uber" => "Transportation", - "Netflix" => "Subscriptions", - "Spotify" => "Subscriptions", - "Delta Airlines" => "Transportation", - "Airbnb" => "Housing", - "Sephora" => "Shopping" - } - - family.categories.find_by(name: mapping[merchant.name]) - end - - def tag_for_merchant(merchant, family) - mapping = { - "Delta Airlines" => "Trips", - "Airbnb" => "Trips" - } - - tag_from_merchant = family.tags.find_by(name: mapping[merchant.name]) - tag_from_merchant || family.tags.find_by(name: "Demo Tag") - end - - def securities - @securities ||= Security.all.to_a - end end diff --git a/app/models/demo/rule_generator.rb b/app/models/demo/rule_generator.rb new file mode 100644 index 00000000..794dc45f --- /dev/null +++ b/app/models/demo/rule_generator.rb @@ -0,0 +1,79 @@ +class Demo::RuleGenerator + include Demo::DataHelper + + def create_rules!(family) + tags = create_tags!(family) + categories = create_categories!(family) + merchants = create_merchants!(family) + + rules = [] + + if merchants.any? && categories.any? + rule = family.rules.create!( + name: "Auto-categorize Grocery Purchases", + resource_type: "Transaction", + conditions: [ + Rule::Condition.new(condition_type: "merchant_name", operator: "contains", value: "Whole Foods") + ], + actions: [ + Rule::Action.new(action_type: "category_id", value: categories.first.id.to_s) + ] + ) + rules << rule + end + + rules + end + + def create_tags!(family) + tag_names = [ "Business", "Tax Deductible", "Recurring", "Emergency" ] + tags = [] + + tag_names.each do |name| + tag = family.tags.find_or_create_by!(name: name) do |t| + t.color = random_color + end + tags << tag + end + + tags + end + + def create_categories!(family) + category_data = [ + { name: "Groceries", color: random_color }, + { name: "Transportation", color: random_color }, + { name: "Entertainment", color: random_color }, + { name: "Utilities", color: random_color }, + { name: "Healthcare", color: random_color } + ] + + categories = [] + category_data.each do |data| + category = family.categories.find_or_create_by!(name: data[:name]) do |c| + c.color = data[:color] + end + categories << category + end + + categories + end + + def create_merchants!(family) + merchant_names = [ + "Whole Foods Market", + "Shell Gas Station", + "Netflix", + "Electric Company", + "Local Coffee Shop" + ] + + merchants = [] + merchant_names.each do |name| + merchant = family.merchants.find_or_create_by!(name: name) + merchants << merchant + end + + merchants + end +end diff --git a/app/models/demo/scenarios/basic_budget.rb b/app/models/demo/scenarios/basic_budget.rb new file mode 100644 index 00000000..f215bceb --- /dev/null +++ b/app/models/demo/scenarios/basic_budget.rb @@ -0,0 +1,129 @@ +# Basic budget scenario - minimal budgeting demonstration with categories +# +# This scenario creates a simple budget demonstration with parent/child categories +# and one transaction per category. Designed to showcase basic budgeting features +# without overwhelming complexity. Ideal for: +# - Basic budgeting feature demos +# - Category hierarchy demonstrations +# - Simple transaction categorization examples +# - Lightweight testing environments +# +class Demo::Scenarios::BasicBudget < Demo::BaseScenario + include Demo::DataHelper + + # Scenario characteristics and configuration + SCENARIO_NAME = "Basic Budget".freeze + PURPOSE = "Simple budget demonstration with category hierarchy".freeze + TARGET_ACCOUNTS_PER_FAMILY = 1 # Single checking account + TARGET_TRANSACTIONS_PER_FAMILY = 4 # One income, three expenses + TARGET_CATEGORIES = 4 # Income + 3 expense categories (with one subcategory) + INCLUDES_SECURITIES = false + INCLUDES_TRANSFERS = false + INCLUDES_RULES = false + + private + + # Generate basic budget demonstration data + # Creates simple category hierarchy and one transaction per category + # + # @param family [Family] The family to generate data for + # @param options [Hash] Additional options (unused in this scenario) + def generate_family_data!(family, **options) + create_category_hierarchy!(family) + create_demo_checking_account!(family) + create_sample_categorized_transactions!(family) + end + + # Create parent categories with one subcategory example + def create_category_hierarchy!(family) + # Create parent categories + @food_category = family.categories.create!( + name: "Food & Drink", + color: random_color, + classification: "expense" + ) + + @transport_category = family.categories.create!( + name: "Transportation", + color: random_color, + classification: "expense" + ) + + # Create subcategory to demonstrate hierarchy + @restaurants_category = family.categories.create!( + name: "Restaurants", + parent: @food_category, + color: random_color, + classification: "expense" + ) + + puts " - #{TARGET_CATEGORIES} categories created (with parent/child hierarchy)" + end + + # Create single checking account for budget demonstration + def create_demo_checking_account!(family) + @checking_account = family.accounts.create!( + accountable: Depository.new, + name: "Demo Checking", + balance: 0, # Will be calculated from transactions + currency: "USD" + ) + + puts " - #{TARGET_ACCOUNTS_PER_FAMILY} demo checking account created" + end + + # Create one transaction for each category to demonstrate categorization + def create_sample_categorized_transactions!(family) + # Create income category and transaction first + income_category = family.categories.create!( + name: "Income", + color: random_color, + classification: "income" + ) + + # Add income transaction (negative amount = inflow) + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: -500, # Income (negative) + name: "Salary", + category: income_category, + date: 5.days.ago + ) + + # Grocery transaction (parent category) + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: 100, + name: "Grocery Store", + category: @food_category, + date: 2.days.ago + ) + + # Restaurant transaction (subcategory) + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: 50, + name: "Restaurant Meal", + category: @restaurants_category, + date: 1.day.ago + ) + + # Transportation transaction + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: 20, + name: "Gas Station", + category: @transport_category, + date: Date.current + ) + + # Update account balance to match transaction sum + @generators[:transaction_generator].update_account_balances_from_transactions!(family) + + puts " - #{TARGET_TRANSACTIONS_PER_FAMILY + 1} categorized transactions created (including income)" + end + + def scenario_name + SCENARIO_NAME + end +end diff --git a/app/models/demo/scenarios/clean_slate.rb b/app/models/demo/scenarios/clean_slate.rb new file mode 100644 index 00000000..b37514d8 --- /dev/null +++ b/app/models/demo/scenarios/clean_slate.rb @@ -0,0 +1,126 @@ +# Clean slate scenario - minimal starter data for new user onboarding +# +# This scenario creates the absolute minimum data needed to help new users +# understand Maybe's core features without overwhelming them. Ideal for: +# - New user onboarding flows +# - Tutorial walkthroughs +# - Clean development environments +# - User acceptance testing with minimal data +# +# The scenario only generates data when explicitly requested via with_minimal_data: true, +# otherwise it creates no data at all (true "clean slate"). +# +# @example Minimal data generation +# scenario = Demo::Scenarios::CleanSlate.new(generators) +# scenario.generate!(families, with_minimal_data: true) +# +# @example True clean slate (no data) +# scenario = Demo::Scenarios::CleanSlate.new(generators) +# scenario.generate!(families) # Creates nothing +# +class Demo::Scenarios::CleanSlate < Demo::BaseScenario + # Scenario characteristics and configuration + SCENARIO_NAME = "Clean Slate".freeze + PURPOSE = "Minimal starter data for new user onboarding and tutorials".freeze + TARGET_ACCOUNTS_PER_FAMILY = 1 # Single checking account only + TARGET_TRANSACTIONS_PER_FAMILY = 3 # Just enough to show transaction history + INCLUDES_SECURITIES = false + INCLUDES_TRANSFERS = false + INCLUDES_RULES = false + MINIMAL_CATEGORIES = 2 # Essential expense and income categories only + + # Override the base generate! method to handle the special with_minimal_data option + # Only generates data when explicitly requested to avoid accidental data creation + # + # @param families [Array] Families to generate data for + # @param options [Hash] Options hash that may contain with_minimal_data or require_onboarding + def generate!(families, **options) + # For "empty" task, don't generate any data + # For "new_user" task, generate minimal data for onboarding users + with_minimal_data = options[:with_minimal_data] || options[:require_onboarding] + return unless with_minimal_data + + super(families, **options) + end + + private + + # Generate minimal family data for getting started + # Creates only essential accounts and transactions to demonstrate core features + # + # @param family [Family] The family to generate data for + # @param options [Hash] Additional options (with_minimal_data used for validation) + def generate_family_data!(family, **options) + create_essential_categories!(family) + create_primary_checking_account!(family) + create_sample_transaction_history!(family) + end + + # Create only the most essential categories for basic expense tracking + def create_essential_categories!(family) + @food_category = family.categories.create!( + name: "Food & Drink", + color: "#4da568", + classification: "expense" + ) + + @income_category = family.categories.create!( + name: "Income", + color: "#6471eb", + classification: "income" + ) + + puts " - #{MINIMAL_CATEGORIES} essential categories created" + end + + # Create a single primary checking account with a reasonable starting balance + def create_primary_checking_account!(family) + @checking_account = family.accounts.create!( + accountable: Depository.new, + name: "Main Checking", + balance: 0, # Will be calculated from transactions + currency: "USD" + ) + + puts " - #{TARGET_ACCOUNTS_PER_FAMILY} primary checking account created" + end + + # Create minimal transaction history showing income and expense patterns + def create_sample_transaction_history!(family) + # Recent salary deposit + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: -3000, # Income (negative = inflow) + name: "Salary", + category: @income_category, + date: 15.days.ago + ) + + # Recent grocery purchase + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: 75, # Expense (positive = outflow) + name: "Grocery Store", + category: @food_category, + date: 5.days.ago + ) + + # Recent restaurant expense + @generators[:transaction_generator].create_transaction!( + account: @checking_account, + amount: 45, # Expense + name: "Restaurant", + category: @food_category, + date: 2.days.ago + ) + + # Update account balance to match transaction sum + @generators[:transaction_generator].update_account_balances_from_transactions!(family) + + puts " - #{TARGET_TRANSACTIONS_PER_FAMILY} sample transactions created" + end + + def scenario_name + SCENARIO_NAME + end +end diff --git a/app/models/demo/scenarios/default.rb b/app/models/demo/scenarios/default.rb new file mode 100644 index 00000000..78eb6a8f --- /dev/null +++ b/app/models/demo/scenarios/default.rb @@ -0,0 +1,77 @@ +# Default demo scenario - comprehensive realistic data for product demonstrations +# +# This scenario creates a complete, realistic demo environment that showcases +# all of Maybe's features with believable data patterns. Ideal for: +# - Product demonstrations to potential users +# - UI/UX testing with realistic data volumes +# - Feature development with complete data sets +# - Screenshots and marketing materials +# +class Demo::Scenarios::Default < Demo::BaseScenario + # Scenario characteristics and configuration + SCENARIO_NAME = "Comprehensive Demo".freeze + PURPOSE = "Complete realistic demo environment showcasing all Maybe features".freeze + TARGET_ACCOUNTS_PER_FAMILY = 7 # 1 each: checking, savings, credit card, 3 investments, 1 property+mortgage + TARGET_TRANSACTIONS_PER_FAMILY = 50 # Realistic 3-month transaction history + INCLUDES_SECURITIES = true + INCLUDES_TRANSFERS = true + INCLUDES_RULES = true + + private + + # Load securities before generating family data + # Securities are needed for investment account trades + def setup(**options) + @generators[:security_generator].load_securities! + puts "Securities loaded for investment accounts" + end + + # Generate complete family financial data + # Creates all account types with realistic balances and transaction patterns + # + # @param family [Family] The family to generate data for + # @param options [Hash] Additional options (unused in this scenario) + def generate_family_data!(family, **options) + create_foundational_data!(family) + create_all_account_types!(family) + create_realistic_transaction_patterns!(family) + create_account_transfers!(family) + end + + # Create rules, tags, categories, and merchants for the family + def create_foundational_data!(family) + @generators[:rule_generator].create_rules!(family) + @generators[:rule_generator].create_tags!(family) + @generators[:rule_generator].create_categories!(family) + @generators[:rule_generator].create_merchants!(family) + puts " - Rules, categories, and merchants created" + end + + # Create one of each major account type to demonstrate full feature set + def create_all_account_types!(family) + @generators[:account_generator].create_credit_card_accounts!(family) + @generators[:account_generator].create_checking_accounts!(family) + @generators[:account_generator].create_savings_accounts!(family) + @generators[:account_generator].create_investment_accounts!(family) + @generators[:account_generator].create_properties_and_mortgages!(family) + @generators[:account_generator].create_vehicles_and_loans!(family) + @generators[:account_generator].create_other_accounts!(family) + puts " - All #{TARGET_ACCOUNTS_PER_FAMILY} account types created" + end + + # Generate realistic transaction patterns across all accounts + def create_realistic_transaction_patterns!(family) + @generators[:transaction_generator].create_realistic_transactions!(family) + puts " - Realistic transaction patterns created (~#{TARGET_TRANSACTIONS_PER_FAMILY} transactions)" + end + + # Create transfer patterns between accounts (credit card payments, investments, etc.) + def create_account_transfers!(family) + @generators[:transfer_generator].create_transfer_transactions!(family) + puts " - Account transfer patterns created" + end + + def scenario_name + SCENARIO_NAME + end +end diff --git a/app/models/demo/scenarios/multi_currency.rb b/app/models/demo/scenarios/multi_currency.rb new file mode 100644 index 00000000..229eb458 --- /dev/null +++ b/app/models/demo/scenarios/multi_currency.rb @@ -0,0 +1,241 @@ +# Multi-currency scenario - international financial management demonstration +# +# This scenario creates accounts and transactions in multiple currencies to showcase +# Maybe's multi-currency capabilities. Demonstrates currency conversion, international +# transactions, and mixed-currency portfolio management. Ideal for: +# - International users and use cases +# - Currency conversion feature testing +# - Multi-region financial management demos +# - Exchange rate and conversion testing +# +# Primary currency is EUR with additional USD and GBP accounts and transactions. +# +class Demo::Scenarios::MultiCurrency < Demo::BaseScenario + include Demo::DataHelper + + # Scenario characteristics and configuration + SCENARIO_NAME = "Multi-Currency".freeze + PURPOSE = "International financial management with multiple currencies".freeze + PRIMARY_CURRENCY = "EUR".freeze + SUPPORTED_CURRENCIES = %w[EUR USD GBP].freeze + TARGET_ACCOUNTS_PER_FAMILY = 5 # 2 EUR (checking, credit), 1 USD, 1 GBP, 1 multi-currency investment + TARGET_TRANSACTIONS_PER_FAMILY = 10 # Distributed across currencies + INCLUDES_SECURITIES = false # Keep simple for currency focus + INCLUDES_TRANSFERS = true # Minimal transfers to avoid currency complexity + INCLUDES_RULES = false # Focus on currency, not categorization + + private + + # Generate family data with multiple currencies + # Creates accounts in EUR, USD, and GBP with appropriate transactions + # + # @param family [Family] The family to generate data for (should have EUR as primary currency) + # @param options [Hash] Additional options (unused in this scenario) + def generate_family_data!(family, **options) + create_basic_categorization!(family) + create_multi_currency_accounts!(family) + create_international_transactions!(family) + create_minimal_transfers!(family) + end + + # Create basic categories for international transactions + def create_basic_categorization!(family) + @generators[:rule_generator].create_categories!(family) + @generators[:rule_generator].create_merchants!(family) + puts " - Basic categories and merchants created for international transactions" + end + + # Create accounts in multiple currencies to demonstrate international capabilities + def create_multi_currency_accounts!(family) + create_eur_accounts!(family) # Primary currency accounts + create_usd_accounts!(family) # US dollar accounts + create_gbp_accounts!(family) # British pound accounts + create_investment_account!(family) # Multi-currency investment + + puts " - #{TARGET_ACCOUNTS_PER_FAMILY} multi-currency accounts created (#{SUPPORTED_CURRENCIES.join(', ')})" + end + + # Create EUR accounts (primary currency for this scenario) + def create_eur_accounts!(family) + # Create EUR checking account + family.accounts.create!( + accountable: Depository.new, + name: "EUR Checking Account", + balance: 0, # Will be calculated from transactions + currency: "EUR" + ) + + # Create EUR credit card + family.accounts.create!( + accountable: CreditCard.new, + name: "EUR Credit Card", + balance: 0, # Will be calculated from transactions + currency: "EUR" + ) + end + + # Create USD accounts for US-based transactions + def create_usd_accounts!(family) + family.accounts.create!( + accountable: Depository.new, + name: "USD Checking Account", + balance: 0, # Will be calculated from transactions + currency: "USD" + ) + end + + # Create GBP accounts for UK-based transactions + def create_gbp_accounts!(family) + family.accounts.create!( + accountable: Depository.new, + name: "GBP Savings Account", + balance: 0, # Will be calculated from transactions + currency: "GBP", + subtype: "savings" + ) + end + + # Create investment account (uses primary currency) + def create_investment_account!(family) + @generators[:account_generator].create_investment_accounts!(family, count: 1) + end + + # Create transactions in various currencies to demonstrate international usage + def create_international_transactions!(family) + # Create initial valuations for accounts that need them + create_initial_valuations!(family) + + create_eur_transaction_patterns!(family) + create_usd_transaction_patterns!(family) + create_gbp_transaction_patterns!(family) + + # Update account balances to match transaction sums + @generators[:transaction_generator].update_account_balances_from_transactions!(family) + + puts " - International transactions created across #{SUPPORTED_CURRENCIES.length} currencies" + end + + # Create initial valuations for credit cards in this scenario + def create_initial_valuations!(family) + family.accounts.each do |account| + next unless account.accountable_type == "CreditCard" + + Entry.create!( + account: account, + amount: 1000, # Initial credit card debt + name: "Initial creditcard valuation", + date: 2.years.ago.to_date, + currency: account.currency, + entryable_type: "Valuation", + entryable_attributes: {} + ) + end + end + + # Create EUR transactions (primary currency patterns) with both income and expenses + def create_eur_transaction_patterns!(family) + eur_accounts = family.accounts.where(currency: "EUR") + + eur_accounts.each do |account| + next if account.accountable_type == "Investment" + + if account.accountable_type == "CreditCard" + # Credit cards only get purchases (positive amounts) + 5.times do |i| + @generators[:transaction_generator].create_transaction!( + account: account, + amount: random_positive_amount(50, 300), # Purchases (positive) + name: "EUR Purchase #{i + 1}", + date: random_date_within_days(60), + currency: "EUR" + ) + end + else + # Checking accounts get both income and expenses + # Create income transactions (negative amounts) + 2.times do |i| + @generators[:transaction_generator].create_transaction!( + account: account, + amount: -random_positive_amount(2000, 3000), # Higher income to cover transfers + name: "EUR Salary #{i + 1}", + date: random_date_within_days(60), + currency: "EUR" + ) + end + + # Create expense transactions (positive amounts) + 3.times do |i| + @generators[:transaction_generator].create_transaction!( + account: account, + amount: random_positive_amount(20, 200), # Expense (positive) + name: "EUR Purchase #{i + 1}", + date: random_date_within_days(60), + currency: "EUR" + ) + end + end + end + end + + # Create USD transactions (US-based spending patterns) with both income and expenses + def create_usd_transaction_patterns!(family) + usd_accounts = family.accounts.where(currency: "USD") + + usd_accounts.each do |account| + # Create income transaction (negative amount) + @generators[:transaction_generator].create_transaction!( + account: account, + amount: -random_positive_amount(1500, 2500), # Higher income to cover transfers + name: "USD Freelance Payment", + date: random_date_within_days(60), + currency: "USD" + ) + + # Create expense transactions (positive amounts) + 2.times do |i| + @generators[:transaction_generator].create_transaction!( + account: account, + amount: random_positive_amount(30, 150), # Expense (positive) + name: "USD Purchase #{i + 1}", + date: random_date_within_days(60), + currency: "USD" + ) + end + end + end + + # Create GBP transactions (UK-based spending patterns) with both income and expenses + def create_gbp_transaction_patterns!(family) + gbp_accounts = family.accounts.where(currency: "GBP") + + gbp_accounts.each do |account| + # Create income transaction (negative amount) + @generators[:transaction_generator].create_transaction!( + account: account, + amount: -random_positive_amount(500, 800), # Income (negative) + name: "GBP Consulting Payment", + date: random_date_within_days(60), + currency: "GBP" + ) + + # Create expense transaction (positive amount) + @generators[:transaction_generator].create_transaction!( + account: account, + amount: random_positive_amount(25, 100), # Expense (positive) + name: "GBP Purchase", + date: random_date_within_days(60), + currency: "GBP" + ) + end + end + + # Create minimal transfers to keep scenario focused on currency demonstration + def create_minimal_transfers!(family) + @generators[:transfer_generator].create_transfer_transactions!(family, count: 1) + puts " - Minimal account transfers created" + end + + def scenario_name + SCENARIO_NAME + end +end diff --git a/app/models/demo/scenarios/performance_testing.rb b/app/models/demo/scenarios/performance_testing.rb new file mode 100644 index 00000000..1bfff17b --- /dev/null +++ b/app/models/demo/scenarios/performance_testing.rb @@ -0,0 +1,349 @@ +# Performance testing scenario - high-volume data for load testing +# +# This scenario creates large volumes of realistic data to test application +# performance under load. Uses an efficient approach: generates one complete +# realistic family in Ruby, then uses SQL bulk operations to duplicate it +# 499 times for maximum performance. Ideal for: +# - Performance testing and benchmarking +# - Load testing database operations +# - UI performance testing with large datasets +# - Scalability validation at production scale +# + +require "bcrypt" + +class Demo::Scenarios::PerformanceTesting < Demo::BaseScenario + # Scenario characteristics and configuration + SCENARIO_NAME = "Performance Testing".freeze + PURPOSE = "High-volume data generation for performance testing and load validation".freeze + TARGET_FAMILIES = 500 + TARGET_ACCOUNTS_PER_FAMILY = 29 # 3 credit cards, 5 checking, 2 savings, 10 investments, 2 properties+mortgages, 3 vehicles+2 loans, 4 other assets+liabilities + TARGET_TRANSACTIONS_PER_FAMILY = 200 # Reasonable volume for development performance testing + TARGET_TRANSFERS_PER_FAMILY = 10 + SECURITIES_COUNT = 50 # Large number for investment account testing + INCLUDES_SECURITIES = true + INCLUDES_TRANSFERS = true + INCLUDES_RULES = true + + # Override generate! to use our efficient bulk duplication approach + def generate!(families, **options) + puts "Creating performance test data for #{TARGET_FAMILIES} families using efficient bulk duplication..." + + setup(**options) if respond_to?(:setup, true) + + # Step 1: Create one complete realistic family + template_family = create_template_family!(families.first, **options) + + # Step 2: Efficiently duplicate it 499 times using SQL + duplicate_family_data!(template_family, TARGET_FAMILIES - 1) + + puts "Performance test data created successfully with #{TARGET_FAMILIES} families!" + end + + private + + # Load large number of securities before generating family data + def setup(**options) + @generators[:security_generator].load_securities!(count: SECURITIES_COUNT) + puts "#{SECURITIES_COUNT} securities loaded for performance testing" + end + + # Create one complete, realistic family that will serve as our template + def create_template_family!(family_or_name, **options) + # Handle both Family object and family name string + family = if family_or_name.is_a?(Family) + family_or_name + else + Family.find_by(name: family_or_name) + end + + unless family + raise "Template family '#{family_or_name}' not found. Ensure family creation happened first." + end + + puts "Creating template family: #{family.name}..." + generate_family_data!(family, **options) + + puts "Template family created with #{family.accounts.count} accounts and #{family.entries.count} entries" + family + end + + # Efficiently duplicate the template family data using SQL bulk operations + def duplicate_family_data!(template_family, copies_needed) + puts "Duplicating template family #{copies_needed} times using efficient SQL operations..." + + ActiveRecord::Base.transaction do + # Get all related data for the template family + template_data = extract_template_data(template_family) + + # Create family records in batches + create_family_copies(template_family, copies_needed) + + # Bulk duplicate all related data + duplicate_accounts_and_related_data(template_data, copies_needed) + end + + puts "Successfully created #{copies_needed} family copies" + end + + # Extract all data related to the template family for duplication + def extract_template_data(family) + { + accounts: family.accounts.includes(:accountable), + entries: family.entries.includes(:entryable), + categories: family.categories, + merchants: family.merchants, + tags: family.tags, + rules: family.rules, + holdings: family.holdings + } + end + + # Create family and user records efficiently + def create_family_copies(template_family, count) + puts "Creating #{count} family records..." + + families_data = [] + users_data = [] + password_digest = BCrypt::Password.create("password") + + (2..count + 1).each do |i| + family_id = SecureRandom.uuid + family_name = "Performance Family #{i}" + + families_data << { + id: family_id, + name: family_name, + currency: template_family.currency, + locale: template_family.locale, + country: template_family.country, + timezone: template_family.timezone, + date_format: template_family.date_format, + created_at: Time.current, + updated_at: Time.current + } + + # Create admin user + users_data << { + id: SecureRandom.uuid, + family_id: family_id, + email: "user#{i}@maybe.local", + first_name: "Demo", + last_name: "User", + role: "admin", + password_digest: password_digest, + onboarded_at: Time.current, + created_at: Time.current, + updated_at: Time.current + } + + # Create member user + users_data << { + id: SecureRandom.uuid, + family_id: family_id, + email: "member_user#{i}@maybe.local", + first_name: "Demo (member user)", + last_name: "User", + role: "member", + password_digest: password_digest, + onboarded_at: Time.current, + created_at: Time.current, + updated_at: Time.current + } + end + + # Bulk insert families and users + Family.insert_all(families_data) + User.insert_all(users_data) + + puts "Created #{count} families and #{users_data.length} users" + end + + # Efficiently duplicate accounts and all related data using SQL + def duplicate_accounts_and_related_data(template_data, count) + puts "Duplicating accounts and related data for #{count} families..." + + new_families = Family.where("name LIKE 'Performance Family %'") + .where.not(id: template_data[:accounts].first&.family_id) + .limit(count) + + new_families.find_each.with_index do |family, index| + duplicate_family_accounts_bulk(template_data, family) + puts "Completed family #{index + 1}/#{count}" if (index + 1) % 50 == 0 + end + end + + # Duplicate all accounts and related data for a single family using bulk operations + def duplicate_family_accounts_bulk(template_data, target_family) + return if template_data[:accounts].empty? + + account_id_mapping = {} + + # Create accounts one by one to handle accountables properly + template_data[:accounts].each do |template_account| + new_account = target_family.accounts.create!( + accountable: template_account.accountable.dup, + name: template_account.name, + balance: template_account.balance, + currency: template_account.currency, + subtype: template_account.subtype, + is_active: template_account.is_active + ) + account_id_mapping[template_account.id] = new_account.id + end + + # Bulk create other related data + create_bulk_categories(template_data[:categories], target_family) + create_bulk_entries_and_related(template_data, target_family, account_id_mapping) + rescue => e + puts "Error duplicating data for #{target_family.name}: #{e.message}" + # Continue with next family rather than failing completely + end + + # Bulk create categories for a family + def create_bulk_categories(template_categories, target_family) + return if template_categories.empty? + + # Create mapping from old category IDs to new category IDs + category_id_mapping = {} + + # First pass: generate new IDs for all categories + template_categories.each do |template_category| + category_id_mapping[template_category.id] = SecureRandom.uuid + end + + # Second pass: create category data with properly mapped parent_ids + categories_data = template_categories.map do |template_category| + # Map parent_id to the new family's category ID, or nil if no parent + new_parent_id = template_category.parent_id ? category_id_mapping[template_category.parent_id] : nil + + { + id: category_id_mapping[template_category.id], + family_id: target_family.id, + name: template_category.name, + color: template_category.color, + classification: template_category.classification, + parent_id: new_parent_id, + created_at: Time.current, + updated_at: Time.current + } + end + + Category.insert_all(categories_data) + end + + # Bulk create entries and related entryables + def create_bulk_entries_and_related(template_data, target_family, account_id_mapping) + return if template_data[:entries].empty? + + entries_data = [] + transactions_data = [] + trades_data = [] + + template_data[:entries].each do |template_entry| + new_account_id = account_id_mapping[template_entry.account_id] + next unless new_account_id + + new_entry_id = SecureRandom.uuid + new_entryable_id = SecureRandom.uuid + + entries_data << { + id: new_entry_id, + account_id: new_account_id, + entryable_type: template_entry.entryable_type, + entryable_id: new_entryable_id, + name: template_entry.name, + date: template_entry.date, + amount: template_entry.amount, + currency: template_entry.currency, + notes: template_entry.notes, + created_at: Time.current, + updated_at: Time.current + } + + # Create entryable data based on type + case template_entry.entryable_type + when "Transaction" + transactions_data << { + id: new_entryable_id, + created_at: Time.current, + updated_at: Time.current + } + when "Trade" + trades_data << { + id: new_entryable_id, + security_id: template_entry.entryable.security_id, + qty: template_entry.entryable.qty, + price: template_entry.entryable.price, + currency: template_entry.entryable.currency, + created_at: Time.current, + updated_at: Time.current + } + end + end + + # Bulk insert all data + Entry.insert_all(entries_data) if entries_data.any? + Transaction.insert_all(transactions_data) if transactions_data.any? + Trade.insert_all(trades_data) if trades_data.any? + end + + # Generate high-volume family data for the template family + def generate_family_data!(family, **options) + create_foundational_data!(family) + create_high_volume_accounts!(family) + create_performance_transactions!(family) + create_performance_transfers!(family) + end + + # Create rules, tags, categories and merchants for performance testing + def create_foundational_data!(family) + @generators[:rule_generator].create_tags!(family) + @generators[:rule_generator].create_categories!(family) + @generators[:rule_generator].create_merchants!(family) + @generators[:rule_generator].create_rules!(family) + puts " - Foundational data created (tags, categories, merchants, rules)" + end + + # Create large numbers of accounts across all types for performance testing + def create_high_volume_accounts!(family) + @generators[:account_generator].create_credit_card_accounts!(family, count: 3) + puts " - 3 credit card accounts created" + + @generators[:account_generator].create_checking_accounts!(family, count: 5) + puts " - 5 checking accounts created" + + @generators[:account_generator].create_savings_accounts!(family, count: 2) + puts " - 2 savings accounts created" + + @generators[:account_generator].create_investment_accounts!(family, count: 10) + puts " - 10 investment accounts created" + + @generators[:account_generator].create_properties_and_mortgages!(family, count: 2) + puts " - 2 properties and mortgages created" + + @generators[:account_generator].create_vehicles_and_loans!(family, vehicle_count: 3, loan_count: 2) + puts " - 3 vehicles and 2 loans created" + + @generators[:account_generator].create_other_accounts!(family, asset_count: 4, liability_count: 4) + puts " - 4 other assets and 4 other liabilities created" + + puts " - Total: #{TARGET_ACCOUNTS_PER_FAMILY} accounts created for performance testing" + end + + # Create high-volume transactions for performance testing + def create_performance_transactions!(family) + @generators[:transaction_generator].create_performance_transactions!(family) + puts " - High-volume performance transactions created (~#{TARGET_TRANSACTIONS_PER_FAMILY} transactions)" + end + + # Create multiple transfer cycles for performance testing + def create_performance_transfers!(family) + @generators[:transfer_generator].create_transfer_transactions!(family, count: TARGET_TRANSFERS_PER_FAMILY) + puts " - #{TARGET_TRANSFERS_PER_FAMILY} transfer transaction cycles created" + end + + def scenario_name + SCENARIO_NAME + end +end diff --git a/app/models/demo/security_generator.rb b/app/models/demo/security_generator.rb new file mode 100644 index 00000000..eca2d0a8 --- /dev/null +++ b/app/models/demo/security_generator.rb @@ -0,0 +1,76 @@ +class Demo::SecurityGenerator + include Demo::DataHelper + + def load_securities!(count: 6) + if count <= 6 + create_standard_securities!(count) + else + securities = create_standard_securities!(6) + securities.concat(create_performance_securities!(count - 6)) + securities + end + end + + def create_standard_securities!(count) + securities_data = [ + { ticker: "AAPL", name: "Apple Inc.", exchange: "XNAS" }, + { ticker: "GOOGL", name: "Alphabet Inc.", exchange: "XNAS" }, + { ticker: "MSFT", name: "Microsoft Corporation", exchange: "XNAS" }, + { ticker: "AMZN", name: "Amazon.com Inc.", exchange: "XNAS" }, + { ticker: "TSLA", name: "Tesla Inc.", exchange: "XNAS" }, + { ticker: "NVDA", name: "NVIDIA Corporation", exchange: "XNAS" } + ] + + securities = [] + count.times do |i| + data = securities_data[i] + security = create_security!( + ticker: data[:ticker], + name: data[:name], + exchange_operating_mic: data[:exchange] + ) + securities << security + end + securities + end + + def create_performance_securities!(count) + securities = [] + count.times do |i| + security = create_security!( + ticker: "SYM#{i + 7}", + name: "Company #{i + 7}", + exchange_operating_mic: "XNAS" + ) + securities << security + end + securities + end + + def create_security!(ticker:, name:, exchange_operating_mic:) + security = Security.create!(ticker: ticker, name: name, exchange_operating_mic: exchange_operating_mic) + create_price_history!(security) + security + end + + def create_price_history!(security, extended: false) + days_back = extended ? 365 : 90 + price_base = 100.0 + prices = [] + + (0..days_back).each do |i| + date = i.days.ago.to_date + price_change = (rand - 0.5) * 10 + price_base = [ price_base + price_change, 10.0 ].max + + price = security.prices.create!( + date: date, + price: price_base.round(2), + currency: "USD" + ) + prices << price + end + + prices + end +end diff --git a/app/models/demo/transaction_generator.rb b/app/models/demo/transaction_generator.rb new file mode 100644 index 00000000..aa74dadc --- /dev/null +++ b/app/models/demo/transaction_generator.rb @@ -0,0 +1,448 @@ +class Demo::TransactionGenerator + include Demo::DataHelper + + def create_transaction!(attributes = {}) + # Separate entry attributes from transaction attributes + entry_attributes = attributes.extract!(:account, :date, :amount, :currency, :name, :notes) + transaction_attributes = attributes # category, merchant, etc. + + # Set defaults for entry + entry_defaults = { + date: 30.days.ago.to_date, + amount: 100, + currency: "USD", + name: "Demo Transaction" + } + + # Create entry with transaction as entryable + entry = Entry.create!( + entry_defaults.merge(entry_attributes).merge( + entryable_type: "Transaction", + entryable_attributes: transaction_attributes + ) + ) + + entry.entryable # Returns the Transaction + end + + def create_trade!(attributes = {}) + # Separate entry attributes from trade attributes + entry_attributes = attributes.extract!(:account, :date, :amount, :currency, :name, :notes) + trade_attributes = attributes # security, qty, price, etc. + + # Validate required trade attributes + security = trade_attributes[:security] || Security.first + unless security + raise ArgumentError, "Security is required for trade creation. Load securities first." + end + + # Set defaults for entry + entry_defaults = { + date: 30.days.ago.to_date, + currency: "USD", + name: "Demo Trade" + } + + # Set defaults for trade + trade_defaults = { + qty: 10, + price: 100, + currency: "USD" + } + + # Merge defaults with provided attributes + final_entry_attributes = entry_defaults.merge(entry_attributes) + final_trade_attributes = trade_defaults.merge(trade_attributes) + final_trade_attributes[:security] = security + + # Calculate amount if not provided (qty * price) + unless final_entry_attributes[:amount] + final_entry_attributes[:amount] = final_trade_attributes[:qty] * final_trade_attributes[:price] + end + + # Create entry with trade as entryable + entry = Entry.create!( + final_entry_attributes.merge( + entryable_type: "Trade", + entryable_attributes: final_trade_attributes + ) + ) + + entry.entryable # Returns the Trade + end + + def create_realistic_transactions!(family) + categories = family.categories.limit(10) + accounts_by_type = group_accounts_by_type(family) + entries = [] + + # Create initial valuations for accounts before other transactions + entries.concat(create_initial_valuations!(family)) + + accounts_by_type[:checking].each do |account| + entries.concat(create_income_transactions!(account)) + entries.concat(create_expense_transactions!(account, categories)) + end + + accounts_by_type[:credit_cards].each do |account| + entries.concat(create_credit_card_transactions!(account, categories)) + end + + accounts_by_type[:investments].each do |account| + entries.concat(create_investment_trades!(account)) + end + + # Update account balances to match transaction sums + update_account_balances_from_transactions!(family) + + entries + end + + def create_performance_transactions!(family) + categories = family.categories.limit(5) + accounts_by_type = group_accounts_by_type(family) + entries = [] + + # Create initial valuations for accounts before other transactions + entries.concat(create_initial_valuations!(family)) + + accounts_by_type[:checking].each do |account| + entries.concat(create_bulk_transactions!(account, PERFORMANCE_TRANSACTION_COUNTS[:depository_sample], income: true)) + entries.concat(create_bulk_transactions!(account, PERFORMANCE_TRANSACTION_COUNTS[:depository_sample], income: false)) + end + + accounts_by_type[:credit_cards].each do |account| + entries.concat(create_bulk_transactions!(account, PERFORMANCE_TRANSACTION_COUNTS[:credit_card_sample], credit_card: true)) + end + + accounts_by_type[:investments].each do |account| + entries.concat(create_bulk_investment_trades!(account, PERFORMANCE_TRANSACTION_COUNTS[:investment_trades])) + end + + # Update account balances to match transaction sums + update_account_balances_from_transactions!(family) + + entries + end + + # Create initial valuations for accounts to establish realistic starting values + # This is more appropriate than fake transactions + def create_initial_valuations!(family) + entries = [] + + family.accounts.each do |account| + initial_value = case account.accountable_type + when "Loan" + case account.name + when /Mortgage/i then 300000 # Initial mortgage debt + when /Auto/i, /Car/i then 15000 # Initial car loan debt + else 10000 # Other loan debt + end + when "CreditCard" + 5000 # Initial credit card debt + when "Property" + 500000 # Initial property value + when "Vehicle" + 25000 # Initial vehicle value + when "OtherAsset" + 5000 # Initial other asset value + when "OtherLiability" + 2000 # Initial other liability debt + else + next # Skip accounts that don't need initial valuations + end + + # Create valuation entry + entry = Entry.create!( + account: account, + amount: initial_value, + name: "Initial #{account.accountable_type.humanize.downcase} valuation", + date: 2.years.ago.to_date, + currency: account.currency, + entryable_type: "Valuation", + entryable_attributes: {} + ) + entries << entry + end + + entries + end + + # Update account balances to match the sum of their transactions and valuations + # This ensures realistic balances without artificial balancing transactions + def update_account_balances_from_transactions!(family) + family.accounts.each do |account| + transaction_sum = account.entries + .where(entryable_type: [ "Transaction", "Trade", "Valuation" ]) + .sum(:amount) + + # Calculate realistic balance based on transaction sum and account type + # For assets: balance should be positive, so we negate the transaction sum + # For liabilities: balance should reflect debt owed + new_balance = case account.classification + when "asset" + -transaction_sum # Assets: negative transaction sum = positive balance + when "liability" + transaction_sum # Liabilities: positive transaction sum = positive debt balance + else + -transaction_sum + end + + # Ensure minimum realistic balance + new_balance = [ new_balance, minimum_realistic_balance(account) ].max + + account.update!(balance: new_balance) + end + end + + private + + def create_income_transactions!(account) + entries = [] + + 6.times do |i| + transaction = create_transaction!( + account: account, + name: "Salary #{i + 1}", + amount: -income_amount, + date: random_date_within_days(90), + currency: account.currency + ) + entries << transaction.entry + end + + entries + end + + def create_expense_transactions!(account, categories) + entries = [] + expense_types = [ + { name: "Grocery Store", amount_range: [ 50, 200 ] }, + { name: "Gas Station", amount_range: [ 30, 80 ] }, + { name: "Restaurant", amount_range: [ 25, 150 ] }, + { name: "Online Purchase", amount_range: [ 20, 300 ] } + ] + + 20.times do + expense_type = expense_types.sample + transaction = create_transaction!( + account: account, + name: expense_type[:name], + amount: expense_amount(expense_type[:amount_range][0], expense_type[:amount_range][1]), + date: random_date_within_days(90), + currency: account.currency, + category: categories.sample + ) + entries << transaction.entry + end + + entries + end + + def create_credit_card_transactions!(account, categories) + entries = [] + + credit_card_merchants = [ + { name: "Amazon Purchase", amount_range: [ 25, 500 ] }, + { name: "Target", amount_range: [ 30, 150 ] }, + { name: "Coffee Shop", amount_range: [ 5, 15 ] }, + { name: "Department Store", amount_range: [ 50, 300 ] }, + { name: "Subscription Service", amount_range: [ 10, 50 ] } + ] + + 25.times do + merchant_data = credit_card_merchants.sample + transaction = create_transaction!( + account: account, + name: merchant_data[:name], + amount: expense_amount(merchant_data[:amount_range][0], merchant_data[:amount_range][1]), + date: random_date_within_days(90), + currency: account.currency, + category: categories.sample + ) + entries << transaction.entry + end + + entries + end + + def create_investment_trades!(account) + securities = Security.limit(3) + return [] unless securities.any? + + entries = [] + + trade_patterns = [ + { type: "buy", qty_range: [ 1, 50 ] }, + { type: "buy", qty_range: [ 10, 100 ] }, + { type: "sell", qty_range: [ 1, 25 ] } + ] + + 15.times do + security = securities.sample + pattern = trade_patterns.sample + + recent_price = security.prices.order(date: :desc).first&.price || 100.0 + + trade = create_trade!( + account: account, + security: security, + qty: rand(pattern[:qty_range][0]..pattern[:qty_range][1]), + price: recent_price * (0.95 + rand * 0.1), + date: random_date_within_days(90), + currency: account.currency + ) + entries << trade.entry + end + + entries + end + + def create_bulk_investment_trades!(account, count) + securities = Security.limit(5) + return [] unless securities.any? + + entries = [] + + count.times do |i| + security = securities.sample + recent_price = security.prices.order(date: :desc).first&.price || 100.0 + + trade = create_trade!( + account: account, + security: security, + qty: rand(1..100), + price: recent_price * (0.9 + rand * 0.2), + date: random_date_within_days(365), + currency: account.currency, + name: "Bulk Trade #{i + 1}" + ) + entries << trade.entry + end + + entries + end + + def create_bulk_transactions!(account, count, income: false, credit_card: false) + entries = [] + + # Handle credit cards specially to ensure balanced purchases and payments + if account.accountable_type == "CreditCard" + # Create a mix of purchases (positive) and payments (negative) + purchase_count = (count * 0.8).to_i # 80% purchases + payment_count = count - purchase_count # 20% payments + + total_purchases = 0 + + # Create purchases first + purchase_count.times do |i| + amount = expense_amount(10, 200) # Credit card purchases (positive) + total_purchases += amount + + transaction = create_transaction!( + account: account, + name: "Bulk CC Purchase #{i + 1}", + amount: amount, + date: random_date_within_days(365), + currency: account.currency + ) + entries << transaction.entry + end + + # Create reasonable payments (negative amounts) + # Payments should be smaller than total debt available + initial_debt = 5000 # From initial valuation + available_debt = initial_debt + total_purchases + + payment_count.times do |i| + # Payment should be reasonable portion of available debt + max_payment = [ available_debt * 0.1, 500 ].max # 10% of debt or min $500 + amount = -expense_amount(50, max_payment.to_i) # Payment (negative) + + transaction = create_transaction!( + account: account, + name: "Credit card payment #{i + 1}", + amount: amount, + date: random_date_within_days(365), + currency: account.currency + ) + entries << transaction.entry + end + + else + # Regular handling for non-credit card accounts + count.times do |i| + amount = if income + -income_amount # Income (negative) + elsif credit_card + expense_amount(10, 200) # This path shouldn't be reached for actual credit cards + else + expense_amount(5, 500) # Regular expenses (positive) + end + + name_prefix = if income + "Bulk Income" + elsif credit_card + "Bulk CC Purchase" + else + "Bulk Expense" + end + + transaction = create_transaction!( + account: account, + name: "#{name_prefix} #{i + 1}", + amount: amount, + date: random_date_within_days(365), + currency: account.currency + ) + entries << transaction.entry + end + end + + entries + end + + def expense_amount(min_or_range = :small, max = nil) + if min_or_range.is_a?(Symbol) + case min_or_range + when :small then random_amount(10, 200) + when :medium then random_amount(50, 500) + when :large then random_amount(200, 1000) + when :credit_card then random_amount(20, 300) + else random_amount(10, 500) + end + else + max_amount = max || (min_or_range + 100) + random_amount(min_or_range, max_amount) + end + end + + def income_amount(type = :salary) + case type + when :salary then random_amount(3000, 7000) + when :dividend then random_amount(100, 500) + when :interest then random_amount(50, 200) + else random_amount(1000, 5000) + end + end + + # Determine minimum realistic balance for account type + def minimum_realistic_balance(account) + case account.accountable_type + when "Depository" + account.subtype == "savings" ? 1000 : 500 + when "Investment" + 5000 + when "Property" + 100000 + when "Vehicle" + 5000 + when "OtherAsset" + 100 + when "CreditCard", "Loan", "OtherLiability" + 100 # Minimum debt + else + 100 + end + end +end diff --git a/app/models/demo/transfer_generator.rb b/app/models/demo/transfer_generator.rb new file mode 100644 index 00000000..3bd3939c --- /dev/null +++ b/app/models/demo/transfer_generator.rb @@ -0,0 +1,159 @@ +class Demo::TransferGenerator + include Demo::DataHelper + + def initialize + end + + def create_transfer_transactions!(family, count: 1) + accounts_by_type = group_accounts_by_type(family) + created_transfers = [] + + count.times do |i| + suffix = count > 1 ? "_#{i + 1}" : "" + + created_transfers.concat(create_credit_card_payments!(accounts_by_type, suffix: suffix)) + created_transfers.concat(create_investment_contributions!(accounts_by_type, suffix: suffix)) + created_transfers.concat(create_savings_transfers!(accounts_by_type, suffix: suffix)) + created_transfers.concat(create_loan_payments!(accounts_by_type, suffix: suffix)) + end + + created_transfers + end + + def create_transfer!(from_account:, to_account:, amount:, date:, description: "") + transfer = Transfer.from_accounts( + from_account: from_account, + to_account: to_account, + date: date, + amount: amount + ) + + transfer.inflow_transaction.entry.update!( + name: "#{description.presence || 'Transfer'} from #{from_account.name}" + ) + transfer.outflow_transaction.entry.update!( + name: "#{description.presence || 'Transfer'} to #{to_account.name}" + ) + + transfer.status = "confirmed" + transfer.save! + + transfer + end + + private + + def create_credit_card_payments!(accounts_by_type, suffix: "") + checking_accounts = accounts_by_type[:checking] + credit_cards = accounts_by_type[:credit_cards] + transfers = [] + + return transfers unless checking_accounts.any? && credit_cards.any? + + checking = checking_accounts.first + + credit_cards.each_with_index do |credit_card, index| + payment_amount = [ credit_card.balance.abs * 0.3, 500 ].max + payment_date = (7 + index * 3).days.ago.to_date + + transfer = create_transfer!( + from_account: checking, + to_account: credit_card, + amount: payment_amount, + date: payment_date, + description: "Credit card payment#{suffix}" + ) + transfers << transfer + end + + transfers + end + + def create_investment_contributions!(accounts_by_type, suffix: "") + checking_accounts = accounts_by_type[:checking] + investment_accounts = accounts_by_type[:investments] + transfers = [] + + return transfers unless checking_accounts.any? && investment_accounts.any? + + checking = checking_accounts.first + + investment_accounts.each_with_index do |investment, index| + contribution_amount = case investment.name + when /401k/i then 1500 + when /Roth/i then 500 + else 1000 + end + + contribution_date = (14 + index * 7).days.ago.to_date + + transfer = create_transfer!( + from_account: checking, + to_account: investment, + amount: contribution_amount, + date: contribution_date, + description: "Investment contribution#{suffix}" + ) + transfers << transfer + end + + transfers + end + + def create_savings_transfers!(accounts_by_type, suffix: "") + checking_accounts = accounts_by_type[:checking] + savings_accounts = accounts_by_type[:savings] + transfers = [] + + return transfers unless checking_accounts.any? && savings_accounts.any? + + checking = checking_accounts.first + + savings_accounts.each_with_index do |savings, index| + transfer_amount = 1000 + transfer_date = (21 + index * 5).days.ago.to_date + + transfer = create_transfer!( + from_account: checking, + to_account: savings, + amount: transfer_amount, + date: transfer_date, + description: "Savings transfer#{suffix}" + ) + transfers << transfer + end + + transfers + end + + def create_loan_payments!(accounts_by_type, suffix: "") + checking_accounts = accounts_by_type[:checking] + loans = accounts_by_type[:loans] + transfers = [] + + return transfers unless checking_accounts.any? && loans.any? + + checking = checking_accounts.first + + loans.each_with_index do |loan, index| + payment_amount = case loan.name + when /Mortgage/i then 2500 + when /Auto/i, /Car/i then 450 + else 500 + end + + payment_date = (28 + index * 2).days.ago.to_date + + transfer = create_transfer!( + from_account: checking, + to_account: loan, + amount: payment_amount, + date: payment_date, + description: "Loan payment#{suffix}" + ) + transfers << transfer + end + + transfers + end +end diff --git a/lib/tasks/demo_data.rake b/lib/tasks/demo_data.rake index c57a006f..b6ecb6b9 100644 --- a/lib/tasks/demo_data.rake +++ b/lib/tasks/demo_data.rake @@ -1,28 +1,39 @@ namespace :demo_data do - desc "Creates or resets demo data used in development environment" + desc "Creates a new user with no data. Use for testing empty data states." task empty: :environment do families = [ "Demo Family 1" ] Demo::Generator.new.reset_and_clear_data!(families) end + desc "Creates a new user who has to go through onboarding still. Use for testing onboarding flows." task new_user: :environment do families = [ "Demo Family 1" ] Demo::Generator.new.reset_and_clear_data!(families, require_onboarding: true) end + desc "General data reset that loads semi-realistic data" task :reset, [ :count ] => :environment do |t, args| count = (args[:count] || 1).to_i families = count.times.map { |i| "Demo Family #{i + 1}" } Demo::Generator.new.reset_data!(families) end + desc "Use this when you need to test multi-currency features of the app with a minimal setup" task multi_currency: :environment do families = [ "Demo Family 1", "Demo Family 2" ] Demo::Generator.new.generate_multi_currency_data!(families) end + desc "Use this when you want realistic budget data" task basic_budget: :environment do families = [ "Demo Family 1" ] Demo::Generator.new.generate_basic_budget_data!(families) end + + # DO NOT RUN THIS unless you're testing performance locally. It will take a long time to load/clear. Easiest to clear with a db:reset + desc "Generates realistic data for 500 families for performance testing. Creates 1 family with Ruby, then efficiently duplicates it 499 times using SQL bulk operations." + task performance_testing: :environment do + families = [ "Performance Family 1" ] + Demo::Generator.new.generate_performance_testing_data!(families) + end end