mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
* Benchmarking setup * Get demo data working in benchmark scenario * Finalize default demo scenario * Finalize benchmarking setup
1233 lines
51 KiB
Ruby
1233 lines
51 KiB
Ruby
class Demo::Generator
|
||
# @param seed [Integer, String, nil] Seed value used to initialise the internal PRNG. If nil, the ENV variable DEMO_DATA_SEED will
|
||
# be honoured and default to a random seed when not present.
|
||
#
|
||
# Initialising an explicit PRNG gives us repeatable demo datasets while still
|
||
# allowing truly random data when the caller does not care about
|
||
# determinism. The global `Kernel.rand` and helpers like `Array#sample`
|
||
# will also be seeded so that *all* random behaviour inside this object –
|
||
# including library helpers that rely on Ruby's global RNG – follow the
|
||
# same deterministic sequence.
|
||
def initialize(seed: ENV.fetch("DEMO_DATA_SEED", nil))
|
||
# Convert the seed to an Integer if one was provided, otherwise fall back
|
||
# to a random, but memoised, seed so the generator instance can report it
|
||
# back to callers when needed (e.g. for debugging a specific run).
|
||
@seed = seed.present? ? seed.to_i : Random.new_seed
|
||
|
||
# Internal PRNG instance – use this instead of the global RNG wherever we
|
||
# explicitly call `rand` inside the class. We override `rand` below so
|
||
# existing method bodies automatically delegate here without requiring
|
||
# widespread refactors.
|
||
@rng = Random.new(@seed)
|
||
|
||
# Also seed Ruby's global RNG so helpers that rely on it (e.g.
|
||
# Array#sample, Kernel.rand in invoked libraries, etc.) remain
|
||
# deterministic for the lifetime of this generator instance.
|
||
srand(@seed)
|
||
end
|
||
|
||
# Expose the seed so callers can reproduce a run if necessary.
|
||
attr_reader :seed
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Performance helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
private
|
||
|
||
# Simple timing helper. Pass a descriptive label and a block; the runtime
|
||
# will be printed automatically when the block completes.
|
||
# If max_seconds is provided, raise RuntimeError when the block exceeds that
|
||
# duration. Useful to keep CI/dev machines honest about demo-data perf.
|
||
def with_timing(label, max_seconds: nil)
|
||
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||
result = yield
|
||
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
||
puts "⏱️ #{label} completed in #{duration.round(2)}s"
|
||
|
||
if max_seconds && duration > max_seconds
|
||
raise "Demo::Generator ##{label} exceeded #{max_seconds}s (#{duration.round(2)}s)"
|
||
end
|
||
|
||
result
|
||
end
|
||
|
||
# Override Kernel#rand so *all* `rand` calls inside this instance (including
|
||
# those already present in the file) are routed through the seeded PRNG.
|
||
def rand(*args)
|
||
@rng.rand(*args)
|
||
end
|
||
|
||
# Generate empty family - no financial data
|
||
def generate_empty_data!(skip_clear: false)
|
||
with_timing(__method__) do
|
||
unless skip_clear
|
||
puts "🧹 Clearing existing data..."
|
||
clear_all_data!
|
||
end
|
||
|
||
puts "👥 Creating empty family..."
|
||
create_family_and_users!("Demo Family", "user@maybe.local", onboarded: true, subscribed: true)
|
||
|
||
puts "✅ Empty demo data loaded successfully!"
|
||
end
|
||
end
|
||
|
||
# Generate new user family - no financial data, needs onboarding
|
||
def generate_new_user_data!(skip_clear: false)
|
||
with_timing(__method__) do
|
||
unless skip_clear
|
||
puts "🧹 Clearing existing data..."
|
||
clear_all_data!
|
||
end
|
||
|
||
puts "👥 Creating new user family..."
|
||
create_family_and_users!("Demo Family", "user@maybe.local", onboarded: false, subscribed: false)
|
||
|
||
puts "✅ New user demo data loaded successfully!"
|
||
end
|
||
end
|
||
|
||
# Generate comprehensive realistic demo data with multi-currency
|
||
def generate_default_data!(skip_clear: false, email: "user@maybe.local")
|
||
if skip_clear
|
||
puts "⏭️ Skipping data clearing (appending new family)..."
|
||
else
|
||
puts "🧹 Clearing existing data..."
|
||
clear_all_data!
|
||
end
|
||
|
||
with_timing(__method__, max_seconds: 1000) do
|
||
puts "👥 Creating demo family..."
|
||
family = create_family_and_users!("Demo Family", email, onboarded: true, subscribed: true)
|
||
|
||
puts "📊 Creating realistic financial data..."
|
||
create_realistic_categories!(family)
|
||
create_realistic_accounts!(family)
|
||
create_realistic_transactions!(family)
|
||
# Auto-fill current-month budget based on recent spending averages
|
||
generate_budget_auto_fill!(family)
|
||
|
||
puts "✅ Realistic demo data loaded successfully!"
|
||
end
|
||
end
|
||
|
||
# Multi-currency support (keeping existing functionality)
|
||
def generate_multi_currency_data!(family_names)
|
||
with_timing(__method__) do
|
||
generate_for_scenario(:multi_currency, family_names)
|
||
end
|
||
end
|
||
|
||
def clear_all_data!
|
||
family_count = Family.count
|
||
if family_count > 50
|
||
raise "Too much data to clear efficiently (#{family_count} families). Run 'rails db:reset' instead."
|
||
end
|
||
Demo::DataCleaner.new.destroy_everything!
|
||
end
|
||
|
||
def create_family_and_users!(family_name, email, onboarded:, subscribed:)
|
||
family = Family.create!(
|
||
name: family_name,
|
||
currency: "USD",
|
||
locale: "en",
|
||
country: "US",
|
||
timezone: "America/New_York",
|
||
date_format: "%m-%d-%Y"
|
||
)
|
||
|
||
family.start_subscription!("sub_demo_123") if subscribed
|
||
|
||
# Admin user
|
||
family.users.create!(
|
||
email: email,
|
||
first_name: "Demo (admin)",
|
||
last_name: "Maybe",
|
||
role: "admin",
|
||
password: "password",
|
||
onboarded_at: onboarded ? Time.current : nil
|
||
)
|
||
|
||
# Member user
|
||
family.users.create!(
|
||
email: "partner_#{email}",
|
||
first_name: "Demo (member)",
|
||
last_name: "Maybe",
|
||
role: "member",
|
||
password: "password",
|
||
onboarded_at: onboarded ? Time.current : nil
|
||
)
|
||
|
||
family
|
||
end
|
||
|
||
def create_realistic_categories!(family)
|
||
# Income categories (3 total)
|
||
@salary_cat = family.categories.create!(name: "Salary", color: "#10b981", classification: "income")
|
||
@freelance_cat = family.categories.create!(name: "Freelance", color: "#059669", classification: "income")
|
||
@investment_income_cat = family.categories.create!(name: "Investment Income", color: "#047857", classification: "income")
|
||
|
||
# Expense categories with subcategories (12 total)
|
||
@housing_cat = family.categories.create!(name: "Housing", color: "#dc2626", classification: "expense")
|
||
@rent_cat = family.categories.create!(name: "Rent/Mortgage", parent: @housing_cat, color: "#b91c1c", classification: "expense")
|
||
@utilities_cat = family.categories.create!(name: "Utilities", parent: @housing_cat, color: "#991b1b", classification: "expense")
|
||
|
||
@food_cat = family.categories.create!(name: "Food & Dining", color: "#ea580c", classification: "expense")
|
||
@groceries_cat = family.categories.create!(name: "Groceries", parent: @food_cat, color: "#c2410c", classification: "expense")
|
||
@restaurants_cat = family.categories.create!(name: "Restaurants", parent: @food_cat, color: "#9a3412", classification: "expense")
|
||
@coffee_cat = family.categories.create!(name: "Coffee & Takeout", parent: @food_cat, color: "#7c2d12", classification: "expense")
|
||
|
||
@transportation_cat = family.categories.create!(name: "Transportation", color: "#2563eb", classification: "expense")
|
||
@gas_cat = family.categories.create!(name: "Gas", parent: @transportation_cat, color: "#1d4ed8", classification: "expense")
|
||
@car_payment_cat = family.categories.create!(name: "Car Payment", parent: @transportation_cat, color: "#1e40af", classification: "expense")
|
||
|
||
@entertainment_cat = family.categories.create!(name: "Entertainment", color: "#7c3aed", classification: "expense")
|
||
@healthcare_cat = family.categories.create!(name: "Healthcare", color: "#db2777", classification: "expense")
|
||
@shopping_cat = family.categories.create!(name: "Shopping", color: "#059669", classification: "expense")
|
||
@travel_cat = family.categories.create!(name: "Travel", color: "#0891b2", classification: "expense")
|
||
@personal_care_cat = family.categories.create!(name: "Personal Care", color: "#be185d", classification: "expense")
|
||
|
||
# Additional high-level expense categories to reach 13 top-level items
|
||
@insurance_cat = family.categories.create!(name: "Insurance", color: "#6366f1", classification: "expense")
|
||
@misc_cat = family.categories.create!(name: "Miscellaneous", color: "#6b7280", classification: "expense")
|
||
|
||
# Interest expense bucket
|
||
@interest_cat = family.categories.create!(name: "Loan Interest", color: "#475569", classification: "expense")
|
||
end
|
||
|
||
def create_realistic_accounts!(family)
|
||
# Checking accounts (USD)
|
||
@chase_checking = family.accounts.create!(accountable: Depository.new, name: "Chase Premier Checking", balance: 0, currency: "USD")
|
||
@ally_checking = family.accounts.create!(accountable: Depository.new, name: "Ally Online Checking", balance: 0, currency: "USD")
|
||
|
||
# Savings account (USD)
|
||
@marcus_savings = family.accounts.create!(accountable: Depository.new, name: "Marcus High-Yield Savings", balance: 0, currency: "USD")
|
||
|
||
# EUR checking (EUR)
|
||
@eu_checking = family.accounts.create!(accountable: Depository.new, name: "Deutsche Bank EUR Account", balance: 0, currency: "EUR")
|
||
|
||
# Credit cards (USD)
|
||
@amex_gold = family.accounts.create!(accountable: CreditCard.new, name: "Amex Gold Card", balance: 0, currency: "USD")
|
||
@chase_sapphire = family.accounts.create!(accountable: CreditCard.new, name: "Chase Sapphire Reserve", balance: 0, currency: "USD")
|
||
|
||
# Investment accounts (USD + GBP)
|
||
@vanguard_401k = family.accounts.create!(accountable: Investment.new, name: "Vanguard 401(k)", balance: 0, currency: "USD")
|
||
@schwab_brokerage = family.accounts.create!(accountable: Investment.new, name: "Charles Schwab Brokerage", balance: 0, currency: "USD")
|
||
@fidelity_roth_ira = family.accounts.create!(accountable: Investment.new, name: "Fidelity Roth IRA", balance: 0, currency: "USD")
|
||
@hsa_investment = family.accounts.create!(accountable: Investment.new, name: "Fidelity HSA Investment", balance: 0, currency: "USD")
|
||
@uk_isa = family.accounts.create!(accountable: Investment.new, name: "Vanguard UK ISA", balance: 0, currency: "GBP")
|
||
|
||
# Property (USD)
|
||
@home = family.accounts.create!(accountable: Property.new, name: "Primary Residence", balance: 0, currency: "USD")
|
||
|
||
# Vehicles (USD)
|
||
@honda_accord = family.accounts.create!(accountable: Vehicle.new, name: "2016 Honda Accord", balance: 0, currency: "USD")
|
||
@tesla_model3 = family.accounts.create!(accountable: Vehicle.new, name: "2021 Tesla Model 3", balance: 0, currency: "USD")
|
||
|
||
# Crypto (USD)
|
||
@coinbase_usdc = family.accounts.create!(accountable: Crypto.new, name: "Coinbase USDC", balance: 0, currency: "USD")
|
||
|
||
# Loans / Liabilities (USD)
|
||
@mortgage = family.accounts.create!(accountable: Loan.new, name: "Home Mortgage", balance: 0, currency: "USD")
|
||
@car_loan = family.accounts.create!(accountable: Loan.new, name: "Car Loan", balance: 0, currency: "USD")
|
||
@student_loan = family.accounts.create!(accountable: Loan.new, name: "Student Loan", balance: 0, currency: "USD")
|
||
|
||
@personal_loc = family.accounts.create!(accountable: OtherLiability.new, name: "Personal Line of Credit", balance: 0, currency: "USD")
|
||
|
||
# Other asset (USD)
|
||
@jewelry = family.accounts.create!(accountable: OtherAsset.new, name: "Jewelry Collection", balance: 0, currency: "USD")
|
||
end
|
||
|
||
def create_realistic_transactions!(family)
|
||
load_securities!
|
||
|
||
puts " 📈 Generating salary history (12 years)..."
|
||
generate_salary_history!
|
||
|
||
puts " 🏠 Generating housing transactions..."
|
||
generate_housing_transactions!
|
||
|
||
puts " 🍕 Generating food & dining transactions..."
|
||
generate_food_transactions!
|
||
|
||
puts " 🚗 Generating transportation transactions..."
|
||
generate_transportation_transactions!
|
||
|
||
puts " 🎬 Generating entertainment transactions..."
|
||
generate_entertainment_transactions!
|
||
|
||
puts " 🛒 Generating shopping transactions..."
|
||
generate_shopping_transactions!
|
||
|
||
puts " ⚕️ Generating healthcare transactions..."
|
||
generate_healthcare_transactions!
|
||
|
||
puts " ✈️ Generating travel transactions..."
|
||
generate_travel_transactions!
|
||
|
||
puts " 💅 Generating personal care transactions..."
|
||
generate_personal_care_transactions!
|
||
|
||
puts " 💰 Generating investment transactions..."
|
||
generate_investment_transactions!
|
||
|
||
puts " 🏡 Generating major purchases..."
|
||
generate_major_purchases!
|
||
|
||
puts " 💳 Generating transfers and payments..."
|
||
generate_transfers_and_payments!
|
||
|
||
puts " 🏦 Generating loan payments..."
|
||
generate_loan_payments!
|
||
|
||
puts " 🧾 Generating regular expense baseline..."
|
||
generate_regular_expenses!
|
||
|
||
puts " 🗄️ Generating legacy historical data..."
|
||
generate_legacy_transactions!
|
||
|
||
puts " 🔒 Generating crypto & misc asset transactions..."
|
||
generate_crypto_and_misc_assets!
|
||
|
||
puts " ✅ Reconciling balances to target snapshot..."
|
||
reconcile_balances!(family)
|
||
|
||
puts " 📊 Generated approximately #{Entry.joins(:account).where(accounts: { family_id: family.id }).count} transactions"
|
||
|
||
puts "🔄 Final sync to calculate adjusted balances..."
|
||
sync_family_accounts!(family)
|
||
end
|
||
|
||
# Auto-fill current-month budget based on recent spending averages
|
||
def generate_budget_auto_fill!(family)
|
||
current_month = Date.current.beginning_of_month
|
||
analysis_start = (current_month - 3.months).beginning_of_month
|
||
analysis_period = analysis_start..(current_month - 1.day)
|
||
|
||
# Fetch expense transactions in the analysis period
|
||
txns = Entry.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id")
|
||
.joins("INNER JOIN categories ON categories.id = transactions.category_id")
|
||
.where(entries: { entryable_type: "Transaction", date: analysis_period })
|
||
.where(categories: { classification: "expense" })
|
||
|
||
spend_per_cat = txns.group("categories.id").sum("entries.amount")
|
||
|
||
budget = family.budgets.where(start_date: current_month).first_or_initialize
|
||
budget.update!(
|
||
end_date: current_month.end_of_month,
|
||
currency: "USD",
|
||
budgeted_spending: spend_per_cat.values.sum / 3.0, # placeholder, refine below
|
||
expected_income: 0 # Could compute similarly if desired
|
||
)
|
||
|
||
spend_per_cat.each do |cat_id, total|
|
||
avg = total / 3.0
|
||
rounded = ((avg / 25.0).round) * 25
|
||
category = Category.find(cat_id)
|
||
budget.budget_categories.find_or_create_by!(category: category) do |bc|
|
||
bc.budgeted_spending = rounded
|
||
bc.currency = "USD"
|
||
end
|
||
end
|
||
|
||
# Update aggregate budgeted_spending to sum of categories
|
||
budget.update!(budgeted_spending: budget.budget_categories.sum(:budgeted_spending))
|
||
end
|
||
|
||
# Helper method to get weighted random date (favoring recent years)
|
||
def weighted_random_date
|
||
# Focus on last 3 years for transaction generation
|
||
rand(3.years.ago.to_date..Date.current)
|
||
end
|
||
|
||
# Helper method to get random accounts for transactions
|
||
def random_checking_account
|
||
[ @chase_checking, @ally_checking ].sample
|
||
end
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Payroll system — 156 deterministic deposits (bi-weekly, six years)
|
||
# ---------------------------------------------------------------------------
|
||
def generate_salary_history!
|
||
deposit_amount = 8_500 # Increased from 4,200 to ~$200k annually
|
||
total_deposits = 78 # Reduced from 156 (only 3 years instead of 6)
|
||
|
||
# Find first Friday ≥ 3.years.ago so the cadence remains bi-weekly.
|
||
first_date = 3.years.ago.to_date
|
||
first_date += 1 until first_date.friday?
|
||
|
||
total_deposits.times do |i|
|
||
date = first_date + (14 * i)
|
||
break if date > Date.current # safety
|
||
|
||
amount = -jitter(deposit_amount, 0.02).round # negative inflow per conventions
|
||
create_transaction!(@chase_checking, amount, "Acme Corp Payroll", @salary_cat, date)
|
||
|
||
# 10 % automated savings transfer to Marcus Savings same day
|
||
savings_amount = (-amount * 0.10).round
|
||
create_transfer!(@chase_checking, @marcus_savings, savings_amount, "Auto-Save 10% of Paycheck", date)
|
||
end
|
||
|
||
# Add freelance income to help balance expenses
|
||
15.times do
|
||
date = weighted_random_date
|
||
amount = -rand(1500..4000) # Negative for income
|
||
create_transaction!(@chase_checking, amount, "Freelance Project", @freelance_cat, date)
|
||
end
|
||
|
||
# Add quarterly investment dividends
|
||
(3.years.ago.to_date..Date.current).each do |date|
|
||
next unless date.day == 15 && [ 3, 6, 9, 12 ].include?(date.month) # Quarterly
|
||
dividend_amount = -rand(800..1500) # Negative for income
|
||
create_transaction!(@chase_checking, dividend_amount, "Investment Dividends", @investment_income_cat, date)
|
||
end
|
||
|
||
# Add more regular freelance income to maintain positive checking balance
|
||
40.times do # Increased from 15
|
||
date = weighted_random_date
|
||
amount = -rand(800..2500) # More frequent, smaller freelance income
|
||
create_transaction!(@chase_checking, amount, "Freelance Payment", @freelance_cat, date)
|
||
end
|
||
|
||
# Add side income streams
|
||
25.times do
|
||
date = weighted_random_date
|
||
amount = -rand(200..800)
|
||
income_types = [ "Cash Tips", "Selling Items", "Refund", "Rebate", "Gift Card Cash Out" ]
|
||
create_transaction!(@chase_checking, amount, income_types.sample, @freelance_cat, date)
|
||
end
|
||
end
|
||
|
||
def generate_housing_transactions!
|
||
start_date = 3.years.ago.to_date # Reduced from 12 years
|
||
base_rent = 2500 # Higher starting amount for higher income family
|
||
|
||
# Monthly rent/mortgage payments
|
||
(start_date..Date.current).each do |date|
|
||
next unless date.day == 1 # First of month
|
||
|
||
# Mortgage payment from checking account (positive expense)
|
||
create_transaction!(@chase_checking, 2800, "Mortgage Payment", @rent_cat, date)
|
||
# Principal payment reduces mortgage debt (negative transaction)
|
||
principal_payment = 800 # ~$800 goes to principal
|
||
create_transaction!(@mortgage, -principal_payment, "Principal Payment", nil, date)
|
||
end
|
||
|
||
# Monthly utilities (reduced frequency)
|
||
utilities = [
|
||
{ name: "ConEd Electric", range: 150..300 },
|
||
{ name: "Verizon Internet", range: 85..105 },
|
||
{ name: "Water & Sewer", range: 60..90 },
|
||
{ name: "Gas Bill", range: 80..220 }
|
||
]
|
||
|
||
utilities.each do |utility|
|
||
(start_date..Date.current).each do |date|
|
||
next unless date.day.between?(5, 15) && rand < 0.9 # Monthly with higher frequency
|
||
amount = rand(utility[:range])
|
||
create_transaction!(@chase_checking, amount, utility[:name], @utilities_cat, date)
|
||
end
|
||
end
|
||
end
|
||
|
||
def generate_food_transactions!
|
||
# Weekly groceries (increased volume but kept amounts reasonable)
|
||
120.times do # Increased from 60
|
||
date = weighted_random_date
|
||
amount = rand(60..180) # Reduced max from 220
|
||
stores = [ "Whole Foods", "Trader Joe's", "Safeway", "Stop & Shop", "Fresh Market" ]
|
||
create_transaction!(@chase_checking, amount, "#{stores.sample} Market", @groceries_cat, date)
|
||
end
|
||
|
||
# Restaurant dining (increased volume)
|
||
100.times do # Increased from 50
|
||
date = weighted_random_date
|
||
amount = rand(25..65) # Reduced max from 80
|
||
restaurants = [ "Pizza Corner", "Sushi Place", "Italian Kitchen", "Mexican Grill", "Greek Taverna" ]
|
||
create_transaction!(@chase_checking, amount, restaurants.sample, @restaurants_cat, date)
|
||
end
|
||
|
||
# Coffee & takeout (increased volume)
|
||
80.times do # Increased from 40
|
||
date = weighted_random_date
|
||
amount = rand(8..20) # Reduced from 10-25
|
||
places = [ "Local Coffee", "Dunkin'", "Corner Deli", "Food Truck" ]
|
||
create_transaction!(@chase_checking, amount, places.sample, @coffee_cat, date)
|
||
end
|
||
end
|
||
|
||
def generate_transportation_transactions!
|
||
# Gas stations (checking account only)
|
||
60.times do
|
||
date = weighted_random_date
|
||
amount = rand(35..75)
|
||
stations = [ "Shell", "Exxon", "BP", "Chevron", "Mobil", "Sunoco" ]
|
||
create_transaction!(@chase_checking, amount, "#{stations.sample} Gas", @gas_cat, date)
|
||
end
|
||
|
||
# Car payment (monthly for 6 years)
|
||
car_payment_start = 6.years.ago.to_date
|
||
car_payment_end = 1.year.ago.to_date
|
||
|
||
(car_payment_start..car_payment_end).each do |date|
|
||
next unless date.day == 15 # 15th of month
|
||
create_transaction!(@chase_checking, 385, "Auto Loan Payment", @car_payment_cat, date)
|
||
end
|
||
end
|
||
|
||
def generate_entertainment_transactions!
|
||
# Monthly subscriptions (increased timeframe)
|
||
subscriptions = [
|
||
{ name: "Netflix", amount: 15 },
|
||
{ name: "Spotify Premium", amount: 12 },
|
||
{ name: "Disney+", amount: 8 },
|
||
{ name: "HBO Max", amount: 16 },
|
||
{ name: "Amazon Prime", amount: 14 }
|
||
]
|
||
|
||
subscriptions.each do |sub|
|
||
(3.years.ago.to_date..Date.current).each do |date| # Reduced from 12 years
|
||
next unless date.day == rand(1..28) && rand < 0.9 # Higher frequency for active subscriptions
|
||
create_transaction!(@chase_checking, sub[:amount], sub[:name], @entertainment_cat, date)
|
||
end
|
||
end
|
||
|
||
# Random entertainment (increased volume)
|
||
60.times do # Increased from 25
|
||
date = weighted_random_date
|
||
amount = rand(15..60) # Reduced from 20-80
|
||
activities = [ "Movie Theater", "Sports Game", "Museum", "Comedy Club", "Bowling", "Mini Golf", "Arcade" ]
|
||
create_transaction!(@chase_checking, amount, activities.sample, @entertainment_cat, date)
|
||
end
|
||
end
|
||
|
||
def generate_shopping_transactions!
|
||
# Online shopping (increased volume)
|
||
80.times do # Increased from 40
|
||
date = weighted_random_date
|
||
amount = rand(30..90) # Reduced max from 120
|
||
stores = [ "Target.com", "Walmart", "Costco" ]
|
||
create_transaction!(@chase_checking, amount, "#{stores.sample} Purchase", @shopping_cat, date)
|
||
end
|
||
|
||
# In-store shopping (increased volume)
|
||
60.times do # Increased from 25
|
||
date = weighted_random_date
|
||
amount = rand(35..80) # Reduced max from 100
|
||
stores = [ "Target", "REI", "Barnes & Noble", "GameStop" ]
|
||
create_transaction!(@chase_checking, amount, stores.sample, @shopping_cat, date)
|
||
end
|
||
end
|
||
|
||
def generate_healthcare_transactions!
|
||
# Doctor visits (increased volume)
|
||
45.times do # Increased from 25
|
||
date = weighted_random_date
|
||
amount = rand(150..350) # Reduced from 180-450
|
||
providers = [ "Dr. Smith", "Dr. Johnson", "Dr. Williams", "Specialist Visit", "Urgent Care" ]
|
||
create_transaction!(@chase_checking, amount, providers.sample, @healthcare_cat, date)
|
||
end
|
||
|
||
# Pharmacy (increased volume)
|
||
80.times do # Increased from 40
|
||
date = weighted_random_date
|
||
amount = rand(12..65) # Reduced from 15-85
|
||
pharmacies = [ "CVS Pharmacy", "Walgreens", "Rite Aid", "Local Pharmacy" ]
|
||
create_transaction!(@chase_checking, amount, pharmacies.sample, @healthcare_cat, date)
|
||
end
|
||
end
|
||
|
||
def generate_travel_transactions!
|
||
# Major vacations (reduced count - premium travel handled in credit card cycles)
|
||
8.times do
|
||
date = weighted_random_date
|
||
|
||
# Smaller local trips from checking
|
||
hotel_amount = rand(200..500)
|
||
hotels = [ "Local Hotel", "B&B", "Nearby Resort" ]
|
||
if rand < 0.3 && date > 3.years.ago.to_date # Some EUR transactions
|
||
create_transaction!(@eu_checking, hotel_amount, hotels.sample, @travel_cat, date)
|
||
else
|
||
create_transaction!(@chase_checking, hotel_amount, hotels.sample, @travel_cat, date)
|
||
end
|
||
|
||
# Domestic flights (smaller amounts)
|
||
flight_amount = rand(200..400)
|
||
create_transaction!(@chase_checking, flight_amount, "Domestic Flight", @travel_cat, date + rand(1..5).days)
|
||
|
||
# Local activities
|
||
activity_amount = rand(50..150)
|
||
activities = [ "Local Tour", "Museum Tickets", "Activity Pass" ]
|
||
create_transaction!(@chase_checking, activity_amount, activities.sample, @travel_cat, date + rand(1..7).days)
|
||
end
|
||
end
|
||
|
||
def generate_personal_care_transactions!
|
||
# Gym membership
|
||
(12.years.ago.to_date..Date.current).each do |date|
|
||
next unless date.day == 1 && rand < 0.8 # Monthly
|
||
create_transaction!(@chase_checking, 45, "Gym Membership", @personal_care_cat, date)
|
||
end
|
||
|
||
# Beauty/grooming (checking account only)
|
||
40.times do
|
||
date = weighted_random_date
|
||
amount = rand(25..80)
|
||
services = [ "Hair Salon", "Barber Shop", "Nail Salon" ]
|
||
create_transaction!(@chase_checking, amount, services.sample, @personal_care_cat, date)
|
||
end
|
||
end
|
||
|
||
def generate_investment_transactions!
|
||
security = Security.first || Security.create!(ticker: "VTI", name: "Vanguard Total Stock Market ETF", country_code: "US")
|
||
|
||
generate_401k_trades!(security)
|
||
generate_brokerage_trades!(security)
|
||
generate_roth_trades!(security)
|
||
generate_uk_isa_trades!(security)
|
||
end
|
||
|
||
# ---------------------------------------------------- 401k (180 trades) --
|
||
def generate_401k_trades!(security)
|
||
payroll_dates = collect_payroll_dates.first(90) # 90 paydays ⇒ 180 trades
|
||
|
||
payroll_dates.each do |date|
|
||
# Employee contribution $1 200
|
||
create_trade_for(@vanguard_401k, security, 1_200, date, "401k Employee")
|
||
|
||
# Employer match $300
|
||
create_trade_for(@vanguard_401k, security, 300, date, "401k Employer Match")
|
||
end
|
||
end
|
||
|
||
# -------------------------------------------- Brokerage (144 trades) -----
|
||
def generate_brokerage_trades!(security)
|
||
date_cursor = 36.months.ago.beginning_of_month
|
||
while date_cursor <= Date.current
|
||
4.times do |i|
|
||
trade_date = date_cursor + i * 7.days # roughly spread within month
|
||
create_trade_for(@schwab_brokerage, security, rand(400..1_000), trade_date, "Brokerage Purchase")
|
||
end
|
||
date_cursor = date_cursor.next_month.beginning_of_month
|
||
end
|
||
end
|
||
|
||
# ----------------------------------------------- Roth IRA (108 trades) ---
|
||
def generate_roth_trades!(security)
|
||
date_cursor = 36.months.ago.beginning_of_month
|
||
while date_cursor <= Date.current
|
||
# Split $500 monthly across 3 staggered trades
|
||
3.times do |i|
|
||
trade_date = date_cursor + i * 10.days
|
||
create_trade_for(@fidelity_roth_ira, security, (500 / 3.0), trade_date, "Roth IRA Contribution")
|
||
end
|
||
date_cursor = date_cursor.next_month.beginning_of_month
|
||
end
|
||
end
|
||
|
||
# ------------------------------------------------- UK ISA (108 trades) ----
|
||
def generate_uk_isa_trades!(security)
|
||
date_cursor = 36.months.ago.beginning_of_month
|
||
while date_cursor <= Date.current
|
||
3.times do |i|
|
||
trade_date = date_cursor + i * 10.days
|
||
create_trade_for(@uk_isa, security, (400 / 3.0), trade_date, "ISA Investment", price_range: 60..150)
|
||
end
|
||
date_cursor = date_cursor.next_month.beginning_of_month
|
||
end
|
||
end
|
||
|
||
# --------------------------- Helpers for investment trade generation -----
|
||
def collect_payroll_dates
|
||
dates = []
|
||
d = 36.months.ago.to_date
|
||
d += 1 until d.friday?
|
||
while d <= Date.current
|
||
dates << d if d.cweek.even?
|
||
d += 14 # next bi-weekly
|
||
end
|
||
dates
|
||
end
|
||
|
||
def create_trade_for(account, security, investment_amount, date, memo, price_range: 80..200)
|
||
price = rand(price_range)
|
||
qty = (investment_amount.to_f / price).round(2)
|
||
create_investment_transaction!(account, security, qty, price, date, memo)
|
||
end
|
||
|
||
def generate_major_purchases!
|
||
# Home purchase (5 years ago) - only record the down payment, not full value
|
||
# Property value will be set by valuation in reconcile_balances!
|
||
home_date = 5.years.ago.to_date
|
||
create_transaction!(@chase_checking, 70_000, "Home Down Payment", @housing_cat, home_date)
|
||
create_transaction!(@mortgage, 320_000, "Mortgage Principal", nil, home_date) # Initial mortgage debt
|
||
|
||
# Initial account funding (realistic amounts)
|
||
create_transaction!(@chase_checking, -5_000, "Initial Deposit", @salary_cat, 12.years.ago.to_date)
|
||
create_transaction!(@ally_checking, -2_000, "Initial Deposit", @salary_cat, 12.years.ago.to_date)
|
||
create_transaction!(@marcus_savings, -10_000, "Initial Savings", @salary_cat, 12.years.ago.to_date)
|
||
create_transaction!(@eu_checking, -5_000, "EUR Account Opening", nil, 4.years.ago.to_date)
|
||
|
||
# Car purchases (realistic amounts)
|
||
create_transaction!(@chase_checking, 3_000, "Car Down Payment", @transportation_cat, 6.years.ago.to_date)
|
||
create_transaction!(@chase_checking, 2_500, "Second Car Down Payment", @transportation_cat, 8.years.ago.to_date)
|
||
|
||
# Major but realistic expenses
|
||
create_transaction!(@chase_checking, 8_000, "Kitchen Renovation", @utilities_cat, 2.years.ago.to_date)
|
||
create_transaction!(@chase_checking, 5_000, "Bathroom Remodel", @utilities_cat, 1.year.ago.to_date)
|
||
create_transaction!(@chase_checking, 12_000, "Roof Replacement", @utilities_cat, 3.years.ago.to_date)
|
||
create_transaction!(@chase_checking, 8_000, "Family Emergency", @healthcare_cat, 4.years.ago.to_date)
|
||
create_transaction!(@chase_checking, 15_000, "Wedding Expenses", @entertainment_cat, 9.years.ago.to_date)
|
||
end
|
||
|
||
def generate_transfers_and_payments!
|
||
generate_credit_card_cycles!
|
||
|
||
generate_monthly_ally_transfers!
|
||
generate_quarterly_fx_transfers!
|
||
generate_additional_savings_transfers!
|
||
end
|
||
|
||
# Additional savings transfers to improve income/expense balance
|
||
def generate_additional_savings_transfers!
|
||
# Monthly extra savings transfers
|
||
(3.years.ago.to_date..Date.current).each do |date|
|
||
next unless date.day == 15 && rand < 0.7 # Semi-monthly savings
|
||
amount = rand(500..1500)
|
||
create_transfer!(@chase_checking, @marcus_savings, amount, "Extra Savings Transfer", date)
|
||
end
|
||
|
||
# Quarterly HSA contributions
|
||
(3.years.ago.to_date..Date.current).each do |date|
|
||
next unless date.day == 1 && [ 1, 4, 7, 10 ].include?(date.month) # Quarterly
|
||
amount = rand(1000..2000)
|
||
create_transfer!(@chase_checking, @hsa_investment, amount, "HSA Contribution", date)
|
||
end
|
||
|
||
# Occasional windfalls (tax refunds, bonuses, etc.)
|
||
8.times do
|
||
date = weighted_random_date
|
||
amount = rand(2000..8000)
|
||
create_transaction!(@chase_checking, -amount, "Tax Refund/Bonus", @salary_cat, date)
|
||
end
|
||
|
||
# CRITICAL: Regular transfers FROM savings TO checking to maintain positive balance
|
||
# This is realistic - people move money from savings to checking regularly
|
||
(3.years.ago.to_date..Date.current).each do |date|
|
||
next unless date.day == rand(20..28) && rand < 0.8 # Monthly transfers from savings
|
||
amount = rand(2000..5000)
|
||
create_transfer!(@marcus_savings, @chase_checking, amount, "Transfer from Savings", date)
|
||
end
|
||
|
||
# Weekly smaller transfers from savings for cash flow
|
||
(3.years.ago.to_date..Date.current).each do |date|
|
||
next unless date.wday == 1 && rand < 0.4 # Some Mondays
|
||
amount = rand(500..1200)
|
||
create_transfer!(@marcus_savings, @chase_checking, amount, "Weekly Cash Flow", date)
|
||
end
|
||
end
|
||
|
||
# $300 from Chase Checking to Ally Checking on the first business day of each
|
||
# month for the past 36 months.
|
||
def generate_monthly_ally_transfers!
|
||
date_cursor = 36.months.ago.beginning_of_month
|
||
while date_cursor <= Date.current
|
||
transfer_date = first_business_day(date_cursor)
|
||
create_transfer!(@chase_checking, @ally_checking, 300, "Monthly Ally Transfer", transfer_date)
|
||
date_cursor = date_cursor.next_month.beginning_of_month
|
||
end
|
||
end
|
||
|
||
# Quarterly $2 000 FX transfer from Chase Checking to EUR account
|
||
def generate_quarterly_fx_transfers!
|
||
date_cursor = 36.months.ago.beginning_of_quarter
|
||
while date_cursor <= Date.current
|
||
transfer_date = date_cursor + 2.days # arbitrary within quarter start
|
||
create_transfer!(@chase_checking, @eu_checking, 2_000, "Quarterly FX Transfer", transfer_date)
|
||
date_cursor = date_cursor.next_quarter.beginning_of_quarter
|
||
end
|
||
end
|
||
|
||
# Returns the first weekday (Mon-Fri) of the month containing +date+.
|
||
def first_business_day(date)
|
||
d = date.beginning_of_month
|
||
d += 1.day while d.saturday? || d.sunday?
|
||
d
|
||
end
|
||
|
||
def generate_credit_card_cycles!
|
||
# REDUCED: 30-45 charges per month across both cards for 36 months (≈1,400 total)
|
||
# This is still significant but more realistic than 80-120/month
|
||
# Pay 90-95 % of new balance 5 days post-cycle; final balances should
|
||
# be ~$2 500 (Amex) and ~$4 200 (Sapphire).
|
||
|
||
start_date = 36.months.ago.beginning_of_month
|
||
end_date = Date.current.end_of_month
|
||
|
||
amex_balance = 0
|
||
sapphire_balance = 0
|
||
|
||
charges_this_run = 0
|
||
payments_this_run = 0
|
||
|
||
date_cursor = start_date
|
||
while date_cursor <= end_date
|
||
# --- Charge generation (REDUCED FOR BALANCE) -------------------------
|
||
month_charge_target = rand(30..45) # Reduced from 80-120 to 30-45
|
||
# Split roughly evenly but add a little variance.
|
||
amex_count = (month_charge_target * rand(0.45..0.55)).to_i
|
||
sapphire_count = month_charge_target - amex_count
|
||
|
||
amex_total = generate_credit_card_charges(@amex_gold, date_cursor, amex_count)
|
||
sapphire_total = generate_credit_card_charges(@chase_sapphire, date_cursor, sapphire_count)
|
||
|
||
amex_balance += amex_total
|
||
sapphire_balance += sapphire_total
|
||
|
||
charges_this_run += (amex_count + sapphire_count)
|
||
|
||
# --- Monthly payments (5 days after month end) ------------------------
|
||
payment_date = (date_cursor.end_of_month + 5.days)
|
||
|
||
if amex_total.positive?
|
||
amex_payment = (amex_total * rand(0.90..0.95)).round
|
||
create_transfer!(@chase_checking, @amex_gold, amex_payment, "Amex Payment", payment_date)
|
||
amex_balance -= amex_payment
|
||
payments_this_run += 1
|
||
end
|
||
|
||
if sapphire_total.positive?
|
||
sapphire_payment = (sapphire_total * rand(0.90..0.95)).round
|
||
create_transfer!(@chase_checking, @chase_sapphire, sapphire_payment, "Sapphire Payment", payment_date)
|
||
sapphire_balance -= sapphire_payment
|
||
payments_this_run += 1
|
||
end
|
||
|
||
date_cursor = date_cursor.next_month.beginning_of_month
|
||
end
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Re-balance to hit target ending balances (tolerance ±$250)
|
||
# -----------------------------------------------------------------------
|
||
target_amex = 2_500
|
||
target_sapphire = 4_200
|
||
|
||
diff_amex = amex_balance - target_amex
|
||
diff_sapphire = sapphire_balance - target_sapphire
|
||
|
||
if diff_amex.abs > 250
|
||
adjust_payment = diff_amex.positive? ? diff_amex : 0
|
||
create_transfer!(@chase_checking, @amex_gold, adjust_payment, "Amex Balance Adjust", Date.current)
|
||
amex_balance -= adjust_payment
|
||
end
|
||
|
||
if diff_sapphire.abs > 250
|
||
adjust_payment = diff_sapphire.positive? ? diff_sapphire : 0
|
||
create_transfer!(@chase_checking, @chase_sapphire, adjust_payment, "Sapphire Balance Adjust", Date.current)
|
||
sapphire_balance -= adjust_payment
|
||
end
|
||
|
||
puts " 💳 Charges generated: #{charges_this_run} | Payments: #{payments_this_run}"
|
||
puts " 💳 Final Amex balance: ~$#{amex_balance} | target ~$#{target_amex}"
|
||
puts " 💳 Final Sapphire balance: ~$#{sapphire_balance} | target ~$#{target_sapphire}"
|
||
end
|
||
|
||
# Generate exactly +count+ charges on +account+ within the month of +base_date+.
|
||
# Returns total charge amount.
|
||
def generate_credit_card_charges(account, base_date, count)
|
||
total = 0
|
||
|
||
count.times do
|
||
charge_date = base_date + rand(0..27).days
|
||
|
||
amount = rand(15..80) # Reduced from 25..150 due to higher frequency
|
||
# bias amounts to achieve reasonable monthly totals
|
||
amount = jitter(amount, 0.15).round
|
||
|
||
merchant = if account == @amex_gold
|
||
pick(%w[WholeFoods Starbucks UberEats Netflix LocalBistro AirBnB])
|
||
else
|
||
pick([ "Delta Airlines", "Hilton Hotels", "Expedia", "Apple", "BestBuy", "Amazon" ])
|
||
end
|
||
|
||
create_transaction!(account, amount, merchant, random_expense_category, charge_date)
|
||
total += amount
|
||
end
|
||
|
||
total
|
||
end
|
||
|
||
def random_expense_category
|
||
[ @food_cat, @entertainment_cat, @shopping_cat, @travel_cat, @transportation_cat ].sample
|
||
end
|
||
|
||
def create_transaction!(account, amount, name, category, date)
|
||
# For credit cards (liabilities), positive amounts = charges (increase debt)
|
||
# For checking accounts (assets), positive amounts = expenses (decrease balance)
|
||
# The amount is already signed correctly by the caller
|
||
account.entries.create!(
|
||
entryable: Transaction.new(category: category),
|
||
amount: amount,
|
||
name: name,
|
||
currency: account.currency,
|
||
date: date
|
||
)
|
||
end
|
||
|
||
def create_investment_transaction!(account, security, qty, price, date, name)
|
||
account.entries.create!(
|
||
entryable: Trade.new(security: security, qty: qty, price: price, currency: account.currency),
|
||
amount: -(qty * price),
|
||
name: name,
|
||
currency: account.currency,
|
||
date: date
|
||
)
|
||
end
|
||
|
||
def create_transfer!(from_account, to_account, amount, name, date)
|
||
outflow = from_account.entries.create!(
|
||
entryable: Transaction.new,
|
||
amount: amount,
|
||
name: name,
|
||
currency: from_account.currency,
|
||
date: date
|
||
)
|
||
inflow = to_account.entries.create!(
|
||
entryable: Transaction.new,
|
||
amount: -amount,
|
||
name: name,
|
||
currency: to_account.currency,
|
||
date: date
|
||
)
|
||
Transfer.create!(inflow_transaction: inflow.entryable, outflow_transaction: outflow.entryable)
|
||
end
|
||
|
||
def load_securities!
|
||
return if Security.exists?
|
||
|
||
Security.create!([
|
||
{ ticker: "VTI", name: "Vanguard Total Stock Market ETF", country_code: "US" },
|
||
{ ticker: "VXUS", name: "Vanguard Total International Stock ETF", country_code: "US" },
|
||
{ ticker: "BND", name: "Vanguard Total Bond Market ETF", country_code: "US" }
|
||
])
|
||
end
|
||
|
||
def sync_family_accounts!(family)
|
||
family.accounts.each do |account|
|
||
sync = Sync.create!(syncable: account)
|
||
sync.perform
|
||
end
|
||
end
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Deterministic helper methods
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Deterministically walk through the elements of +array+, returning the next
|
||
# element each time it is called with the *same* array instance.
|
||
#
|
||
# Example:
|
||
# colours = %w[red green blue]
|
||
# 4.times.map { pick(colours) } #=> ["red", "green", "blue", "red"]
|
||
def pick(array)
|
||
@pick_indices ||= Hash.new(0)
|
||
idx = @pick_indices[array.object_id]
|
||
@pick_indices[array.object_id] += 1
|
||
array[idx % array.length]
|
||
end
|
||
|
||
# Adds a small random variation (±pct, default 3%) to +num+. Useful for
|
||
# making otherwise deterministic amounts look more natural while retaining
|
||
# overall reproducibility via the seeded RNG.
|
||
def jitter(num, pct = 0.03)
|
||
variation = num * pct * (rand * 2 - 1) # rand(-pct..pct)
|
||
(num + variation).round(2)
|
||
end
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Loan payments (Task 8)
|
||
# ---------------------------------------------------------------------------
|
||
def generate_loan_payments!
|
||
date_cursor = 36.months.ago.beginning_of_month
|
||
while date_cursor <= Date.current
|
||
payment_date = first_business_day(date_cursor)
|
||
|
||
# Mortgage
|
||
make_loan_payment!(
|
||
principal_account: @mortgage,
|
||
principal_amount: 600,
|
||
interest_amount: 1_100,
|
||
interest_category: @housing_cat,
|
||
date: payment_date,
|
||
memo: "Mortgage Payment"
|
||
)
|
||
|
||
# Student loan
|
||
make_loan_payment!(
|
||
principal_account: @student_loan,
|
||
principal_amount: 350,
|
||
interest_amount: 100,
|
||
interest_category: @interest_cat,
|
||
date: payment_date,
|
||
memo: "Student Loan Payment"
|
||
)
|
||
|
||
# Car loan – assume 300 principal / 130 interest
|
||
make_loan_payment!(
|
||
principal_account: @car_loan,
|
||
principal_amount: 300,
|
||
interest_amount: 130,
|
||
interest_category: @transportation_cat,
|
||
date: payment_date,
|
||
memo: "Auto Loan Payment"
|
||
)
|
||
|
||
date_cursor = date_cursor.next_month.beginning_of_month
|
||
end
|
||
end
|
||
|
||
def make_loan_payment!(principal_account:, principal_amount:, interest_amount:, interest_category:, date:, memo:)
|
||
# Principal portion – transfer from checking to loan account
|
||
create_transfer!(@chase_checking, principal_account, principal_amount, memo, date)
|
||
|
||
# Interest portion – expense from checking
|
||
create_transaction!(@chase_checking, interest_amount, "#{memo} Interest", interest_category, date)
|
||
end
|
||
|
||
# Generate additional baseline expenses to reach 8k-12k transaction target
|
||
def generate_regular_expenses!
|
||
expense_generators = [
|
||
->(date) { create_transaction!(@chase_checking, jitter(rand(150..220), 0.05).round, pick([ "ConEd Electric", "National Grid", "Gas & Power" ]), @utilities_cat, date) },
|
||
->(date) { create_transaction!(@chase_checking, jitter(rand(10..20), 0.1).round, pick([ "Spotify", "Netflix", "Hulu", "Apple One" ]), @entertainment_cat, date) },
|
||
->(date) { create_transaction!(@chase_checking, jitter(rand(45..90), 0.1).round, pick([ "Whole Foods", "Trader Joe's", "Safeway" ])+" Market", @groceries_cat, date) },
|
||
->(date) { create_transaction!(@chase_checking, jitter(rand(25..50), 0.1).round, pick([ "Shell Gas", "BP Gas", "Exxon" ]), @gas_cat, date) },
|
||
->(date) { create_transaction!(@chase_checking, jitter(rand(15..40), 0.1).round, pick([ "Movie Streaming", "Book Purchase", "Mobile Game" ]), @entertainment_cat, date) }
|
||
]
|
||
|
||
desired = 600 # Increased from 300 to help reach 8k
|
||
current = Entry.joins(:account).where(accounts: { id: [ @chase_checking.id ] }, entryable_type: "Transaction").count
|
||
to_create = [ desired - current, 0 ].max
|
||
|
||
to_create.times do
|
||
date = weighted_random_date
|
||
expense_generators.sample.call(date)
|
||
end
|
||
|
||
# Add high-volume, low-impact transactions to reach 8k minimum
|
||
generate_micro_transactions!
|
||
end
|
||
|
||
# Generate many small transactions to reach volume target
|
||
def generate_micro_transactions!
|
||
# ATM withdrawals and fees (reduced)
|
||
120.times do # Reduced from 200
|
||
date = weighted_random_date
|
||
amount = rand(20..60)
|
||
create_transaction!(@chase_checking, amount, "ATM Withdrawal", @misc_cat, date)
|
||
# Small ATM fee
|
||
create_transaction!(@chase_checking, rand(2..4), "ATM Fee", @misc_cat, date)
|
||
end
|
||
|
||
# Small convenience store purchases (reduced)
|
||
200.times do # Reduced from 300
|
||
date = weighted_random_date
|
||
amount = rand(3..15)
|
||
stores = [ "7-Eleven", "Wawa", "Circle K", "Quick Stop", "Corner Store" ]
|
||
create_transaction!(@chase_checking, amount, stores.sample, @shopping_cat, date)
|
||
end
|
||
|
||
# Small digital purchases (reduced)
|
||
120.times do # Reduced from 200
|
||
date = weighted_random_date
|
||
amount = rand(1..10)
|
||
items = [ "App Store", "Google Play", "iTunes", "Steam", "Kindle Book" ]
|
||
create_transaction!(@chase_checking, amount, items.sample, @entertainment_cat, date)
|
||
end
|
||
|
||
# Parking meters and tolls (reduced)
|
||
100.times do # Reduced from 150
|
||
date = weighted_random_date
|
||
amount = rand(2..8)
|
||
create_transaction!(@chase_checking, amount, pick([ "Parking Meter", "Bridge Toll", "Tunnel Toll" ]), @transportation_cat, date)
|
||
end
|
||
|
||
# Small cash transactions (reduced)
|
||
150.times do # Reduced from 250
|
||
date = weighted_random_date
|
||
amount = rand(5..25)
|
||
vendors = [ "Food Truck", "Farmer's Market", "Street Vendor", "Tip", "Donation" ]
|
||
create_transaction!(@chase_checking, amount, vendors.sample, @misc_cat, date)
|
||
end
|
||
|
||
# Vending machine purchases (reduced)
|
||
60.times do # Reduced from 100
|
||
date = weighted_random_date
|
||
amount = rand(1..5)
|
||
create_transaction!(@chase_checking, amount, "Vending Machine", @shopping_cat, date)
|
||
end
|
||
|
||
# Public transportation (reduced)
|
||
120.times do # Reduced from 180
|
||
date = weighted_random_date
|
||
amount = rand(2..8)
|
||
transit = [ "Metro Card", "Bus Fare", "Train Ticket", "Uber/Lyft" ]
|
||
create_transaction!(@chase_checking, amount, transit.sample, @transportation_cat, date)
|
||
end
|
||
|
||
# Additional small transactions to ensure we reach 8k minimum (reduced)
|
||
400.times do # Reduced from 600
|
||
date = weighted_random_date
|
||
amount = rand(1..12)
|
||
merchants = [
|
||
"Newsstand", "Coffee Cart", "Tip Jar", "Donation Box", "Laundromat",
|
||
"Car Wash", "Redbox", "PayPhone", "Photo Booth", "Arcade Game",
|
||
"Postage", "Newspaper", "Lottery Ticket", "Gumball Machine", "Ice Cream Truck"
|
||
]
|
||
create_transaction!(@chase_checking, amount, merchants.sample, @misc_cat, date)
|
||
end
|
||
|
||
# Extra small transactions to ensure 8k+ volume
|
||
500.times do
|
||
date = weighted_random_date
|
||
amount = rand(1..8)
|
||
tiny_merchants = [
|
||
"Candy Machine", "Sticker Machine", "Penny Scale", "Charity Donation",
|
||
"Busker Tip", "Church Offering", "Lemonade Stand", "Girl Scout Cookies",
|
||
"Raffle Ticket", "Bake Sale", "Car Wash Tip", "Street Performer"
|
||
]
|
||
create_transaction!(@chase_checking, amount, tiny_merchants.sample, @misc_cat, date)
|
||
end
|
||
end
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Legacy historical transactions (Task 11)
|
||
# ---------------------------------------------------------------------------
|
||
def generate_legacy_transactions!
|
||
# Small recent legacy transactions (3-6 years ago)
|
||
count = rand(40..60) # Increased from 20-30
|
||
count.times do
|
||
years_ago = rand(3..6)
|
||
date = years_ago.years.ago.to_date - rand(0..364).days
|
||
|
||
base_amount = rand(12..45) # Reduced from 15-60
|
||
discount = (1 - 0.02 * [ years_ago - 3, 0 ].max)
|
||
amount = (base_amount * discount).round
|
||
|
||
account = [ @chase_checking, @ally_checking ].sample
|
||
category = pick([ @groceries_cat, @utilities_cat, @gas_cat, @restaurants_cat, @shopping_cat ])
|
||
|
||
merchant = case category
|
||
when @groceries_cat then pick(%w[Walmart Kroger Safeway]) + " Market"
|
||
when @utilities_cat then pick([ "Local Electric", "City Water", "Gas Co." ])
|
||
when @gas_cat then pick(%w[Shell Exxon BP])
|
||
when @restaurants_cat then pick([ "Diner", "Burger Grill", "Pizza Place" ])
|
||
else pick([ "General Store", "Department Shop", "Outlet" ])
|
||
end
|
||
|
||
create_transaction!(account, amount, merchant, category, date)
|
||
end
|
||
|
||
# Very old transactions (7-15 years ago) - just scattered outliers
|
||
count = rand(25..40) # Increased from 15-25
|
||
count.times do
|
||
years_ago = rand(7..15)
|
||
date = years_ago.years.ago.to_date - rand(0..364).days
|
||
|
||
base_amount = rand(8..30) # Reduced from 10-40
|
||
discount = (1 - 0.03 * [ years_ago - 7, 0 ].max) # More discount for very old
|
||
amount = (base_amount * discount).round.clamp(5, 25) # Reduced max from 35
|
||
|
||
account = @chase_checking # Just use main checking for simplicity
|
||
category = pick([ @groceries_cat, @gas_cat, @restaurants_cat ])
|
||
|
||
merchant = case category
|
||
when @groceries_cat then pick(%w[Walmart Kroger]) + " Market"
|
||
when @gas_cat then pick(%w[Shell Exxon])
|
||
else pick([ "Old Diner", "Local Restaurant" ])
|
||
end
|
||
|
||
create_transaction!(account, amount, "#{merchant} (#{years_ago}y ago)", category, date)
|
||
end
|
||
|
||
# Additional small transactions to reach 8k minimum if needed
|
||
additional_needed = [ 400, 0 ].max # Increased from 200
|
||
additional_needed.times do
|
||
years_ago = rand(4..12)
|
||
date = years_ago.years.ago.to_date - rand(0..364).days
|
||
amount = rand(6..20) # Reduced from 8-25
|
||
|
||
account = [ @chase_checking, @ally_checking ].sample
|
||
category = pick([ @groceries_cat, @gas_cat, @utilities_cat ])
|
||
|
||
merchant = "Legacy #{pick(%w[Store Gas Electric])}"
|
||
create_transaction!(account, amount, merchant, category, date)
|
||
end
|
||
end
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Crypto & misc assets (Task 12)
|
||
# ---------------------------------------------------------------------------
|
||
def generate_crypto_and_misc_assets!
|
||
# One-time USDC deposit 18 months ago
|
||
deposit_date = 18.months.ago.to_date
|
||
create_transaction!(@coinbase_usdc, -3_500, "Initial USDC Deposit", nil, deposit_date)
|
||
end
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Balance Reconciliation (Task 14)
|
||
# ---------------------------------------------------------------------------
|
||
def reconcile_balances!(family)
|
||
# Use valuations only for property/vehicle accounts that should have specific values
|
||
# All other accounts should reach target balances through natural transaction flow
|
||
|
||
# Property valuations (these accounts are valued, not transaction-driven)
|
||
@home.entries.create!(
|
||
entryable: Valuation.new,
|
||
amount: 350_000,
|
||
name: "Current Market Value",
|
||
currency: "USD",
|
||
date: Date.current
|
||
)
|
||
|
||
# Vehicle valuations (these depreciate over time)
|
||
@honda_accord.entries.create!(
|
||
entryable: Valuation.new,
|
||
amount: 18_000,
|
||
name: "Current Market Value",
|
||
currency: "USD",
|
||
date: Date.current
|
||
)
|
||
|
||
@tesla_model3.entries.create!(
|
||
entryable: Valuation.new,
|
||
amount: 4_500,
|
||
name: "Current Market Value",
|
||
currency: "USD",
|
||
date: Date.current
|
||
)
|
||
|
||
@jewelry.entries.create!(
|
||
entryable: Valuation.new,
|
||
amount: 2000,
|
||
name: "Current Market Value",
|
||
currency: "USD",
|
||
date: 90.days.ago.to_date
|
||
)
|
||
|
||
@personal_loc.entries.create!(
|
||
entryable: Valuation.new,
|
||
amount: 800,
|
||
name: "Owed",
|
||
currency: "USD",
|
||
date: 120.days.ago.to_date
|
||
)
|
||
|
||
puts " ✅ Set property and vehicle valuations"
|
||
end
|
||
end
|
||
|
||
# Expose public API after full class definition
|
||
Demo::Generator.public_instance_methods.include?(:generate_default_data!) or Demo::Generator.class_eval do
|
||
public :generate_empty_data!, :generate_new_user_data!, :generate_default_data!, :generate_multi_currency_data!
|
||
end
|