Skip to main content

koprogo_api/domain/services/
annual_report_exporter.rs

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