Skip to main content

koprogo_api/domain/services/
owner_statement_exporter.rs

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