koprogo_api/domain/services/
owner_statement_exporter.rs

1use crate::domain::entities::{Building, Expense, Owner, Unit};
2use chrono::{DateTime, Utc};
3use printpdf::*;
4use std::io::BufWriter;
5
6/// Owner Financial Statement Exporter - Generates PDF for Relevé de Charges
7///
8/// Generates statements showing an owner's expenses over a period.
9pub struct OwnerStatementExporter;
10
11#[derive(Debug, Clone)]
12pub struct UnitWithOwnership {
13    pub unit: Unit,
14    pub ownership_percentage: f64, // 0.0 to 1.0
15}
16
17impl OwnerStatementExporter {
18    /// Export owner financial statement to PDF bytes
19    ///
20    /// Generates a Relevé de Charges including:
21    /// - Owner information
22    /// - Period covered
23    /// - Units owned with percentages
24    /// - Expense breakdown by category
25    /// - Payment status
26    /// - Total due
27    pub fn export_to_pdf(
28        owner: &Owner,
29        building: &Building,
30        units: &[UnitWithOwnership],
31        expenses: &[Expense],
32        start_date: DateTime<Utc>,
33        end_date: DateTime<Utc>,
34    ) -> Result<Vec<u8>, String> {
35        // Create PDF document (A4: 210mm x 297mm)
36        let (doc, page1, layer1) =
37            PdfDocument::new("Relevé de Charges", Mm(210.0), Mm(297.0), "Layer 1");
38        let current_layer = doc.get_page(page1).get_layer(layer1);
39
40        // Load fonts
41        let font = doc
42            .add_builtin_font(BuiltinFont::Helvetica)
43            .map_err(|e| e.to_string())?;
44        let font_bold = doc
45            .add_builtin_font(BuiltinFont::HelveticaBold)
46            .map_err(|e| e.to_string())?;
47
48        let mut y = 270.0; // Start from top
49
50        // === HEADER ===
51        current_layer.use_text(
52            "RELEVÉ DE CHARGES".to_string(),
53            18.0,
54            Mm(20.0),
55            Mm(y),
56            &font_bold,
57        );
58        y -= 15.0;
59
60        // Building information
61        current_layer.use_text(
62            format!("Copropriété: {}", building.name),
63            12.0,
64            Mm(20.0),
65            Mm(y),
66            &font_bold,
67        );
68        y -= 7.0;
69
70        current_layer.use_text(
71            format!("Adresse: {}", building.address),
72            10.0,
73            Mm(20.0),
74            Mm(y),
75            &font,
76        );
77        y -= 10.0;
78
79        // Period
80        let period = format!(
81            "Période: du {} au {}",
82            start_date.format("%d/%m/%Y"),
83            end_date.format("%d/%m/%Y")
84        );
85        current_layer.use_text(period, 10.0, Mm(20.0), Mm(y), &font);
86        y -= 10.0;
87
88        // Owner information
89        current_layer.use_text(
90            "COPROPRIÉTAIRE".to_string(),
91            14.0,
92            Mm(20.0),
93            Mm(y),
94            &font_bold,
95        );
96        y -= 8.0;
97
98        current_layer.use_text(
99            format!("{} {}", owner.first_name, owner.last_name),
100            11.0,
101            Mm(20.0),
102            Mm(y),
103            &font,
104        );
105        y -= 6.0;
106
107        current_layer.use_text(
108            format!("Email: {}", owner.email),
109            10.0,
110            Mm(20.0),
111            Mm(y),
112            &font,
113        );
114        y -= 6.0;
115
116        if let Some(ref phone) = owner.phone {
117            current_layer.use_text(
118                format!("Téléphone: {}", phone),
119                10.0,
120                Mm(20.0),
121                Mm(y),
122                &font,
123            );
124            y -= 6.0;
125        }
126        y -= 5.0;
127
128        // === UNITS OWNED ===
129        current_layer.use_text(
130            "LOTS DÉTENUS".to_string(),
131            14.0,
132            Mm(20.0),
133            Mm(y),
134            &font_bold,
135        );
136        y -= 8.0;
137
138        current_layer.use_text("Lot", 10.0, Mm(20.0), Mm(y), &font_bold);
139        current_layer.use_text("Étage", 10.0, Mm(60.0), Mm(y), &font_bold);
140        current_layer.use_text("Surface", 10.0, Mm(90.0), Mm(y), &font_bold);
141        current_layer.use_text("Quote-part", 10.0, Mm(130.0), Mm(y), &font_bold);
142        y -= 6.0;
143
144        for unit_info in units {
145            if y < 100.0 {
146                // Reserve space for totals
147                break;
148            }
149
150            current_layer.use_text(&unit_info.unit.unit_number, 9.0, Mm(20.0), Mm(y), &font);
151
152            if let Some(floor) = unit_info.unit.floor {
153                current_layer.use_text(floor.to_string(), 9.0, Mm(60.0), Mm(y), &font);
154            }
155
156            current_layer.use_text(
157                format!("{:.2} m²", unit_info.unit.surface_area),
158                9.0,
159                Mm(90.0),
160                Mm(y),
161                &font,
162            );
163
164            current_layer.use_text(
165                format!("{:.2}%", unit_info.ownership_percentage * 100.0),
166                9.0,
167                Mm(130.0),
168                Mm(y),
169                &font,
170            );
171            y -= 5.0;
172        }
173        y -= 8.0;
174
175        // === EXPENSES ===
176        current_layer.use_text(
177            "DÉTAIL DES CHARGES".to_string(),
178            14.0,
179            Mm(20.0),
180            Mm(y),
181            &font_bold,
182        );
183        y -= 8.0;
184
185        current_layer.use_text("Date", 10.0, Mm(20.0), Mm(y), &font_bold);
186        current_layer.use_text("Description", 10.0, Mm(50.0), Mm(y), &font_bold);
187        current_layer.use_text("Montant", 10.0, Mm(140.0), Mm(y), &font_bold);
188        current_layer.use_text("Statut", 10.0, Mm(170.0), Mm(y), &font_bold);
189        y -= 6.0;
190
191        let mut total_amount = 0.0;
192        let mut total_paid = 0.0;
193
194        for expense in expenses {
195            if y < 50.0 {
196                // Reserve space for footer
197                break;
198            }
199
200            current_layer.use_text(
201                expense.expense_date.format("%d/%m/%Y").to_string(),
202                9.0,
203                Mm(20.0),
204                Mm(y),
205                &font,
206            );
207
208            let description = if expense.description.len() > 30 {
209                format!("{}...", &expense.description[..30])
210            } else {
211                expense.description.clone()
212            };
213            current_layer.use_text(description, 9.0, Mm(50.0), Mm(y), &font);
214
215            current_layer.use_text(
216                format!("{:.2} €", expense.amount),
217                9.0,
218                Mm(140.0),
219                Mm(y),
220                &font,
221            );
222
223            let status = if expense.is_paid() {
224                "Payée"
225            } else {
226                "En attente"
227            };
228            current_layer.use_text(status.to_string(), 9.0, Mm(170.0), Mm(y), &font);
229
230            total_amount += expense.amount;
231            if expense.is_paid() {
232                total_paid += expense.amount;
233            }
234
235            y -= 5.0;
236        }
237        y -= 10.0;
238
239        // === SUMMARY ===
240        current_layer.use_text(
241            "RÉCAPITULATIF".to_string(),
242            14.0,
243            Mm(20.0),
244            Mm(y),
245            &font_bold,
246        );
247        y -= 8.0;
248
249        current_layer.use_text(
250            format!("Total des charges: {:.2} €", total_amount),
251            11.0,
252            Mm(20.0),
253            Mm(y),
254            &font,
255        );
256        y -= 6.0;
257
258        current_layer.use_text(
259            format!("Montant payé: {:.2} €", total_paid),
260            11.0,
261            Mm(20.0),
262            Mm(y),
263            &font,
264        );
265        y -= 6.0;
266
267        let amount_due = total_amount - total_paid;
268        current_layer.use_text(
269            format!("Montant dû: {:.2} €", amount_due),
270            12.0,
271            Mm(20.0),
272            Mm(y),
273            &font_bold,
274        );
275        y -= 10.0;
276
277        // Payment instructions
278        if amount_due > 0.0 {
279            current_layer.use_text(
280                "Modalités de paiement:".to_string(),
281                10.0,
282                Mm(20.0),
283                Mm(y),
284                &font_bold,
285            );
286            y -= 6.0;
287
288            current_layer.use_text(
289                "Merci d'effectuer votre paiement par virement bancaire".to_string(),
290                9.0,
291                Mm(20.0),
292                Mm(y),
293                &font,
294            );
295            y -= 5.0;
296
297            current_layer.use_text(
298                "avec la référence suivante en communication.".to_string(),
299                9.0,
300                Mm(20.0),
301                Mm(y),
302                &font,
303            );
304        }
305
306        // Save to bytes
307        let mut buffer = Vec::new();
308        doc.save(&mut BufWriter::new(&mut buffer))
309            .map_err(|e| e.to_string())?;
310
311        Ok(buffer)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::domain::entities::ExpenseCategory;
319    use uuid::Uuid;
320
321    #[test]
322    fn test_export_owner_statement_pdf() {
323        let owner = Owner {
324            id: Uuid::new_v4(),
325            organization_id: Uuid::new_v4(),
326            user_id: None,
327            first_name: "Jean".to_string(),
328            last_name: "Dupont".to_string(),
329            email: "jean@example.com".to_string(),
330            phone: Some("+32 2 123 45 67".to_string()),
331            address: "123 Rue de Test".to_string(),
332            city: "Bruxelles".to_string(),
333            postal_code: "1000".to_string(),
334            country: "Belgium".to_string(),
335            created_at: Utc::now(),
336            updated_at: Utc::now(),
337        };
338
339        let building = Building {
340            id: Uuid::new_v4(),
341            name: "Les Jardins de Bruxelles".to_string(),
342            address: "123 Avenue Louise".to_string(),
343            city: "Bruxelles".to_string(),
344            postal_code: "1000".to_string(),
345            country: "Belgium".to_string(),
346            total_units: 10,
347            total_tantiemes: 1000,
348            construction_year: Some(1990),
349            syndic_name: None,
350            syndic_email: None,
351            syndic_phone: None,
352            syndic_address: None,
353            syndic_office_hours: None,
354            syndic_emergency_contact: None,
355            slug: None,
356            organization_id: owner.organization_id,
357            created_at: Utc::now(),
358            updated_at: Utc::now(),
359        };
360
361        let unit = Unit {
362            id: Uuid::new_v4(),
363            organization_id: building.organization_id,
364            building_id: building.id,
365            unit_number: "A1".to_string(),
366            unit_type: crate::domain::entities::UnitType::Apartment,
367            floor: Some(1),
368            surface_area: 75.5,
369            quota: 150.0,
370            owner_id: None,
371            created_at: Utc::now(),
372            updated_at: Utc::now(),
373        };
374
375        let units = vec![UnitWithOwnership {
376            unit,
377            ownership_percentage: 0.15, // 15%
378        }];
379
380        let expenses = vec![Expense {
381            id: Uuid::new_v4(),
382            building_id: building.id,
383            organization_id: building.organization_id,
384            description: "Entretien ascenseur".to_string(),
385            amount: 150.0,
386            amount_excl_vat: Some(123.97),
387            vat_rate: Some(21.0),
388            vat_amount: Some(26.03),
389            amount_incl_vat: Some(150.0),
390            expense_date: Utc::now(),
391            invoice_date: None,
392            due_date: None,
393            paid_date: None,
394            category: ExpenseCategory::Maintenance,
395            approval_status: crate::domain::entities::ApprovalStatus::Approved,
396            submitted_at: None,
397            approved_by: None,
398            approved_at: None,
399            rejection_reason: None,
400            payment_status: crate::domain::entities::PaymentStatus::Pending,
401            supplier: None,
402            invoice_number: Some("INV-001".to_string()),
403            account_code: None,
404            created_at: Utc::now(),
405            updated_at: Utc::now(),
406        }];
407
408        let result = OwnerStatementExporter::export_to_pdf(
409            &owner,
410            &building,
411            &units,
412            &expenses,
413            Utc::now() - chrono::Duration::days(30),
414            Utc::now(),
415        );
416
417        assert!(result.is_ok());
418        let pdf_bytes = result.unwrap();
419        assert!(!pdf_bytes.is_empty());
420        assert!(pdf_bytes.len() > 100);
421    }
422}