Add feature batch 2, subscription/recurring sync, smooth charts, and app icon
This commit is contained in:
@@ -1,13 +1,33 @@
|
||||
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<usize, rusqlite::Error> {
|
||||
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<GeneratedInfo>), 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 {
|
||||
@@ -22,19 +42,43 @@ pub fn generate_missed_transactions(
|
||||
|
||||
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: 1.0,
|
||||
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() {
|
||||
@@ -42,7 +86,15 @@ pub fn generate_missed_transactions(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
Ok((count, details))
|
||||
}
|
||||
|
||||
fn fetch_rate_sync(service: &ExchangeRateService<'_>, from: &str, to: &str) -> Option<f64> {
|
||||
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 {
|
||||
@@ -55,6 +107,32 @@ fn next_date(date: NaiveDate, freq: Frequency) -> NaiveDate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the next occurrence date for a recurring transaction from today.
|
||||
pub fn next_occurrence(rec: &crate::models::RecurringTransaction, from: NaiveDate) -> Option<NaiveDate> {
|
||||
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<NaiveDate> {
|
||||
let mut dates = Vec::new();
|
||||
let mut current = from;
|
||||
@@ -65,7 +143,7 @@ fn generate_dates(from: NaiveDate, until: NaiveDate, freq: Frequency) -> Vec<Nai
|
||||
dates
|
||||
}
|
||||
|
||||
fn add_months(date: NaiveDate, months: u32) -> NaiveDate {
|
||||
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;
|
||||
@@ -113,13 +191,16 @@ mod tests {
|
||||
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).unwrap();
|
||||
let count = generate_missed_transactions(&db, today, "USD").unwrap();
|
||||
// Should generate Feb 27, Feb 28, Mar 1 = 3 transactions
|
||||
assert_eq!(count, 3);
|
||||
}
|
||||
@@ -139,13 +220,16 @@ mod tests {
|
||||
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).unwrap();
|
||||
let count = generate_missed_transactions(&db, today, "USD").unwrap();
|
||||
// Should generate Feb 15 and Mar 15
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
@@ -165,11 +249,14 @@ mod tests {
|
||||
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).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);
|
||||
}
|
||||
@@ -189,11 +276,14 @@ mod tests {
|
||||
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).unwrap();
|
||||
let count = generate_missed_transactions(&db, today, "USD").unwrap();
|
||||
// From Feb 1 weekly: Feb 1, 8, 15, 22 = 4
|
||||
assert_eq!(count, 4);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user