Add PDF monthly report generation
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user