Skip to main content

koprogo_api/domain/entities/
expense.rs

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