use chrono::{Datelike, Local, NaiveDate}; use rand::Rng; use rusqlite::params; use crate::db::Database; /// Populate the database with realistic demo data spanning ~2 years. /// Assumes the database already has default categories seeded. pub fn seed_demo_data(db: &Database) -> Result<(), Box> { let mut rng = rand::thread_rng(); let today = Local::now().date_naive(); let start = NaiveDate::from_ymd_opt(today.year() - 2, today.month(), 1).unwrap(); // -- Settings -- db.set_setting("base_currency", "USD")?; db.set_setting("theme", "system")?; // -- Look up category IDs -- let cats: Vec<(i64, String, String)> = db.conn.prepare( "SELECT id, name, type FROM categories ORDER BY id" )?.query_map([], |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?)) })?.filter_map(|r| r.ok()).collect(); let cat_id = |name: &str| -> i64 { cats.iter().find(|(_, n, _)| n == name).map(|(id, _, _)| *id).unwrap_or(1) }; // Expense category IDs let food_id = cat_id("Food and Dining"); let groceries_id = cat_id("Groceries"); let transport_id = cat_id("Transport"); let housing_id = cat_id("Housing/Rent"); let utilities_id = cat_id("Utilities"); let entertainment_id = cat_id("Entertainment"); let shopping_id = cat_id("Shopping"); let health_id = cat_id("Health"); let education_id = cat_id("Education"); let subscriptions_id = cat_id("Subscriptions"); let personal_id = cat_id("Personal Care"); let gifts_id = cat_id("Gifts"); let travel_id = cat_id("Travel"); // Income category IDs let salary_id = cat_id("Salary"); let freelance_id = cat_id("Freelance"); let investment_id = cat_id("Investment"); let gift_income_id = cat_id("Gift"); let refund_id = cat_id("Refund"); // Realistic payees and notes per category let food_payees = ["Chipotle", "Starbucks", "Panda Express", "Subway", "Pizza Hut", "Local Diner", "Thai Kitchen", "Burger Joint", "Sushi Bar", "Taco Bell"]; let grocery_payees = ["Whole Foods", "Trader Joe's", "Kroger", "Costco", "Aldi", "Safeway", "Target", "Walmart"]; let transport_notes = ["Gas station", "Bus pass", "Uber ride", "Lyft", "Parking", "Car wash", "Oil change", "Tire rotation"]; let entertainment_notes = ["Movie tickets", "Netflix", "Concert", "Board game", "Bowling", "Escape room", "Museum", "Book"]; let shopping_payees = ["Amazon", "Target", "Best Buy", "IKEA", "Home Depot", "Etsy", "Thrift store"]; let health_notes = ["Pharmacy", "Doctor copay", "Gym membership", "Vitamins", "Dentist", "Eye exam"]; let personal_notes = ["Haircut", "Toiletries", "Dry cleaning", "Laundry"]; // Helper: random float in range let rand_amount = |rng: &mut rand::rngs::ThreadRng, low: f64, high: f64| -> f64 { let val = rng.gen_range(low..high); (val * 100.0).round() / 100.0 }; let rand_pick = |rng: &mut rand::rngs::ThreadRng, items: &[&str]| -> String { items[rng.gen_range(0..items.len())].to_string() }; let insert_txn = |date: NaiveDate, amount: f64, txn_type: &str, cat: i64, note: Option<&str>, payee: Option<&str>| -> Result<(), Box> { let date_str = date.format("%Y-%m-%d").to_string(); let created = format!("{} 12:00:00", date_str); db.conn.execute( "INSERT INTO transactions (amount, type, category_id, currency, exchange_rate, note, date, created_at, payee) VALUES (?1, ?2, ?3, 'USD', 1.0, ?4, ?5, ?6, ?7)", params![amount, txn_type, cat, note, date_str, created, payee], )?; Ok(()) }; // -- Generate transactions month by month -- let mut current = start; while current <= today { let year = current.year(); let month = current.month(); let days_in_month = if month == 12 { NaiveDate::from_ymd_opt(year + 1, 1, 1) } else { NaiveDate::from_ymd_opt(year, month + 1, 1) }.and_then(|d| d.pred_opt()).map(|d| d.day()).unwrap_or(30); let month_str = format!("{}-{:02}", year, month); // Monthly income: salary on the 1st and 15th (biweekly) let base_salary = 2850.0 + (year - start.year()) as f64 * 150.0; if let Some(d) = NaiveDate::from_ymd_opt(year, month, 1) { if d <= today { insert_txn(d, base_salary, "income", salary_id, Some("Biweekly paycheck"), Some("Acme Corp"))?; } } if let Some(d) = NaiveDate::from_ymd_opt(year, month, 15) { if d <= today { insert_txn(d, base_salary, "income", salary_id, Some("Biweekly paycheck"), Some("Acme Corp"))?; } } // Occasional freelance income (30% of months) if rng.gen_bool(0.3) { let day = rng.gen_range(5..=25).min(days_in_month); if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) { if d <= today { let amt = rand_amount(&mut rng, 200.0, 1200.0); insert_txn(d, amt, "income", freelance_id, Some("Web dev project"), Some("Freelance client"))?; } } } // Investment dividends quarterly (March, June, Sept, Dec) if matches!(month, 3 | 6 | 9 | 12) { let day = rng.gen_range(10..=20).min(days_in_month); if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) { if d <= today { let amt = rand_amount(&mut rng, 50.0, 180.0); insert_txn(d, amt, "income", investment_id, Some("Dividend payment"), Some("Vanguard"))?; } } } // Occasional refunds if rng.gen_bool(0.15) { let day = rng.gen_range(1..=days_in_month); if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) { if d <= today { let amt = rand_amount(&mut rng, 10.0, 80.0); insert_txn(d, amt, "income", refund_id, Some("Return item"), Some("Amazon"))?; } } } // Birthday/holiday gift income (December, month of user) if month == 12 { if let Some(d) = NaiveDate::from_ymd_opt(year, month, 25) { if d <= today { let amt = rand_amount(&mut rng, 50.0, 200.0); insert_txn(d, amt, "income", gift_income_id, Some("Holiday gift"), None)?; } } } // -- EXPENSES -- // Rent: 1st of every month if let Some(d) = NaiveDate::from_ymd_opt(year, month, 1) { if d <= today { insert_txn(d, 1350.00, "expense", housing_id, Some("Monthly rent"), Some("Pinewood Apartments"))?; } } // Utilities: ~10th of month if let Some(d) = NaiveDate::from_ymd_opt(year, month, 10.min(days_in_month)) { if d <= today { let electric = rand_amount(&mut rng, 60.0, 140.0); insert_txn(d, electric, "expense", utilities_id, Some("Electric bill"), Some("City Power Co"))?; let internet = 65.00; insert_txn(d, internet, "expense", utilities_id, Some("Internet"), Some("Comcast"))?; } } // Phone bill: 5th if let Some(d) = NaiveDate::from_ymd_opt(year, month, 5.min(days_in_month)) { if d <= today { insert_txn(d, 45.00, "expense", subscriptions_id, Some("Phone plan"), Some("Mint Mobile"))?; } } // Streaming subscriptions: 1st if let Some(d) = NaiveDate::from_ymd_opt(year, month, 1) { if d <= today { insert_txn(d, 15.99, "expense", subscriptions_id, Some("Streaming service"), Some("Netflix"))?; insert_txn(d, 10.99, "expense", subscriptions_id, Some("Music streaming"), Some("Spotify"))?; } } // Groceries: 2-4 trips per month let grocery_trips = rng.gen_range(2..=4); for _ in 0..grocery_trips { let day = rng.gen_range(1..=days_in_month); if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) { if d <= today { let amt = rand_amount(&mut rng, 45.0, 160.0); let payee = rand_pick(&mut rng, &grocery_payees); insert_txn(d, amt, "expense", groceries_id, Some("Weekly groceries"), Some(&payee))?; } } } // Food and dining: 4-8 meals out per month let meals_out = rng.gen_range(4..=8); for _ in 0..meals_out { let day = rng.gen_range(1..=days_in_month); if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) { if d <= today { let amt = rand_amount(&mut rng, 8.0, 55.0); let payee = rand_pick(&mut rng, &food_payees); insert_txn(d, amt, "expense", food_id, None, Some(&payee))?; } } } // Transport: 2-5 per month let transport_count = rng.gen_range(2..=5); for _ in 0..transport_count { let day = rng.gen_range(1..=days_in_month); if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) { if d <= today { let amt = rand_amount(&mut rng, 5.0, 65.0); let note = rand_pick(&mut rng, &transport_notes); insert_txn(d, amt, "expense", transport_id, Some(¬e), None)?; } } } // Entertainment: 1-3 per month let ent_count = rng.gen_range(1..=3); for _ in 0..ent_count { let day = rng.gen_range(1..=days_in_month); if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) { if d <= today { let amt = rand_amount(&mut rng, 10.0, 70.0); let note = rand_pick(&mut rng, &entertainment_notes); insert_txn(d, amt, "expense", entertainment_id, Some(¬e), None)?; } } } // Shopping: 1-3 per month let shop_count = rng.gen_range(1..=3); for _ in 0..shop_count { let day = rng.gen_range(1..=days_in_month); if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) { if d <= today { let amt = rand_amount(&mut rng, 15.0, 120.0); let payee = rand_pick(&mut rng, &shopping_payees); insert_txn(d, amt, "expense", shopping_id, None, Some(&payee))?; } } } // Health: 0-2 per month let health_count = rng.gen_range(0..=2); for _ in 0..health_count { let day = rng.gen_range(1..=days_in_month); if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) { if d <= today { let amt = rand_amount(&mut rng, 15.0, 120.0); let note = rand_pick(&mut rng, &health_notes); insert_txn(d, amt, "expense", health_id, Some(¬e), None)?; } } } // Personal care: 0-2 per month let personal_count = rng.gen_range(0..=2); for _ in 0..personal_count { let day = rng.gen_range(1..=days_in_month); if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) { if d <= today { let amt = rand_amount(&mut rng, 12.0, 60.0); let note = rand_pick(&mut rng, &personal_notes); insert_txn(d, amt, "expense", personal_id, Some(¬e), None)?; } } } // Education: occasional (20% of months) if rng.gen_bool(0.2) { let day = rng.gen_range(1..=days_in_month); if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) { if d <= today { let amt = rand_amount(&mut rng, 15.0, 80.0); insert_txn(d, amt, "expense", education_id, Some("Online course"), Some("Udemy"))?; } } } // Gifts: mainly November/December, occasionally otherwise let gift_chance = if matches!(month, 11 | 12) { 0.8 } else { 0.1 }; if rng.gen_bool(gift_chance) { let day = rng.gen_range(1..=days_in_month); if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) { if d <= today { let amt = rand_amount(&mut rng, 20.0, 150.0); insert_txn(d, amt, "expense", gifts_id, Some("Birthday/holiday gift"), None)?; } } } // Travel: 1-2 trips per year (spread across a few months) if rng.gen_bool(0.08) { for _ in 0..rng.gen_range(2..=4) { let day = rng.gen_range(1..=days_in_month); if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) { if d <= today { let amt = rand_amount(&mut rng, 50.0, 400.0); let notes = ["Hotel stay", "Flight", "Restaurant abroad", "Sightseeing"]; let note = rand_pick(&mut rng, ¬es); insert_txn(d, amt, "expense", travel_id, Some(¬e), None)?; } } } } // -- Budgets for this month -- let budget_items: Vec<(i64, f64)> = vec![ (groceries_id, 500.0), (food_id, 350.0), (transport_id, 200.0), (entertainment_id, 150.0), (shopping_id, 200.0), (utilities_id, 250.0), (subscriptions_id, 80.0), (health_id, 100.0), (personal_id, 75.0), ]; for (cat, amt) in &budget_items { db.conn.execute( "INSERT OR IGNORE INTO budgets (category_id, amount, month) VALUES (?1, ?2, ?3)", params![cat, amt, month_str], )?; } // Advance to next month current = if month == 12 { NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap() } else { NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap() }; } // -- Recurring transactions (plain, non-subscription) -- let two_years_ago = format!("{}-{:02}-01", today.year() - 2, today.month()); let recurring_items: Vec<(f64, &str, i64, &str, &str)> = vec![ (1350.00, "expense", housing_id, "monthly", "Monthly rent"), (65.00, "expense", utilities_id, "monthly", "Internet"), ]; for (amount, txn_type, cat, freq, note) in &recurring_items { db.conn.execute( "INSERT INTO recurring_transactions (amount, type, category_id, currency, note, frequency, start_date, active) VALUES (?1, ?2, ?3, 'USD', ?4, ?5, ?6, 1)", params![amount, txn_type, cat, note, freq, two_years_ago], )?; } // -- Linked subscriptions + recurring -- use crate::models::{Frequency, NewRecurringTransaction, TransactionType}; let sub_services: Vec<(&str, f64, &str, &str)> = vec![ ("Netflix", 15.99, "tabler-brand-netflix", "#E50914"), ("Spotify", 10.99, "tabler-brand-spotify", "#1DB954"), ("iCloud", 2.99, "tabler-cloud", "#3693F3"), ("GitHub", 4.00, "tabler-brand-github", "#333333"), ("Xbox Game Pass", 16.99, "tabler-brand-xbox", "#107C10"), ]; for (name, amount, _icon, _color) in &sub_services { // Find the subscription category by name let sub_cat_id: i64 = db.conn.query_row( "SELECT id FROM subscription_categories WHERE name = ?1", params![name], |row| row.get(0), ).unwrap_or_else(|_| { // Fallback to "Other" category db.conn.query_row( "SELECT id FROM subscription_categories WHERE name = 'Other'", [], |row| row.get(0), ).unwrap_or(1) }); let start = chrono::NaiveDate::parse_from_str(&two_years_ago, "%Y-%m-%d") .unwrap_or(today); let new_rec = NewRecurringTransaction { amount: *amount, transaction_type: TransactionType::Expense, category_id: subscriptions_id, currency: "USD".to_string(), note: Some(name.to_string()), frequency: Frequency::Monthly, start_date: start, end_date: None, is_bill: true, reminder_days: 3, subscription_id: None, }; db.insert_linked_recurring_and_subscription(&new_rec, sub_cat_id, name)?; } // -- Savings goals -- db.conn.execute( "INSERT INTO savings_goals (name, target, saved, currency, deadline, color, icon) VALUES ('Emergency Fund', 10000.0, 6450.0, 'USD', ?1, '#27ae60', 'tabler-shield')", params![format!("{}-12-31", today.year())], )?; db.conn.execute( "INSERT INTO savings_goals (name, target, saved, currency, deadline, color, icon) VALUES ('Vacation Fund', 3000.0, 1820.0, 'USD', ?1, '#3498db', 'tabler-plane')", params![format!("{}-06-30", today.year() + 1)], )?; db.conn.execute( "INSERT INTO savings_goals (name, target, saved, currency, deadline, color, icon) VALUES ('New Laptop', 1500.0, 950.0, 'USD', ?1, '#9b59b6', 'tabler-device-laptop')", params![format!("{}-09-01", today.year())], )?; // -- Wishlist items -- db.conn.execute( "INSERT INTO wishlist_items (name, amount, category_id, note, priority) VALUES ('Noise Cancelling Headphones', 299.99, ?1, 'Sony WH-1000XM5', 1)", params![shopping_id], )?; db.conn.execute( "INSERT INTO wishlist_items (name, amount, category_id, note, priority) VALUES ('Ergonomic Keyboard', 179.00, ?1, 'Kinesis Advantage 360', 2)", params![shopping_id], )?; db.conn.execute( "INSERT INTO wishlist_items (name, amount, category_id, note, priority) VALUES ('Camping Gear Set', 450.00, ?1, 'Tent + sleeping bag + mat', 3)", params![travel_id], )?; // -- Credit Cards -- db.conn.execute( "INSERT INTO credit_cards (name, credit_limit, statement_close_day, due_day, min_payment_pct, current_balance, currency, color) VALUES ('Chase Sapphire', 8000.0, 25, 15, 2.0, 2340.0, 'USD', '#003087')", [], )?; db.conn.execute( "INSERT INTO credit_cards (name, credit_limit, statement_close_day, due_day, min_payment_pct, current_balance, currency, color) VALUES ('Amex Gold', 12000.0, 20, 10, 2.0, 890.0, 'USD', '#C4A000')", [], )?; // -- Purchased wishlist items -- db.conn.execute( "INSERT INTO wishlist_items (name, amount, category_id, note, priority, purchased) VALUES ('Mechanical Keyboard', 149.99, ?1, 'Cherry MX Brown switches', 2, 1)", params![shopping_id], )?; db.conn.execute( "INSERT INTO wishlist_items (name, amount, category_id, note, priority, purchased) VALUES ('Running Shoes', 89.99, ?1, 'Nike Pegasus', 1, 1)", params![shopping_id], )?; // -- Achievements -- let two_years_ago_dt = format!( "{}-{:02}-15 12:00:00", today.year() - 2, today.month() ); let one_year_ago_dt = format!( "{}-{:02}-15 12:00:00", today.year() - 1, today.month() ); let six_months_ago_dt = { let m = if today.month() > 6 { today.month() - 6 } else { today.month() + 6 }; let y = if today.month() > 6 { today.year() } else { today.year() - 1 }; format!("{}-{:02}-15 12:00:00", y, m) }; db.conn.execute( "UPDATE achievements SET earned_at = ?1 WHERE name = 'First Transaction'", params![two_years_ago_dt], )?; db.conn.execute( "UPDATE achievements SET earned_at = ?1 WHERE name = '100 Transactions'", params![one_year_ago_dt], )?; db.conn.execute( "UPDATE achievements SET earned_at = ?1 WHERE name = 'Month Under Budget'", params![six_months_ago_dt], )?; // -- Transaction Templates -- db.insert_template( "Morning Coffee", Some(5.50), TransactionType::Expense, food_id, "USD", Some("Starbucks"), Some("Daily coffee"), None, )?; db.insert_template( "Weekly Groceries", Some(85.00), TransactionType::Expense, groceries_id, "USD", Some("Trader Joe's"), Some("Weekly grocery run"), None, )?; // -- Tags -- db.conn.execute("INSERT OR IGNORE INTO tags (name) VALUES ('essential')", [])?; db.conn.execute("INSERT OR IGNORE INTO tags (name) VALUES ('splurge')", [])?; db.conn.execute("INSERT OR IGNORE INTO tags (name) VALUES ('recurring')", [])?; db.conn.execute("INSERT OR IGNORE INTO tags (name) VALUES ('work-related')", [])?; // -- Categorization rules -- db.conn.execute( "INSERT INTO categorization_rules (field, pattern, category_id, priority) VALUES ('payee', 'Starbucks', ?1, 1)", params![food_id], )?; db.conn.execute( "INSERT INTO categorization_rules (field, pattern, category_id, priority) VALUES ('payee', 'Whole Foods', ?1, 1)", params![groceries_id], )?; db.conn.execute( "INSERT INTO categorization_rules (field, pattern, category_id, priority) VALUES ('payee', 'Amazon', ?1, 1)", params![shopping_id], )?; Ok(()) }