Add PDF monthly report generation

This commit is contained in:
2026-03-02 00:51:31 +02:00
parent e53301421e
commit 341e31ed3b

View File

@@ -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<rusqlite::Error> for PdfError {
fn from(e: rusqlite::Error) -> Self {
PdfError::Db(e)
}
}
impl From<genpdf::error::Error> 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<i64, (String, f64)> =
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();
}
}