koprogo_api/domain/entities/
expense.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Catégorie de charges
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub enum ExpenseCategory {
8    Maintenance,    // Entretien
9    Repairs,        // Réparations
10    Insurance,      // Assurance
11    Utilities,      // Charges courantes (eau, électricité)
12    Cleaning,       // Nettoyage
13    Administration, // Administration
14    Works,          // Travaux
15    Other,
16}
17
18/// Statut de paiement
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20#[serde(rename_all = "snake_case")]
21pub enum PaymentStatus {
22    Pending,
23    Paid,
24    Overdue,
25    Cancelled,
26}
27
28/// Statut d'approbation pour le workflow de validation
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30#[serde(rename_all = "snake_case")]
31pub enum ApprovalStatus {
32    Draft,           // Brouillon - en cours d'édition
33    PendingApproval, // Soumis pour validation
34    Approved,        // Approuvé par le syndic
35    Rejected,        // Rejeté
36}
37
38/// Représente une charge de copropriété / facture
39///
40/// Conforme au PCMN belge (Plan Comptable Minimum Normalisé).
41/// Chaque charge peut être liée à un compte comptable via account_code.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct Expense {
44    pub id: Uuid,
45    pub organization_id: Uuid,
46    pub building_id: Uuid,
47    pub category: ExpenseCategory,
48    pub description: String,
49
50    // Montants et TVA
51    pub amount: f64,                  // Montant TTC (backward compatibility)
52    pub amount_excl_vat: Option<f64>, // Montant HT
53    pub vat_rate: Option<f64>,        // Taux TVA (ex: 21.0 pour 21%)
54    pub vat_amount: Option<f64>,      // Montant TVA
55    pub amount_incl_vat: Option<f64>, // Montant TTC (explicite)
56
57    // Dates multiples
58    pub expense_date: DateTime<Utc>, // Date originale (backward compatibility)
59    pub invoice_date: Option<DateTime<Utc>>, // Date de la facture
60    pub due_date: Option<DateTime<Utc>>, // Date d'échéance
61    pub paid_date: Option<DateTime<Utc>>, // Date de paiement effectif
62
63    // Workflow de validation
64    pub approval_status: ApprovalStatus,
65    pub submitted_at: Option<DateTime<Utc>>, // Date de soumission pour validation
66    pub approved_by: Option<Uuid>,           // User ID qui a approuvé/rejeté
67    pub approved_at: Option<DateTime<Utc>>,  // Date d'approbation/rejet
68    pub rejection_reason: Option<String>,    // Raison du rejet
69
70    // Statut et métadonnées
71    pub payment_status: PaymentStatus,
72    pub supplier: Option<String>,
73    pub invoice_number: Option<String>,
74    /// Code du compte comptable PCMN (e.g., "604001" for electricity, "611002" for elevator maintenance)
75    /// References: accounts.code column in the database
76    pub account_code: Option<String>,
77    /// Link to contractor report for work expenses (Issue #309)
78    /// Required for category = Works before approval
79    pub contractor_report_id: Option<Uuid>,
80    pub created_at: DateTime<Utc>,
81    pub updated_at: DateTime<Utc>,
82}
83
84impl Expense {
85    #[allow(clippy::too_many_arguments)]
86    pub fn new(
87        organization_id: Uuid,
88        building_id: Uuid,
89        category: ExpenseCategory,
90        description: String,
91        amount: f64,
92        expense_date: DateTime<Utc>,
93        supplier: Option<String>,
94        invoice_number: Option<String>,
95        account_code: Option<String>,
96    ) -> Result<Self, String> {
97        if description.is_empty() {
98            return Err("Description cannot be empty".to_string());
99        }
100        if amount <= 0.0 {
101            return Err("Amount must be greater than 0".to_string());
102        }
103
104        // Validate account_code format if provided (Belgian PCMN codes)
105        if let Some(ref code) = account_code {
106            if code.is_empty() {
107                return Err("Account code cannot be empty if provided".to_string());
108            }
109            // Belgian PCMN codes are typically 1-10 characters (e.g., "6", "60", "604001")
110            if code.len() > 40 {
111                return Err("Account code cannot exceed 40 characters".to_string());
112            }
113        }
114
115        let now = Utc::now();
116        Ok(Self {
117            id: Uuid::new_v4(),
118            organization_id,
119            building_id,
120            category,
121            description,
122            amount,
123            amount_excl_vat: None,
124            vat_rate: None,
125            vat_amount: None,
126            amount_incl_vat: Some(amount), // Pour compatibilité, amount = TTC
127            expense_date,
128            invoice_date: None,
129            due_date: None,
130            paid_date: None,
131            approval_status: ApprovalStatus::Draft, // Par défaut en brouillon
132            submitted_at: None,
133            approved_by: None,
134            approved_at: None,
135            rejection_reason: None,
136            payment_status: PaymentStatus::Pending,
137            supplier,
138            invoice_number,
139            account_code,
140            contractor_report_id: None,
141            created_at: now,
142            updated_at: now,
143        })
144    }
145
146    /// Crée une facture avec gestion complète de la TVA
147    #[allow(clippy::too_many_arguments)]
148    pub fn new_with_vat(
149        organization_id: Uuid,
150        building_id: Uuid,
151        category: ExpenseCategory,
152        description: String,
153        amount_excl_vat: f64,
154        vat_rate: f64,
155        invoice_date: DateTime<Utc>,
156        due_date: Option<DateTime<Utc>>,
157        supplier: Option<String>,
158        invoice_number: Option<String>,
159        account_code: Option<String>,
160    ) -> Result<Self, String> {
161        if description.is_empty() {
162            return Err("Description cannot be empty".to_string());
163        }
164        if amount_excl_vat <= 0.0 {
165            return Err("Amount (excl. VAT) must be greater than 0".to_string());
166        }
167        if !(0.0..=100.0).contains(&vat_rate) {
168            return Err("VAT rate must be between 0 and 100".to_string());
169        }
170
171        // Calcul automatique de la TVA
172        let vat_amount = (amount_excl_vat * vat_rate) / 100.0;
173        let amount_incl_vat = amount_excl_vat + vat_amount;
174
175        let now = Utc::now();
176        Ok(Self {
177            id: Uuid::new_v4(),
178            organization_id,
179            building_id,
180            category,
181            description,
182            amount: amount_incl_vat, // Backward compatibility
183            amount_excl_vat: Some(amount_excl_vat),
184            vat_rate: Some(vat_rate),
185            vat_amount: Some(vat_amount),
186            amount_incl_vat: Some(amount_incl_vat),
187            expense_date: invoice_date, // Backward compatibility
188            invoice_date: Some(invoice_date),
189            due_date,
190            paid_date: None,
191            approval_status: ApprovalStatus::Draft,
192            submitted_at: None,
193            approved_by: None,
194            approved_at: None,
195            rejection_reason: None,
196            payment_status: PaymentStatus::Pending,
197            supplier,
198            invoice_number,
199            account_code,
200            contractor_report_id: None,
201            created_at: now,
202            updated_at: now,
203        })
204    }
205
206    /// Recalcule la TVA si le montant HT ou le taux change
207    pub fn recalculate_vat(&mut self) -> Result<(), String> {
208        if let (Some(amount_excl_vat), Some(vat_rate)) = (self.amount_excl_vat, self.vat_rate) {
209            if amount_excl_vat <= 0.0 {
210                return Err("Amount (excl. VAT) must be greater than 0".to_string());
211            }
212            if !(0.0..=100.0).contains(&vat_rate) {
213                return Err("VAT rate must be between 0 and 100".to_string());
214            }
215
216            let vat_amount = (amount_excl_vat * vat_rate) / 100.0;
217            let amount_incl_vat = amount_excl_vat + vat_amount;
218
219            self.vat_amount = Some(vat_amount);
220            self.amount_incl_vat = Some(amount_incl_vat);
221            self.amount = amount_incl_vat; // Backward compatibility
222            self.updated_at = Utc::now();
223            Ok(())
224        } else {
225            Err("Cannot recalculate VAT: amount_excl_vat or vat_rate is missing".to_string())
226        }
227    }
228
229    /// Soumet la facture pour validation (Draft → PendingApproval)
230    pub fn submit_for_approval(&mut self) -> Result<(), String> {
231        match self.approval_status {
232            ApprovalStatus::Draft => {
233                self.approval_status = ApprovalStatus::PendingApproval;
234                self.submitted_at = Some(Utc::now());
235                self.updated_at = Utc::now();
236                Ok(())
237            }
238            ApprovalStatus::Rejected => {
239                // Permet de re-soumettre une facture rejetée
240                self.approval_status = ApprovalStatus::PendingApproval;
241                self.submitted_at = Some(Utc::now());
242                self.rejection_reason = None; // Efface la raison du rejet précédent
243                self.updated_at = Utc::now();
244                Ok(())
245            }
246            ApprovalStatus::PendingApproval => {
247                Err("Invoice is already pending approval".to_string())
248            }
249            ApprovalStatus::Approved => Err("Cannot submit an approved invoice".to_string()),
250        }
251    }
252
253    /// Approuve la facture (PendingApproval → Approved)
254    /// Pour les charges de type "Works", une référence à un rapport contracteur validé est obligatoire
255    pub fn approve(&mut self, approved_by_user_id: Uuid) -> Result<(), String> {
256        // Issue #309: Validation work order chain - Works expenses must have a contractor report
257        if self.category == ExpenseCategory::Works && self.contractor_report_id.is_none() {
258            return Err(
259                "Work expenses require a validated contractor report before approval".to_string(),
260            );
261        }
262
263        match self.approval_status {
264            ApprovalStatus::PendingApproval => {
265                self.approval_status = ApprovalStatus::Approved;
266                self.approved_by = Some(approved_by_user_id);
267                self.approved_at = Some(Utc::now());
268                self.updated_at = Utc::now();
269                Ok(())
270            }
271            ApprovalStatus::Draft => {
272                Err("Cannot approve a draft invoice (must be submitted first)".to_string())
273            }
274            ApprovalStatus::Approved => Err("Invoice is already approved".to_string()),
275            ApprovalStatus::Rejected => {
276                Err("Cannot approve a rejected invoice (resubmit first)".to_string())
277            }
278        }
279    }
280
281    /// Rejette la facture avec une raison (PendingApproval → Rejected)
282    pub fn reject(&mut self, rejected_by_user_id: Uuid, reason: String) -> Result<(), String> {
283        if reason.trim().is_empty() {
284            return Err("Rejection reason cannot be empty".to_string());
285        }
286
287        match self.approval_status {
288            ApprovalStatus::PendingApproval => {
289                self.approval_status = ApprovalStatus::Rejected;
290                self.approved_by = Some(rejected_by_user_id); // Celui qui a rejeté
291                self.approved_at = Some(Utc::now());
292                self.rejection_reason = Some(reason);
293                self.updated_at = Utc::now();
294                Ok(())
295            }
296            ApprovalStatus::Draft => {
297                Err("Cannot reject a draft invoice (not submitted)".to_string())
298            }
299            ApprovalStatus::Approved => Err("Cannot reject an approved invoice".to_string()),
300            ApprovalStatus::Rejected => Err("Invoice is already rejected".to_string()),
301        }
302    }
303
304    /// Vérifie si la facture peut être modifiée (uniquement en Draft ou Rejected)
305    pub fn can_be_modified(&self) -> bool {
306        matches!(
307            self.approval_status,
308            ApprovalStatus::Draft | ApprovalStatus::Rejected
309        )
310    }
311
312    /// Vérifie si la facture est approuvée
313    pub fn is_approved(&self) -> bool {
314        self.approval_status == ApprovalStatus::Approved
315    }
316
317    pub fn mark_as_paid(&mut self) -> Result<(), String> {
318        // Validation critique : une facture ne peut être payée que si elle est approuvée
319        if self.approval_status != ApprovalStatus::Approved {
320            return Err(format!(
321                "Cannot mark expense as paid: invoice must be approved first (current status: {:?})",
322                self.approval_status
323            ));
324        }
325
326        match self.payment_status {
327            PaymentStatus::Pending | PaymentStatus::Overdue => {
328                self.payment_status = PaymentStatus::Paid;
329                self.paid_date = Some(Utc::now()); // Enregistre la date de paiement effectif
330                self.updated_at = Utc::now();
331                Ok(())
332            }
333            PaymentStatus::Paid => Err("Expense is already paid".to_string()),
334            PaymentStatus::Cancelled => Err("Cannot mark a cancelled expense as paid".to_string()),
335        }
336    }
337
338    pub fn mark_as_overdue(&mut self) -> Result<(), String> {
339        match self.payment_status {
340            PaymentStatus::Pending => {
341                self.payment_status = PaymentStatus::Overdue;
342                self.updated_at = Utc::now();
343                Ok(())
344            }
345            PaymentStatus::Overdue => Err("Expense is already overdue".to_string()),
346            PaymentStatus::Paid => Err("Cannot mark a paid expense as overdue".to_string()),
347            PaymentStatus::Cancelled => {
348                Err("Cannot mark a cancelled expense as overdue".to_string())
349            }
350        }
351    }
352
353    pub fn cancel(&mut self) -> Result<(), String> {
354        match self.payment_status {
355            PaymentStatus::Pending | PaymentStatus::Overdue => {
356                self.payment_status = PaymentStatus::Cancelled;
357                self.updated_at = Utc::now();
358                Ok(())
359            }
360            PaymentStatus::Paid => Err("Cannot cancel a paid expense".to_string()),
361            PaymentStatus::Cancelled => Err("Expense is already cancelled".to_string()),
362        }
363    }
364
365    /// Set the contractor report link for work expenses (Issue #309)
366    pub fn set_contractor_report(&mut self, contractor_report_id: Uuid) -> Result<(), String> {
367        if self.category != ExpenseCategory::Works {
368            return Err(
369                "Contractor report can only be linked to Works category expenses".to_string(),
370            );
371        }
372        self.contractor_report_id = Some(contractor_report_id);
373        self.updated_at = Utc::now();
374        Ok(())
375    }
376
377    pub fn reactivate(&mut self) -> Result<(), String> {
378        match self.payment_status {
379            PaymentStatus::Cancelled => {
380                self.payment_status = PaymentStatus::Pending;
381                self.updated_at = Utc::now();
382                Ok(())
383            }
384            _ => Err("Can only reactivate cancelled expenses".to_string()),
385        }
386    }
387
388    pub fn unpay(&mut self) -> Result<(), String> {
389        match self.payment_status {
390            PaymentStatus::Paid => {
391                self.payment_status = PaymentStatus::Pending;
392                self.updated_at = Utc::now();
393                Ok(())
394            }
395            _ => Err("Can only unpay paid expenses".to_string()),
396        }
397    }
398
399    pub fn is_paid(&self) -> bool {
400        self.payment_status == PaymentStatus::Paid
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn test_create_expense_success() {
410        let org_id = Uuid::new_v4();
411        let building_id = Uuid::new_v4();
412        let expense = Expense::new(
413            org_id,
414            building_id,
415            ExpenseCategory::Maintenance,
416            "Entretien ascenseur".to_string(),
417            500.0,
418            Utc::now(),
419            Some("ACME Elevators".to_string()),
420            Some("INV-2024-001".to_string()),
421            Some("611002".to_string()), // Elevator maintenance account (Belgian PCMN)
422        );
423
424        assert!(expense.is_ok());
425        let expense = expense.unwrap();
426        assert_eq!(expense.organization_id, org_id);
427        assert_eq!(expense.amount, 500.0);
428        assert_eq!(expense.payment_status, PaymentStatus::Pending);
429        assert_eq!(expense.account_code, Some("611002".to_string()));
430    }
431
432    #[test]
433    fn test_create_expense_without_account_code() {
434        let org_id = Uuid::new_v4();
435        let building_id = Uuid::new_v4();
436        let expense = Expense::new(
437            org_id,
438            building_id,
439            ExpenseCategory::Other,
440            "Miscellaneous expense".to_string(),
441            100.0,
442            Utc::now(),
443            None,
444            None,
445            None, // No account code
446        );
447
448        assert!(expense.is_ok());
449        let expense = expense.unwrap();
450        assert_eq!(expense.account_code, None);
451    }
452
453    #[test]
454    fn test_create_expense_empty_account_code_fails() {
455        let org_id = Uuid::new_v4();
456        let building_id = Uuid::new_v4();
457        let expense = Expense::new(
458            org_id,
459            building_id,
460            ExpenseCategory::Maintenance,
461            "Test".to_string(),
462            100.0,
463            Utc::now(),
464            None,
465            None,
466            Some("".to_string()), // Empty account code should fail
467        );
468
469        assert!(expense.is_err());
470        assert!(expense
471            .unwrap_err()
472            .contains("Account code cannot be empty"));
473    }
474
475    #[test]
476    fn test_create_expense_long_account_code_fails() {
477        let org_id = Uuid::new_v4();
478        let building_id = Uuid::new_v4();
479        let long_code = "a".repeat(41); // 41 characters, exceeds limit
480        let expense = Expense::new(
481            org_id,
482            building_id,
483            ExpenseCategory::Maintenance,
484            "Test".to_string(),
485            100.0,
486            Utc::now(),
487            None,
488            None,
489            Some(long_code),
490        );
491
492        assert!(expense.is_err());
493        assert!(expense
494            .unwrap_err()
495            .contains("Account code cannot exceed 40 characters"));
496    }
497
498    #[test]
499    fn test_create_expense_negative_amount_fails() {
500        let org_id = Uuid::new_v4();
501        let building_id = Uuid::new_v4();
502        let expense = Expense::new(
503            org_id,
504            building_id,
505            ExpenseCategory::Maintenance,
506            "Test".to_string(),
507            -100.0,
508            Utc::now(),
509            None,
510            None,
511            None,
512        );
513
514        assert!(expense.is_err());
515    }
516
517    #[test]
518    fn test_mark_expense_as_paid() {
519        let org_id = Uuid::new_v4();
520        let building_id = Uuid::new_v4();
521        let syndic_id = Uuid::new_v4();
522        let mut expense = Expense::new(
523            org_id,
524            building_id,
525            ExpenseCategory::Maintenance,
526            "Test".to_string(),
527            100.0,
528            Utc::now(),
529            None,
530            None,
531            None,
532        )
533        .unwrap();
534
535        // Follow approval workflow before payment
536        expense.submit_for_approval().unwrap();
537        expense.approve(syndic_id).unwrap();
538
539        assert!(!expense.is_paid());
540        let result = expense.mark_as_paid();
541        assert!(result.is_ok());
542        assert!(expense.is_paid());
543    }
544
545    #[test]
546    fn test_mark_paid_expense_as_paid_fails() {
547        let org_id = Uuid::new_v4();
548        let building_id = Uuid::new_v4();
549        let syndic_id = Uuid::new_v4();
550        let mut expense = Expense::new(
551            org_id,
552            building_id,
553            ExpenseCategory::Maintenance,
554            "Test".to_string(),
555            100.0,
556            Utc::now(),
557            None,
558            None,
559            None,
560        )
561        .unwrap();
562
563        // Follow approval workflow before payment
564        expense.submit_for_approval().unwrap();
565        expense.approve(syndic_id).unwrap();
566
567        expense.mark_as_paid().unwrap();
568        let result = expense.mark_as_paid();
569        assert!(result.is_err());
570    }
571
572    #[test]
573    fn test_cancel_expense() {
574        let org_id = Uuid::new_v4();
575        let building_id = Uuid::new_v4();
576        let mut expense = Expense::new(
577            org_id,
578            building_id,
579            ExpenseCategory::Maintenance,
580            "Test".to_string(),
581            100.0,
582            Utc::now(),
583            None,
584            None,
585            None,
586        )
587        .unwrap();
588
589        let result = expense.cancel();
590        assert!(result.is_ok());
591        assert_eq!(expense.payment_status, PaymentStatus::Cancelled);
592    }
593
594    #[test]
595    fn test_reactivate_expense() {
596        let org_id = Uuid::new_v4();
597        let building_id = Uuid::new_v4();
598        let mut expense = Expense::new(
599            org_id,
600            building_id,
601            ExpenseCategory::Maintenance,
602            "Test".to_string(),
603            100.0,
604            Utc::now(),
605            None,
606            None,
607            None,
608        )
609        .unwrap();
610
611        expense.cancel().unwrap();
612        let result = expense.reactivate();
613        assert!(result.is_ok());
614        assert_eq!(expense.payment_status, PaymentStatus::Pending);
615    }
616
617    // ========== Tests pour gestion TVA ==========
618
619    #[test]
620    fn test_create_invoice_with_vat_success() {
621        let org_id = Uuid::new_v4();
622        let building_id = Uuid::new_v4();
623        let invoice_date = Utc::now();
624        let due_date = invoice_date + chrono::Duration::days(30);
625
626        let invoice = Expense::new_with_vat(
627            org_id,
628            building_id,
629            ExpenseCategory::Maintenance,
630            "Réparation toiture".to_string(),
631            1000.0, // HT
632            21.0,   // TVA 21%
633            invoice_date,
634            Some(due_date),
635            Some("BatiPro SPRL".to_string()),
636            Some("INV-2025-042".to_string()),
637            None, // account_code
638        );
639
640        assert!(invoice.is_ok());
641        let invoice = invoice.unwrap();
642        assert_eq!(invoice.amount_excl_vat, Some(1000.0));
643        assert_eq!(invoice.vat_rate, Some(21.0));
644        assert_eq!(invoice.vat_amount, Some(210.0));
645        assert_eq!(invoice.amount_incl_vat, Some(1210.0));
646        assert_eq!(invoice.amount, 1210.0); // Backward compatibility
647        assert_eq!(invoice.approval_status, ApprovalStatus::Draft);
648    }
649
650    #[test]
651    fn test_create_invoice_with_vat_6_percent() {
652        let org_id = Uuid::new_v4();
653        let building_id = Uuid::new_v4();
654
655        let invoice = Expense::new_with_vat(
656            org_id,
657            building_id,
658            ExpenseCategory::Works,
659            "Rénovation énergétique".to_string(),
660            5000.0, // HT
661            6.0,    // TVA réduite 6%
662            Utc::now(),
663            None,
664            None,
665            None,
666            None, // account_code
667        )
668        .unwrap();
669
670        assert_eq!(invoice.vat_amount, Some(300.0));
671        assert_eq!(invoice.amount_incl_vat, Some(5300.0));
672    }
673
674    #[test]
675    fn test_create_invoice_negative_vat_rate_fails() {
676        let org_id = Uuid::new_v4();
677        let building_id = Uuid::new_v4();
678
679        let invoice = Expense::new_with_vat(
680            org_id,
681            building_id,
682            ExpenseCategory::Maintenance,
683            "Test".to_string(),
684            100.0,
685            -5.0, // Taux négatif invalide
686            Utc::now(),
687            None,
688            None,
689            None,
690            None, // account_code
691        );
692
693        assert!(invoice.is_err());
694        assert_eq!(invoice.unwrap_err(), "VAT rate must be between 0 and 100");
695    }
696
697    #[test]
698    fn test_create_invoice_vat_rate_above_100_fails() {
699        let org_id = Uuid::new_v4();
700        let building_id = Uuid::new_v4();
701
702        let invoice = Expense::new_with_vat(
703            org_id,
704            building_id,
705            ExpenseCategory::Maintenance,
706            "Test".to_string(),
707            100.0,
708            150.0, // Taux > 100% invalide
709            Utc::now(),
710            None,
711            None,
712            None,
713            None, // account_code
714        );
715
716        assert!(invoice.is_err());
717    }
718
719    #[test]
720    fn test_recalculate_vat_success() {
721        let org_id = Uuid::new_v4();
722        let building_id = Uuid::new_v4();
723
724        let mut invoice = Expense::new_with_vat(
725            org_id,
726            building_id,
727            ExpenseCategory::Maintenance,
728            "Test".to_string(),
729            1000.0,
730            21.0,
731            Utc::now(),
732            None,
733            None,
734            None,
735            None, // account_code
736        )
737        .unwrap();
738
739        // Modifier le montant HT
740        invoice.amount_excl_vat = Some(1500.0);
741        let result = invoice.recalculate_vat();
742
743        assert!(result.is_ok());
744        assert_eq!(invoice.vat_amount, Some(315.0)); // 1500 * 21% = 315
745        assert_eq!(invoice.amount_incl_vat, Some(1815.0));
746    }
747
748    #[test]
749    fn test_recalculate_vat_without_vat_data_fails() {
750        let org_id = Uuid::new_v4();
751        let building_id = Uuid::new_v4();
752
753        // Créer une expense classique sans TVA
754        let mut expense = Expense::new(
755            org_id,
756            building_id,
757            ExpenseCategory::Maintenance,
758            "Test".to_string(),
759            100.0,
760            Utc::now(),
761            None,
762            None,
763            None, // account_code
764        )
765        .unwrap();
766
767        let result = expense.recalculate_vat();
768        assert!(result.is_err());
769    }
770
771    // ========== Tests pour workflow de validation ==========
772
773    #[test]
774    fn test_submit_draft_invoice_for_approval() {
775        let org_id = Uuid::new_v4();
776        let building_id = Uuid::new_v4();
777
778        let mut invoice = Expense::new_with_vat(
779            org_id,
780            building_id,
781            ExpenseCategory::Maintenance,
782            "Test".to_string(),
783            1000.0,
784            21.0,
785            Utc::now(),
786            None,
787            None,
788            None,
789            None, // account_code
790        )
791        .unwrap();
792
793        assert_eq!(invoice.approval_status, ApprovalStatus::Draft);
794        assert!(invoice.submitted_at.is_none());
795
796        let result = invoice.submit_for_approval();
797        assert!(result.is_ok());
798        assert_eq!(invoice.approval_status, ApprovalStatus::PendingApproval);
799        assert!(invoice.submitted_at.is_some());
800    }
801
802    #[test]
803    fn test_submit_already_pending_invoice_fails() {
804        let org_id = Uuid::new_v4();
805        let building_id = Uuid::new_v4();
806
807        let mut invoice = Expense::new_with_vat(
808            org_id,
809            building_id,
810            ExpenseCategory::Maintenance,
811            "Test".to_string(),
812            1000.0,
813            21.0,
814            Utc::now(),
815            None,
816            None,
817            None,
818            None, // account_code
819        )
820        .unwrap();
821
822        invoice.submit_for_approval().unwrap();
823        let result = invoice.submit_for_approval();
824
825        assert!(result.is_err());
826        assert_eq!(result.unwrap_err(), "Invoice is already pending approval");
827    }
828
829    #[test]
830    fn test_resubmit_rejected_invoice() {
831        let org_id = Uuid::new_v4();
832        let building_id = Uuid::new_v4();
833        let user_id = Uuid::new_v4();
834
835        let mut invoice = Expense::new_with_vat(
836            org_id,
837            building_id,
838            ExpenseCategory::Maintenance,
839            "Test".to_string(),
840            1000.0,
841            21.0,
842            Utc::now(),
843            None,
844            None,
845            None,
846            None, // account_code
847        )
848        .unwrap();
849
850        invoice.submit_for_approval().unwrap();
851        invoice
852            .reject(user_id, "Montant incorrect".to_string())
853            .unwrap();
854        assert_eq!(invoice.approval_status, ApprovalStatus::Rejected);
855
856        // Re-soumettre une facture rejetée devrait fonctionner
857        let result = invoice.submit_for_approval();
858        assert!(result.is_ok());
859        assert_eq!(invoice.approval_status, ApprovalStatus::PendingApproval);
860        assert!(invoice.rejection_reason.is_none()); // Raison effacée
861    }
862
863    #[test]
864    fn test_approve_pending_invoice() {
865        let org_id = Uuid::new_v4();
866        let building_id = Uuid::new_v4();
867        let syndic_id = Uuid::new_v4();
868
869        let mut invoice = Expense::new_with_vat(
870            org_id,
871            building_id,
872            ExpenseCategory::Maintenance,
873            "Test".to_string(),
874            1000.0,
875            21.0,
876            Utc::now(),
877            None,
878            None,
879            None,
880            None, // account_code
881        )
882        .unwrap();
883
884        invoice.submit_for_approval().unwrap();
885        let result = invoice.approve(syndic_id);
886
887        assert!(result.is_ok());
888        assert_eq!(invoice.approval_status, ApprovalStatus::Approved);
889        assert_eq!(invoice.approved_by, Some(syndic_id));
890        assert!(invoice.approved_at.is_some());
891        assert!(invoice.is_approved());
892    }
893
894    #[test]
895    fn test_approve_draft_invoice_fails() {
896        let org_id = Uuid::new_v4();
897        let building_id = Uuid::new_v4();
898        let syndic_id = Uuid::new_v4();
899
900        let mut invoice = Expense::new_with_vat(
901            org_id,
902            building_id,
903            ExpenseCategory::Maintenance,
904            "Test".to_string(),
905            1000.0,
906            21.0,
907            Utc::now(),
908            None,
909            None,
910            None,
911            None, // account_code
912        )
913        .unwrap();
914
915        // Ne PAS soumettre, tenter d'approuver directement
916        let result = invoice.approve(syndic_id);
917
918        assert!(result.is_err());
919        assert!(result.unwrap_err().contains("must be submitted first"));
920    }
921
922    #[test]
923    fn test_reject_pending_invoice_with_reason() {
924        let org_id = Uuid::new_v4();
925        let building_id = Uuid::new_v4();
926        let syndic_id = Uuid::new_v4();
927
928        let mut invoice = Expense::new_with_vat(
929            org_id,
930            building_id,
931            ExpenseCategory::Maintenance,
932            "Test".to_string(),
933            1000.0,
934            21.0,
935            Utc::now(),
936            None,
937            None,
938            None,
939            None, // account_code
940        )
941        .unwrap();
942
943        invoice.submit_for_approval().unwrap();
944        let result = invoice.reject(
945            syndic_id,
946            "Le montant ne correspond pas au devis".to_string(),
947        );
948
949        assert!(result.is_ok());
950        assert_eq!(invoice.approval_status, ApprovalStatus::Rejected);
951        assert_eq!(invoice.approved_by, Some(syndic_id));
952        assert_eq!(
953            invoice.rejection_reason,
954            Some("Le montant ne correspond pas au devis".to_string())
955        );
956    }
957
958    #[test]
959    fn test_reject_invoice_without_reason_fails() {
960        let org_id = Uuid::new_v4();
961        let building_id = Uuid::new_v4();
962        let syndic_id = Uuid::new_v4();
963
964        let mut invoice = Expense::new_with_vat(
965            org_id,
966            building_id,
967            ExpenseCategory::Maintenance,
968            "Test".to_string(),
969            1000.0,
970            21.0,
971            Utc::now(),
972            None,
973            None,
974            None,
975            None, // account_code
976        )
977        .unwrap();
978
979        invoice.submit_for_approval().unwrap();
980        let result = invoice.reject(syndic_id, "".to_string());
981
982        assert!(result.is_err());
983        assert_eq!(result.unwrap_err(), "Rejection reason cannot be empty");
984    }
985
986    #[test]
987    fn test_can_be_modified_draft() {
988        let org_id = Uuid::new_v4();
989        let building_id = Uuid::new_v4();
990
991        let invoice = Expense::new_with_vat(
992            org_id,
993            building_id,
994            ExpenseCategory::Maintenance,
995            "Test".to_string(),
996            1000.0,
997            21.0,
998            Utc::now(),
999            None,
1000            None,
1001            None,
1002            None, // account_code
1003        )
1004        .unwrap();
1005
1006        assert!(invoice.can_be_modified()); // Draft peut être modifié
1007    }
1008
1009    #[test]
1010    fn test_can_be_modified_rejected() {
1011        let org_id = Uuid::new_v4();
1012        let building_id = Uuid::new_v4();
1013        let syndic_id = Uuid::new_v4();
1014
1015        let mut invoice = Expense::new_with_vat(
1016            org_id,
1017            building_id,
1018            ExpenseCategory::Maintenance,
1019            "Test".to_string(),
1020            1000.0,
1021            21.0,
1022            Utc::now(),
1023            None,
1024            None,
1025            None,
1026            None, // account_code
1027        )
1028        .unwrap();
1029
1030        invoice.submit_for_approval().unwrap();
1031        invoice.reject(syndic_id, "Erreur".to_string()).unwrap();
1032
1033        assert!(invoice.can_be_modified()); // Rejected peut être modifié
1034    }
1035
1036    #[test]
1037    fn test_cannot_modify_approved_invoice() {
1038        let org_id = Uuid::new_v4();
1039        let building_id = Uuid::new_v4();
1040        let syndic_id = Uuid::new_v4();
1041
1042        let mut invoice = Expense::new_with_vat(
1043            org_id,
1044            building_id,
1045            ExpenseCategory::Maintenance,
1046            "Test".to_string(),
1047            1000.0,
1048            21.0,
1049            Utc::now(),
1050            None,
1051            None,
1052            None,
1053            None, // account_code
1054        )
1055        .unwrap();
1056
1057        invoice.submit_for_approval().unwrap();
1058        invoice.approve(syndic_id).unwrap();
1059
1060        assert!(!invoice.can_be_modified()); // Approved ne peut PAS être modifié
1061    }
1062
1063    #[test]
1064    fn test_mark_as_paid_sets_paid_date() {
1065        let org_id = Uuid::new_v4();
1066        let building_id = Uuid::new_v4();
1067        let syndic_id = Uuid::new_v4();
1068
1069        let mut expense = Expense::new(
1070            org_id,
1071            building_id,
1072            ExpenseCategory::Maintenance,
1073            "Test".to_string(),
1074            100.0,
1075            Utc::now(),
1076            None,
1077            None,
1078            None, // account_code
1079        )
1080        .unwrap();
1081
1082        // Follow approval workflow: Draft → Submit → Approve → Pay
1083        expense.submit_for_approval().unwrap();
1084        expense.approve(syndic_id).unwrap();
1085
1086        assert!(expense.paid_date.is_none());
1087        expense.mark_as_paid().unwrap();
1088        assert!(expense.paid_date.is_some());
1089        assert!(expense.is_paid());
1090    }
1091
1092    #[test]
1093    fn test_workflow_complete_cycle() {
1094        // Test du cycle complet : Draft → Submit → Approve → Pay
1095        let org_id = Uuid::new_v4();
1096        let building_id = Uuid::new_v4();
1097        let syndic_id = Uuid::new_v4();
1098
1099        let mut invoice = Expense::new_with_vat(
1100            org_id,
1101            building_id,
1102            ExpenseCategory::Maintenance,
1103            "Entretien annuel".to_string(),
1104            2000.0,
1105            21.0,
1106            Utc::now(),
1107            Some(Utc::now() + chrono::Duration::days(30)),
1108            Some("MaintenancePro".to_string()),
1109            Some("INV-2025-100".to_string()),
1110            None, // account_code
1111        )
1112        .unwrap();
1113
1114        // Étape 1: Draft
1115        assert_eq!(invoice.approval_status, ApprovalStatus::Draft);
1116        assert!(invoice.can_be_modified());
1117
1118        // Étape 2: Soumettre
1119        invoice.submit_for_approval().unwrap();
1120        assert_eq!(invoice.approval_status, ApprovalStatus::PendingApproval);
1121        assert!(!invoice.can_be_modified());
1122
1123        // Étape 3: Approuver
1124        invoice.approve(syndic_id).unwrap();
1125        assert_eq!(invoice.approval_status, ApprovalStatus::Approved);
1126        assert!(invoice.is_approved());
1127
1128        // Étape 4: Payer
1129        invoice.mark_as_paid().unwrap();
1130        assert!(invoice.is_paid());
1131        assert!(invoice.paid_date.is_some());
1132    }
1133
1134    #[test]
1135    fn test_approve_works_expense_without_contractor_report_fails() {
1136        // Issue #309: Work expenses must have a contractor report before approval
1137        let org_id = Uuid::new_v4();
1138        let building_id = Uuid::new_v4();
1139        let syndic_id = Uuid::new_v4();
1140
1141        let mut expense = Expense::new(
1142            org_id,
1143            building_id,
1144            ExpenseCategory::Works,
1145            "Réparation toiture".to_string(),
1146            5000.0,
1147            Utc::now(),
1148            Some("Construction SPRL".to_string()),
1149            Some("DV-2025-001".to_string()),
1150            None,
1151        )
1152        .unwrap();
1153
1154        expense.submit_for_approval().unwrap();
1155
1156        // Try to approve without contractor report
1157        let result = expense.approve(syndic_id);
1158        assert!(result.is_err());
1159        assert!(result.unwrap_err().contains("contractor report"));
1160    }
1161
1162    #[test]
1163    fn test_set_contractor_report_for_works_expense() {
1164        // Issue #309: Set contractor report link on Works expense
1165        let org_id = Uuid::new_v4();
1166        let building_id = Uuid::new_v4();
1167        let contractor_report_id = Uuid::new_v4();
1168
1169        let mut expense = Expense::new(
1170            org_id,
1171            building_id,
1172            ExpenseCategory::Works,
1173            "Réparation toiture".to_string(),
1174            5000.0,
1175            Utc::now(),
1176            Some("Construction SPRL".to_string()),
1177            Some("DV-2025-001".to_string()),
1178            None,
1179        )
1180        .unwrap();
1181
1182        // Set contractor report
1183        let result = expense.set_contractor_report(contractor_report_id);
1184        assert!(result.is_ok());
1185        assert_eq!(expense.contractor_report_id, Some(contractor_report_id));
1186    }
1187
1188    #[test]
1189    fn test_set_contractor_report_for_non_works_fails() {
1190        // Issue #309: Can only set contractor report for Works category
1191        let org_id = Uuid::new_v4();
1192        let building_id = Uuid::new_v4();
1193        let contractor_report_id = Uuid::new_v4();
1194
1195        let mut expense = Expense::new(
1196            org_id,
1197            building_id,
1198            ExpenseCategory::Maintenance,
1199            "Maintenance".to_string(),
1200            1000.0,
1201            Utc::now(),
1202            None,
1203            None,
1204            None,
1205        )
1206        .unwrap();
1207
1208        // Try to set contractor report on non-Works expense
1209        let result = expense.set_contractor_report(contractor_report_id);
1210        assert!(result.is_err());
1211        assert!(result.unwrap_err().contains("Works category"));
1212    }
1213
1214    #[test]
1215    fn test_approve_works_expense_with_contractor_report_succeeds() {
1216        // Issue #309: Work expenses with contractor report can be approved
1217        let org_id = Uuid::new_v4();
1218        let building_id = Uuid::new_v4();
1219        let syndic_id = Uuid::new_v4();
1220        let contractor_report_id = Uuid::new_v4();
1221
1222        let mut expense = Expense::new(
1223            org_id,
1224            building_id,
1225            ExpenseCategory::Works,
1226            "Réparation toiture".to_string(),
1227            5000.0,
1228            Utc::now(),
1229            Some("Construction SPRL".to_string()),
1230            Some("DV-2025-001".to_string()),
1231            None,
1232        )
1233        .unwrap();
1234
1235        // Set contractor report
1236        expense.set_contractor_report(contractor_report_id).unwrap();
1237
1238        // Submit and approve
1239        expense.submit_for_approval().unwrap();
1240        let result = expense.approve(syndic_id);
1241
1242        assert!(result.is_ok());
1243        assert_eq!(expense.approval_status, ApprovalStatus::Approved);
1244    }
1245}