use crate::db::Database; use crate::models::TransactionType; use genpdf::elements; use genpdf::style; use genpdf::Element as _; use std::path::Path; const FONT_DIRS: &[&str] = &[ "/usr/share/fonts/truetype/liberation", "/usr/share/fonts/liberation-sans", "/usr/share/fonts/TTF", ]; #[derive(Debug)] pub enum PdfError { Db(rusqlite::Error), Pdf(genpdf::error::Error), FontNotFound, } impl From for PdfError { fn from(e: rusqlite::Error) -> Self { PdfError::Db(e) } } impl From for PdfError { fn from(e: genpdf::error::Error) -> Self { PdfError::Pdf(e) } } impl std::fmt::Display for PdfError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { PdfError::Db(e) => write!(f, "Database error: {}", e), PdfError::Pdf(e) => write!(f, "PDF error: {}", e), PdfError::FontNotFound => write!(f, "Liberation Sans font not found"), } } } fn find_font_dir() -> Option<&'static str> { for dir in FONT_DIRS { let path = Path::new(dir).join("LiberationSans-Regular.ttf"); if path.exists() { return Some(dir); } } None } fn month_name(month: u32) -> &'static str { match month { 1 => "January", 2 => "February", 3 => "March", 4 => "April", 5 => "May", 6 => "June", 7 => "July", 8 => "August", 9 => "September", 10 => "October", 11 => "November", 12 => "December", _ => "Unknown", } } pub fn generate_monthly_report( db: &Database, year: i32, month: u32, base_currency: &str, output_path: &Path, ) -> Result<(), PdfError> { let font_dir = find_font_dir().ok_or(PdfError::FontNotFound)?; let font_family = genpdf::fonts::from_files(font_dir, "LiberationSans", None)?; let mut doc = genpdf::Document::new(font_family); doc.set_title("Outlay - Monthly Report"); let mut decorator = genpdf::SimplePageDecorator::new(); decorator.set_margins(15); doc.set_page_decorator(decorator); let title = format!( "Outlay - {} {} Report", month_name(month), year ); doc.push(elements::Paragraph::new(&title).styled(style::Style::new().bold().with_font_size(18))); doc.push(elements::Break::new(1.5)); let month_str = format!("{:04}-{:02}", year, month); let transactions = db.list_transactions_by_month(year, month)?; let mut total_income = 0.0_f64; let mut total_expenses = 0.0_f64; let mut category_totals: std::collections::HashMap = std::collections::HashMap::new(); for txn in &transactions { let converted = txn.amount * txn.exchange_rate; match txn.transaction_type { TransactionType::Income => total_income += converted, TransactionType::Expense => total_expenses += converted, } if txn.transaction_type == TransactionType::Expense { let cat_name = db .get_category(txn.category_id) .map(|c| c.name) .unwrap_or_else(|_| "Unknown".to_string()); let entry = category_totals .entry(txn.category_id) .or_insert_with(|| (cat_name, 0.0)); entry.1 += converted; } } let net = total_income - total_expenses; doc.push(elements::Paragraph::new("Summary").styled(style::Style::new().bold().with_font_size(14))); doc.push(elements::Break::new(0.5)); doc.push(elements::Paragraph::new(format!( "Total Income: {:.2} {}", total_income, base_currency ))); doc.push(elements::Paragraph::new(format!( "Total Expenses: {:.2} {}", total_expenses, base_currency ))); doc.push(elements::Paragraph::new(format!( "Net: {:.2} {}", net, base_currency ))); doc.push(elements::Break::new(1.0)); if !category_totals.is_empty() { doc.push(elements::Paragraph::new("Expense Breakdown by Category").styled( style::Style::new().bold().with_font_size(14), )); doc.push(elements::Break::new(0.5)); let mut table = elements::TableLayout::new(vec![3, 2, 1]); table.set_cell_decorator(elements::FrameCellDecorator::new(true, true, false)); table .row() .element(elements::Paragraph::new("Category").styled(style::Style::new().bold())) .element(elements::Paragraph::new("Amount").styled(style::Style::new().bold())) .element(elements::Paragraph::new("% of Total").styled(style::Style::new().bold())) .push() .map_err(genpdf::error::Error::from)?; let mut sorted: Vec<(i64, (String, f64))> = category_totals.into_iter().collect(); sorted.sort_by(|a, b| b.1 .1.partial_cmp(&a.1 .1).unwrap()); for (_, (name, amount)) in &sorted { let pct = if total_expenses > 0.0 { amount / total_expenses * 100.0 } else { 0.0 }; table .row() .element(elements::Paragraph::new(name)) .element(elements::Paragraph::new(format!("{:.2}", amount))) .element(elements::Paragraph::new(format!("{:.1}%", pct))) .push() .map_err(genpdf::error::Error::from)?; } doc.push(table); doc.push(elements::Break::new(1.0)); } let mut expense_txns: Vec<_> = transactions .iter() .filter(|t| t.transaction_type == TransactionType::Expense) .collect(); expense_txns.sort_by(|a, b| b.amount.partial_cmp(&a.amount).unwrap()); if !expense_txns.is_empty() { doc.push(elements::Paragraph::new("Top Expenses").styled( style::Style::new().bold().with_font_size(14), )); doc.push(elements::Break::new(0.5)); let mut table = elements::TableLayout::new(vec![2, 2, 2, 3]); table.set_cell_decorator(elements::FrameCellDecorator::new(true, true, false)); table .row() .element(elements::Paragraph::new("Date").styled(style::Style::new().bold())) .element(elements::Paragraph::new("Category").styled(style::Style::new().bold())) .element(elements::Paragraph::new("Amount").styled(style::Style::new().bold())) .element(elements::Paragraph::new("Note").styled(style::Style::new().bold())) .push() .map_err(genpdf::error::Error::from)?; for txn in expense_txns.iter().take(5) { let cat_name = db .get_category(txn.category_id) .map(|c| c.name) .unwrap_or_else(|_| "Unknown".to_string()); table .row() .element(elements::Paragraph::new(txn.date.format("%Y-%m-%d").to_string())) .element(elements::Paragraph::new(&cat_name)) .element(elements::Paragraph::new(format!( "{:.2} {}", txn.amount, txn.currency ))) .element(elements::Paragraph::new( txn.note.as_deref().unwrap_or("-"), )) .push() .map_err(genpdf::error::Error::from)?; } doc.push(table); doc.push(elements::Break::new(1.0)); } let budgets = db.list_budgets_for_month(&month_str)?; if !budgets.is_empty() { doc.push(elements::Paragraph::new("Budget vs Actual").styled( style::Style::new().bold().with_font_size(14), )); doc.push(elements::Break::new(0.5)); let mut table = elements::TableLayout::new(vec![3, 2, 2, 1]); table.set_cell_decorator(elements::FrameCellDecorator::new(true, true, false)); table .row() .element(elements::Paragraph::new("Category").styled(style::Style::new().bold())) .element(elements::Paragraph::new("Budget").styled(style::Style::new().bold())) .element(elements::Paragraph::new("Spent").styled(style::Style::new().bold())) .element(elements::Paragraph::new("Used").styled(style::Style::new().bold())) .push() .map_err(genpdf::error::Error::from)?; for budget in &budgets { let cat_name = db .get_category(budget.category_id) .map(|c| c.name) .unwrap_or_else(|_| "Unknown".to_string()); let progress = db.get_budget_progress(budget.category_id, &month_str)?; let (budget_amt, spent, pct) = progress.unwrap_or((budget.amount, 0.0, 0.0)); table .row() .element(elements::Paragraph::new(&cat_name)) .element(elements::Paragraph::new(format!("{:.2}", budget_amt))) .element(elements::Paragraph::new(format!("{:.2}", spent))) .element(elements::Paragraph::new(format!("{:.0}%", pct))) .push() .map_err(genpdf::error::Error::from)?; } doc.push(table); } doc.push(elements::Break::new(1.5)); doc.push( elements::Paragraph::new(format!("Generated by Outlay on {}", chrono::Local::now().format("%Y-%m-%d %H:%M"))) .styled(style::Style::new().with_font_size(8)), ); doc.render_to_file(output_path)?; Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::models::{NewTransaction, TransactionType}; use chrono::NaiveDate; fn setup_db() -> Database { Database::open_in_memory().unwrap() } #[test] fn test_pdf_report_generates_file() { if find_font_dir().is_none() { eprintln!("Skipping PDF test - Liberation Sans fonts not found"); return; } let db = setup_db(); let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); let income_cats = db.list_categories(Some(TransactionType::Income)).unwrap(); let txns = vec![ NewTransaction { amount: 45.0, transaction_type: TransactionType::Expense, category_id: cats[0].id, currency: "USD".to_string(), exchange_rate: 1.0, note: Some("Groceries".to_string()), date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), recurring_id: None, payee: None, }, NewTransaction { amount: 12.50, transaction_type: TransactionType::Expense, category_id: cats[1].id, currency: "USD".to_string(), exchange_rate: 1.0, note: Some("Coffee".to_string()), date: NaiveDate::from_ymd_opt(2026, 3, 5).unwrap(), recurring_id: None, payee: None, }, NewTransaction { amount: 3000.0, transaction_type: TransactionType::Income, category_id: income_cats[0].id, currency: "USD".to_string(), exchange_rate: 1.0, note: Some("Salary".to_string()), date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), recurring_id: None, payee: None, }, ]; for txn in &txns { db.insert_transaction(txn).unwrap(); } db.set_budget(cats[0].id, "2026-03", 200.0, false).unwrap(); let tmp = std::env::temp_dir().join("outlay_test_report.pdf"); generate_monthly_report(&db, 2026, 3, "USD", &tmp).unwrap(); assert!(tmp.exists()); let metadata = std::fs::metadata(&tmp).unwrap(); assert!(metadata.len() > 100); // PDF should be non-trivial size std::fs::remove_file(&tmp).ok(); } #[test] fn test_pdf_report_empty_month() { if find_font_dir().is_none() { eprintln!("Skipping PDF test - Liberation Sans fonts not found"); return; } let db = setup_db(); let tmp = std::env::temp_dir().join("outlay_test_empty_report.pdf"); generate_monthly_report(&db, 2026, 1, "USD", &tmp).unwrap(); assert!(tmp.exists()); std::fs::remove_file(&tmp).ok(); } }