Skip to main content

koprogo_api/domain/services/
annual_report_exporter.rs

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