koprogo_api/domain/services/
annual_report_exporter.rs

1use crate::domain::entities::{Building, Expense, ExpenseCategory};
2use chrono::Utc;
3use printpdf::*;
4use std::collections::HashMap;
5use std::io::BufWriter;
6
7/// Annual Financial Report Exporter - Generates PDF for Rapport Financier Annuel
8///
9/// Generates comprehensive annual financial reports with expense breakdowns.
10pub struct AnnualReportExporter;
11
12#[derive(Debug, Clone)]
13pub struct BudgetItem {
14    pub category: ExpenseCategory,
15    pub budgeted: f64,
16    pub actual: f64,
17}
18
19impl AnnualReportExporter {
20    /// Export annual financial report to PDF bytes
21    ///
22    /// Generates a Rapport Financier Annuel including:
23    /// - Building information
24    /// - Year summary
25    /// - Income breakdown (charges paid)
26    /// - Expense breakdown by category
27    /// - Budget vs actual
28    /// - Reserve fund status
29    pub fn export_to_pdf(
30        building: &Building,
31        year: i32,
32        expenses: &[Expense],
33        budget_items: &[BudgetItem],
34        total_income: f64,
35        reserve_fund: f64,
36    ) -> Result<Vec<u8>, String> {
37        // Create PDF document (A4: 210mm x 297mm)
38        let (doc, page1, layer1) =
39            PdfDocument::new("Rapport Financier Annuel", Mm(210.0), Mm(297.0), "Layer 1");
40        let current_layer = doc.get_page(page1).get_layer(layer1);
41
42        // Load fonts
43        let font = doc
44            .add_builtin_font(BuiltinFont::Helvetica)
45            .map_err(|e| e.to_string())?;
46        let font_bold = doc
47            .add_builtin_font(BuiltinFont::HelveticaBold)
48            .map_err(|e| e.to_string())?;
49
50        let mut y = 270.0; // Start from top
51
52        // === HEADER ===
53        current_layer.use_text(
54            "RAPPORT FINANCIER ANNUEL".to_string(),
55            18.0,
56            Mm(20.0),
57            Mm(y),
58            &font_bold,
59        );
60        y -= 15.0;
61
62        // Building information
63        current_layer.use_text(
64            format!("Copropriété: {}", building.name),
65            12.0,
66            Mm(20.0),
67            Mm(y),
68            &font_bold,
69        );
70        y -= 7.0;
71
72        current_layer.use_text(
73            format!("Adresse: {}", building.address),
74            10.0,
75            Mm(20.0),
76            Mm(y),
77            &font,
78        );
79        y -= 10.0;
80
81        current_layer.use_text(
82            format!("Exercice: {}", year),
83            12.0,
84            Mm(20.0),
85            Mm(y),
86            &font_bold,
87        );
88        y -= 10.0;
89
90        current_layer.use_text(
91            format!("Date d'établissement: {}", Utc::now().format("%d/%m/%Y")),
92            10.0,
93            Mm(20.0),
94            Mm(y),
95            &font,
96        );
97        y -= 15.0;
98
99        // === SUMMARY ===
100        current_layer.use_text(
101            "SYNTHÈSE FINANCIÈRE".to_string(),
102            14.0,
103            Mm(20.0),
104            Mm(y),
105            &font_bold,
106        );
107        y -= 8.0;
108
109        let total_expenses: f64 = expenses.iter().map(|e| e.amount).sum();
110
111        current_layer.use_text(
112            format!(
113                "Total des produits (charges perçues): {:.2} €",
114                total_income
115            ),
116            11.0,
117            Mm(20.0),
118            Mm(y),
119            &font,
120        );
121        y -= 6.0;
122
123        current_layer.use_text(
124            format!("Total des charges: {:.2} €", total_expenses),
125            11.0,
126            Mm(20.0),
127            Mm(y),
128            &font,
129        );
130        y -= 6.0;
131
132        let balance = total_income - total_expenses;
133        let balance_label = if balance >= 0.0 {
134            "Excédent"
135        } else {
136            "Déficit"
137        };
138        current_layer.use_text(
139            format!("{}: {:.2} €", balance_label, balance.abs()),
140            12.0,
141            Mm(20.0),
142            Mm(y),
143            &font_bold,
144        );
145        y -= 6.0;
146
147        current_layer.use_text(
148            format!("Fonds de réserve: {:.2} €", reserve_fund),
149            11.0,
150            Mm(20.0),
151            Mm(y),
152            &font,
153        );
154        y -= 12.0;
155
156        // === EXPENSE BREAKDOWN BY CATEGORY ===
157        current_layer.use_text(
158            "RÉPARTITION DES CHARGES PAR CATÉGORIE".to_string(),
159            14.0,
160            Mm(20.0),
161            Mm(y),
162            &font_bold,
163        );
164        y -= 8.0;
165
166        // Calculate expenses by category
167        let mut category_totals: HashMap<String, f64> = HashMap::new();
168        for expense in expenses {
169            let category_name = Self::category_name(&expense.category);
170            *category_totals.entry(category_name).or_insert(0.0) += expense.amount;
171        }
172
173        // Sort categories by amount (descending)
174        let mut sorted_categories: Vec<_> = category_totals.iter().collect();
175        sorted_categories.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap());
176
177        // Table header
178        current_layer.use_text("Catégorie", 10.0, Mm(20.0), Mm(y), &font_bold);
179        current_layer.use_text("Montant", 10.0, Mm(120.0), Mm(y), &font_bold);
180        current_layer.use_text("% Total", 10.0, Mm(160.0), Mm(y), &font_bold);
181        y -= 6.0;
182
183        for (category, amount) in sorted_categories {
184            if y < 100.0 {
185                // Reserve space for budget comparison
186                break;
187            }
188
189            let percentage = if total_expenses > 0.0 {
190                (amount / total_expenses) * 100.0
191            } else {
192                0.0
193            };
194
195            current_layer.use_text(category.clone(), 9.0, Mm(20.0), Mm(y), &font);
196            current_layer.use_text(format!("{:.2} €", amount), 9.0, Mm(120.0), Mm(y), &font);
197            current_layer.use_text(format!("{:.1}%", percentage), 9.0, Mm(160.0), Mm(y), &font);
198            y -= 5.0;
199        }
200        y -= 10.0;
201
202        // === BUDGET VS ACTUAL ===
203        current_layer.use_text(
204            "COMPARAISON BUDGET / RÉALISÉ".to_string(),
205            14.0,
206            Mm(20.0),
207            Mm(y),
208            &font_bold,
209        );
210        y -= 8.0;
211
212        // Table header
213        current_layer.use_text("Catégorie", 10.0, Mm(20.0), Mm(y), &font_bold);
214        current_layer.use_text("Budget", 10.0, Mm(100.0), Mm(y), &font_bold);
215        current_layer.use_text("Réalisé", 10.0, Mm(130.0), Mm(y), &font_bold);
216        current_layer.use_text("Écart", 10.0, Mm(160.0), Mm(y), &font_bold);
217        y -= 6.0;
218
219        let mut total_budgeted = 0.0;
220        let mut total_actual = 0.0;
221
222        for item in budget_items {
223            if y < 50.0 {
224                // Reserve space for signatures
225                break;
226            }
227
228            let category_name = Self::category_name(&item.category);
229            let variance = item.budgeted - item.actual;
230            let variance_sign = if variance >= 0.0 { "+" } else { "" };
231
232            current_layer.use_text(category_name, 9.0, Mm(20.0), Mm(y), &font);
233            current_layer.use_text(
234                format!("{:.2} €", item.budgeted),
235                9.0,
236                Mm(100.0),
237                Mm(y),
238                &font,
239            );
240            current_layer.use_text(
241                format!("{:.2} €", item.actual),
242                9.0,
243                Mm(130.0),
244                Mm(y),
245                &font,
246            );
247            current_layer.use_text(
248                format!("{}{:.2} €", variance_sign, variance),
249                9.0,
250                Mm(160.0),
251                Mm(y),
252                &font,
253            );
254
255            total_budgeted += item.budgeted;
256            total_actual += item.actual;
257            y -= 5.0;
258        }
259        y -= 3.0;
260
261        // Totals line
262        current_layer.use_text("TOTAL", 10.0, Mm(20.0), Mm(y), &font_bold);
263        current_layer.use_text(
264            format!("{:.2} €", total_budgeted),
265            10.0,
266            Mm(100.0),
267            Mm(y),
268            &font_bold,
269        );
270        current_layer.use_text(
271            format!("{:.2} €", total_actual),
272            10.0,
273            Mm(130.0),
274            Mm(y),
275            &font_bold,
276        );
277
278        let total_variance = total_budgeted - total_actual;
279        let total_variance_sign = if total_variance >= 0.0 { "+" } else { "" };
280        current_layer.use_text(
281            format!("{}{:.2} €", total_variance_sign, total_variance),
282            10.0,
283            Mm(160.0),
284            Mm(y),
285            &font_bold,
286        );
287        y -= 15.0;
288
289        // === SIGNATURES ===
290        if y < 40.0 {
291            y = 40.0;
292        }
293
294        current_layer.use_text("SIGNATURES".to_string(), 12.0, Mm(20.0), Mm(y), &font_bold);
295        y -= 10.0;
296
297        current_layer.use_text(
298            "Le Syndic: ________________".to_string(),
299            10.0,
300            Mm(20.0),
301            Mm(y),
302            &font,
303        );
304
305        current_layer.use_text(
306            "Le Trésorier: ________________".to_string(),
307            10.0,
308            Mm(120.0),
309            Mm(y),
310            &font,
311        );
312        y -= 6.0;
313
314        current_layer.use_text(
315            "Date: ________________".to_string(),
316            10.0,
317            Mm(20.0),
318            Mm(y),
319            &font,
320        );
321
322        // Save to bytes
323        let mut buffer = Vec::new();
324        doc.save(&mut BufWriter::new(&mut buffer))
325            .map_err(|e| e.to_string())?;
326
327        Ok(buffer)
328    }
329
330    fn category_name(category: &ExpenseCategory) -> String {
331        match category {
332            ExpenseCategory::Maintenance => "Entretien".to_string(),
333            ExpenseCategory::Utilities => "Charges courantes".to_string(),
334            ExpenseCategory::Insurance => "Assurances".to_string(),
335            ExpenseCategory::Repairs => "Réparations".to_string(),
336            ExpenseCategory::Administration => "Administration".to_string(),
337            ExpenseCategory::Cleaning => "Nettoyage".to_string(),
338            ExpenseCategory::Works => "Travaux".to_string(),
339            ExpenseCategory::Other => "Autres".to_string(),
340        }
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::domain::entities::ApprovalStatus;
348    use uuid::Uuid;
349
350    #[test]
351    fn test_export_annual_report_pdf() {
352        let building = Building {
353            id: Uuid::new_v4(),
354            name: "Les Jardins de Bruxelles".to_string(),
355            address: "123 Avenue Louise".to_string(),
356            city: "Bruxelles".to_string(),
357            postal_code: "1000".to_string(),
358            country: "Belgium".to_string(),
359            total_units: 10,
360            total_tantiemes: 1000,
361            construction_year: Some(1990),
362            syndic_name: None,
363            syndic_email: None,
364            syndic_phone: None,
365            syndic_address: None,
366            syndic_office_hours: None,
367            syndic_emergency_contact: None,
368            slug: None,
369            organization_id: Uuid::new_v4(),
370            created_at: Utc::now(),
371            updated_at: Utc::now(),
372        };
373
374        let expenses = vec![
375            Expense {
376                id: Uuid::new_v4(),
377                building_id: building.id,
378                organization_id: building.organization_id,
379                description: "Entretien ascenseur".to_string(),
380                amount: 1500.0,
381                amount_excl_vat: Some(1239.67),
382                vat_rate: Some(21.0),
383                vat_amount: Some(260.33),
384                amount_incl_vat: Some(1500.0),
385                expense_date: Utc::now(),
386                invoice_date: None,
387                due_date: None,
388                paid_date: Some(Utc::now()),
389                category: ExpenseCategory::Maintenance,
390                approval_status: ApprovalStatus::Approved,
391                submitted_at: None,
392                approved_by: None,
393                approved_at: None,
394                rejection_reason: None,
395                payment_status: crate::domain::entities::PaymentStatus::Paid,
396                supplier: None,
397                invoice_number: Some("INV-001".to_string()),
398                account_code: None,
399                created_at: Utc::now(),
400                updated_at: Utc::now(),
401            },
402            Expense {
403                id: Uuid::new_v4(),
404                building_id: building.id,
405                organization_id: building.organization_id,
406                description: "Électricité parties communes".to_string(),
407                amount: 800.0,
408                amount_excl_vat: Some(661.16),
409                vat_rate: Some(21.0),
410                vat_amount: Some(138.84),
411                amount_incl_vat: Some(800.0),
412                expense_date: Utc::now(),
413                invoice_date: None,
414                due_date: None,
415                paid_date: Some(Utc::now()),
416                category: ExpenseCategory::Utilities,
417                approval_status: ApprovalStatus::Approved,
418                submitted_at: None,
419                approved_by: None,
420                approved_at: None,
421                rejection_reason: None,
422                payment_status: crate::domain::entities::PaymentStatus::Paid,
423                supplier: None,
424                invoice_number: Some("INV-002".to_string()),
425                account_code: None,
426                created_at: Utc::now(),
427                updated_at: Utc::now(),
428            },
429        ];
430
431        let budget_items = vec![
432            BudgetItem {
433                category: ExpenseCategory::Maintenance,
434                budgeted: 2000.0,
435                actual: 1500.0,
436            },
437            BudgetItem {
438                category: ExpenseCategory::Utilities,
439                budgeted: 1000.0,
440                actual: 800.0,
441            },
442        ];
443
444        let result = AnnualReportExporter::export_to_pdf(
445            &building,
446            2025,
447            &expenses,
448            &budget_items,
449            3000.0, // Total income
450            5000.0, // Reserve fund
451        );
452
453        assert!(result.is_ok());
454        let pdf_bytes = result.unwrap();
455        assert!(!pdf_bytes.is_empty());
456        assert!(pdf_bytes.len() > 100);
457    }
458}