diff --git a/outlay-core/src/export_pdf.rs b/outlay-core/src/export_pdf.rs index e69de29..a5612bd 100644 --- a/outlay-core/src/export_pdf.rs +++ b/outlay-core/src/export_pdf.rs @@ -0,0 +1,370 @@ +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); + + // Title + 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)); + + // Fetch data + 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; + + // Category breakdown + 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; + + // Summary section + 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)); + + // Category breakdown table + 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)); + + // Header + 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)); + } + + // Top 5 expenses + 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)); + } + + // Budget vs actual + 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); + } + + // Footer + 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(); + + // Add sample transactions + 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, + }, + 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, + }, + 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, + }, + ]; + + for txn in &txns { + db.insert_transaction(txn).unwrap(); + } + + // Set a budget + db.set_budget(cats[0].id, "2026-03", 200.0).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 + + // Cleanup + 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(); + } +}