1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-07 22:45:20 +02:00

Benchmarking setup

This commit is contained in:
Zach Gollwitzer 2025-06-13 12:43:44 -04:00
parent cdad31812a
commit ee09d4680e
20 changed files with 490 additions and 2169 deletions

3
.gitignore vendored
View file

@ -98,7 +98,8 @@ node_modules/
.taskmaster/config.json
.taskmaster/templates
tasks.json
tasks/
.taskmaster/tasks/
.taskmaster/reports/
*.mcp.json
scripts/
.cursor/mcp.json

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<Family>] 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

2
db/schema.rb generated
View file

@ -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

142
lib/tasks/benchmarking.rake Normal file
View file

@ -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

View file

@ -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

31
perf.rake Normal file
View file

@ -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