diff --git a/outlay-core/src/db.rs b/outlay-core/src/db.rs index 5a78fcb..10e44a5 100644 --- a/outlay-core/src/db.rs +++ b/outlay-core/src/db.rs @@ -470,6 +470,110 @@ impl Database { Ok(()) } + // -- Recurring Transactions -- + + pub fn insert_recurring(&self, r: &NewRecurringTransaction) -> SqlResult { + self.conn.execute( + "INSERT INTO recurring_transactions (amount, type, category_id, currency, note, frequency, start_date, end_date, last_generated, active) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, 1)", + params![ + r.amount, + r.transaction_type.as_str(), + r.category_id, + r.currency, + r.note, + r.frequency.as_str(), + r.start_date.format("%Y-%m-%d").to_string(), + r.end_date.map(|d| d.format("%Y-%m-%d").to_string()), + ], + )?; + Ok(self.conn.last_insert_rowid()) + } + + pub fn list_recurring(&self, active_only: bool) -> SqlResult> { + let sql = if active_only { + "SELECT id, amount, type, category_id, currency, note, frequency, start_date, end_date, last_generated, active + FROM recurring_transactions WHERE active = 1 ORDER BY id" + } else { + "SELECT id, amount, type, category_id, currency, note, frequency, start_date, end_date, last_generated, active + FROM recurring_transactions ORDER BY active DESC, id" + }; + let mut stmt = self.conn.prepare(sql)?; + let rows = stmt.query_map([], |row| Self::row_to_recurring(row))?; + rows.collect() + } + + pub fn get_recurring(&self, id: i64) -> SqlResult { + self.conn.query_row( + "SELECT id, amount, type, category_id, currency, note, frequency, start_date, end_date, last_generated, active + FROM recurring_transactions WHERE id = ?1", + params![id], + |row| Self::row_to_recurring(row), + ) + } + + pub fn update_recurring(&self, r: &RecurringTransaction) -> SqlResult<()> { + self.conn.execute( + "UPDATE recurring_transactions SET amount=?1, type=?2, category_id=?3, currency=?4, + note=?5, frequency=?6, start_date=?7, end_date=?8, active=?9 WHERE id=?10", + params![ + r.amount, + r.transaction_type.as_str(), + r.category_id, + r.currency, + r.note, + r.frequency.as_str(), + r.start_date.format("%Y-%m-%d").to_string(), + r.end_date.map(|d| d.format("%Y-%m-%d").to_string()), + r.active, + r.id, + ], + )?; + Ok(()) + } + + pub fn delete_recurring(&self, id: i64) -> SqlResult<()> { + self.conn.execute("DELETE FROM recurring_transactions WHERE id = ?1", params![id])?; + Ok(()) + } + + pub fn toggle_recurring_active(&self, id: i64, active: bool) -> SqlResult<()> { + self.conn.execute( + "UPDATE recurring_transactions SET active = ?1 WHERE id = ?2", + params![active, id], + )?; + Ok(()) + } + + pub fn update_recurring_last_generated(&self, id: i64, date: NaiveDate) -> SqlResult<()> { + self.conn.execute( + "UPDATE recurring_transactions SET last_generated = ?1 WHERE id = ?2", + params![date.format("%Y-%m-%d").to_string(), id], + )?; + Ok(()) + } + + fn row_to_recurring(row: &rusqlite::Row) -> SqlResult { + let type_str: String = row.get(2)?; + let freq_str: String = row.get(6)?; + let start_str: String = row.get(7)?; + let end_str: Option = row.get(8)?; + let last_str: Option = row.get(9)?; + Ok(RecurringTransaction { + id: row.get(0)?, + amount: row.get(1)?, + transaction_type: TransactionType::from_str(&type_str).unwrap_or(TransactionType::Expense), + category_id: row.get(3)?, + currency: row.get(4)?, + note: row.get(5)?, + frequency: Frequency::from_str(&freq_str).unwrap_or(Frequency::Monthly), + start_date: NaiveDate::parse_from_str(&start_str, "%Y-%m-%d").unwrap_or_default(), + end_date: end_str.and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()), + last_generated: last_str.and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()), + active: row.get(10)?, + }) + } + // -- Budget Notifications -- pub fn has_notification_been_sent( diff --git a/outlay-core/src/recurring.rs b/outlay-core/src/recurring.rs index e69de29..80b8078 100644 --- a/outlay-core/src/recurring.rs +++ b/outlay-core/src/recurring.rs @@ -0,0 +1,209 @@ +use crate::db::Database; +use crate::models::{Frequency, NewTransaction}; +use chrono::{Datelike, Days, NaiveDate}; + +pub fn generate_missed_transactions( + db: &Database, + today: NaiveDate, +) -> Result { + let recurring = db.list_recurring(true)?; + let mut count = 0; + + 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); + + 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: 1.0, + note: rec.note.clone(), + date: *date, + recurring_id: Some(rec.id), + }; + db.insert_transaction(&txn)?; + count += 1; + } + + if let Some(&last) = dates.last() { + db.update_recurring_last_generated(rec.id, last)?; + } + } + + Ok(count) +} + +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), + } +} + +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 +} + +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, + }; + 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).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, + }; + 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).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()), + }; + db.insert_recurring(&rec).unwrap(); + + let today = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(); + let count = generate_missed_transactions(&db, today).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, + }; + db.insert_recurring(&rec).unwrap(); + + let today = NaiveDate::from_ymd_opt(2026, 2, 22).unwrap(); + let count = generate_missed_transactions(&db, today).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()); + } +}