Skip to main content

koprogo_api/domain/services/
owner_statement_exporter.rs

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