Add recurring transaction logic with catch-up generation
This commit is contained in:
@@ -470,6 +470,110 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- Recurring Transactions --
|
||||
|
||||
pub fn insert_recurring(&self, r: &NewRecurringTransaction) -> SqlResult<i64> {
|
||||
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<Vec<RecurringTransaction>> {
|
||||
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<RecurringTransaction> {
|
||||
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<RecurringTransaction> {
|
||||
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<String> = row.get(8)?;
|
||||
let last_str: Option<String> = 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(
|
||||
|
||||
@@ -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<usize, rusqlite::Error> {
|
||||
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<NaiveDate> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user