use crate::db::Database; use crate::exchange::ExchangeRateService; use crate::models::{Frequency, NewTransaction}; use chrono::{Datelike, Days, NaiveDate}; /// Details about a generated recurring transaction. pub struct GeneratedInfo { pub description: String, pub amount: f64, pub currency: String, } pub fn generate_missed_transactions( db: &Database, today: NaiveDate, base_currency: &str, ) -> Result { let (count, _details) = generate_missed_transactions_detailed(db, today, base_currency)?; Ok(count) } pub fn generate_missed_transactions_detailed( db: &Database, today: NaiveDate, base_currency: &str, ) -> Result<(usize, Vec), rusqlite::Error> { let recurring = db.list_recurring(true)?; let mut count = 0; let mut details = Vec::new(); let rate_service = ExchangeRateService::new(db); for rec in &recurring { let from = match rec.last_generated { Some(last) => next_date(last, rec.frequency), None => rec.start_date, }; let until = match rec.end_date { Some(end) if end < today => end, _ => today, }; let dates = generate_dates(from, until, rec.frequency); // Fetch exchange rate once per recurring (same currency for all dates) let exchange_rate = if rec.currency.eq_ignore_ascii_case(base_currency) { 1.0 } else { fetch_rate_sync(&rate_service, &rec.currency, base_currency).unwrap_or(1.0) }; let desc = rec .note .as_deref() .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .unwrap_or_else(|| { db.get_category(rec.category_id) .map(|c| c.name) .unwrap_or_else(|_| "Recurring".to_string()) }); for date in &dates { let txn = NewTransaction { amount: rec.amount, transaction_type: rec.transaction_type, category_id: rec.category_id, currency: rec.currency.clone(), exchange_rate, note: rec.note.clone(), date: *date, recurring_id: Some(rec.id), payee: None, }; db.insert_transaction(&txn)?; count += 1; details.push(GeneratedInfo { description: desc.clone(), amount: rec.amount, currency: rec.currency.clone(), }); } if let Some(&last) = dates.last() { db.update_recurring_last_generated(rec.id, last)?; } } Ok((count, details)) } fn fetch_rate_sync(service: &ExchangeRateService<'_>, from: &str, to: &str) -> Option { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .ok()?; rt.block_on(service.get_rate(from, to)).ok() } fn next_date(date: NaiveDate, freq: Frequency) -> NaiveDate { match freq { Frequency::Daily => date.checked_add_days(Days::new(1)).unwrap_or(date), Frequency::Weekly => date.checked_add_days(Days::new(7)).unwrap_or(date), Frequency::Biweekly => date.checked_add_days(Days::new(14)).unwrap_or(date), Frequency::Monthly => add_months(date, 1), Frequency::Yearly => add_months(date, 12), } } /// Compute the next occurrence date for a recurring transaction from today. pub fn next_occurrence(rec: &crate::models::RecurringTransaction, from: NaiveDate) -> Option { if !rec.active { return None; } // Start from last_generated + 1 period, or start_date let mut date = match rec.last_generated { Some(last) => next_date(last, rec.frequency), None => rec.start_date, }; // Advance until we reach today or beyond while date < from { date = next_date(date, rec.frequency); } // Check end_date if let Some(end) = rec.end_date { if date > end { return None; } } Some(date) } fn generate_dates(from: NaiveDate, until: NaiveDate, freq: Frequency) -> Vec { let mut dates = Vec::new(); let mut current = from; while current <= until { dates.push(current); current = next_date(current, freq); } dates } pub fn add_months(date: NaiveDate, months: u32) -> NaiveDate { let total_months = date.month0() + months; let new_year = date.year() + (total_months / 12) as i32; let new_month = (total_months % 12) + 1; let max_day = days_in_month(new_year, new_month); let new_day = date.day().min(max_day); NaiveDate::from_ymd_opt(new_year, new_month, new_day).unwrap_or(date) } fn days_in_month(year: i32, month: u32) -> u32 { match month { 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, 4 | 6 | 9 | 11 => 30, 2 => { if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 { 29 } else { 28 } } _ => 30, } } #[cfg(test)] mod tests { use super::*; use crate::models::{NewRecurringTransaction, TransactionType}; fn setup_db() -> Database { Database::open_in_memory().unwrap() } #[test] fn test_generate_daily_recurring() { let db = setup_db(); let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); let cat_id = cats[0].id; let rec = NewRecurringTransaction { amount: 5.0, transaction_type: TransactionType::Expense, category_id: cat_id, currency: "USD".to_string(), note: Some("Coffee".to_string()), frequency: Frequency::Daily, start_date: NaiveDate::from_ymd_opt(2026, 2, 24).unwrap(), end_date: None, is_bill: false, reminder_days: 3, subscription_id: None, }; let rec_id = db.insert_recurring(&rec).unwrap(); let today = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(); db.update_recurring_last_generated(rec_id, NaiveDate::from_ymd_opt(2026, 2, 26).unwrap()).unwrap(); let count = generate_missed_transactions(&db, today, "USD").unwrap(); // Should generate Feb 27, Feb 28, Mar 1 = 3 transactions assert_eq!(count, 3); } #[test] fn test_generate_monthly_recurring() { let db = setup_db(); let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); let cat_id = cats[0].id; let rec = NewRecurringTransaction { amount: 100.0, transaction_type: TransactionType::Expense, category_id: cat_id, currency: "USD".to_string(), note: None, frequency: Frequency::Monthly, start_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(), end_date: None, is_bill: false, reminder_days: 3, subscription_id: None, }; let rec_id = db.insert_recurring(&rec).unwrap(); db.update_recurring_last_generated(rec_id, NaiveDate::from_ymd_opt(2026, 1, 15).unwrap()).unwrap(); let today = NaiveDate::from_ymd_opt(2026, 3, 20).unwrap(); let count = generate_missed_transactions(&db, today, "USD").unwrap(); // Should generate Feb 15 and Mar 15 assert_eq!(count, 2); } #[test] fn test_respects_end_date() { let db = setup_db(); let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); let cat_id = cats[0].id; let rec = NewRecurringTransaction { amount: 50.0, transaction_type: TransactionType::Expense, category_id: cat_id, currency: "USD".to_string(), note: None, frequency: Frequency::Daily, start_date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), end_date: Some(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap()), is_bill: false, reminder_days: 3, subscription_id: None, }; db.insert_recurring(&rec).unwrap(); let today = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(); let count = generate_missed_transactions(&db, today, "USD").unwrap(); // end_date is Jan 5, generates Jan 1-5 = 5 transactions assert_eq!(count, 5); } #[test] fn test_first_generation_from_start_date() { let db = setup_db(); let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); let cat_id = cats[0].id; let rec = NewRecurringTransaction { amount: 25.0, transaction_type: TransactionType::Expense, category_id: cat_id, currency: "USD".to_string(), note: None, frequency: Frequency::Weekly, start_date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), end_date: None, is_bill: false, reminder_days: 3, subscription_id: None, }; db.insert_recurring(&rec).unwrap(); let today = NaiveDate::from_ymd_opt(2026, 2, 22).unwrap(); let count = generate_missed_transactions(&db, today, "USD").unwrap(); // From Feb 1 weekly: Feb 1, 8, 15, 22 = 4 assert_eq!(count, 4); } #[test] fn test_monthly_handles_month_end() { let result = add_months( NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(), 1, ); assert_eq!(result, NaiveDate::from_ymd_opt(2026, 2, 28).unwrap()); } }