Add recurring transaction logic with catch-up generation

This commit is contained in:
2026-03-02 00:34:34 +02:00
parent b0e42e426b
commit 8c4b5a8b4d
2 changed files with 313 additions and 0 deletions

View File

@@ -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());
}
}