Skip to main content

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