1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub enum ExpenseCategory {
8 Maintenance, Repairs, Insurance, Utilities, Cleaning, Administration, Works, Other,
16}
17
18#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30#[serde(rename_all = "snake_case")]
31pub enum ApprovalStatus {
32 Draft, PendingApproval, Approved, Rejected, }
37
38#[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 pub amount: f64, pub amount_excl_vat: Option<f64>, pub vat_rate: Option<f64>, pub vat_amount: Option<f64>, pub amount_incl_vat: Option<f64>, pub expense_date: DateTime<Utc>, pub invoice_date: Option<DateTime<Utc>>, pub due_date: Option<DateTime<Utc>>, pub paid_date: Option<DateTime<Utc>>, pub approval_status: ApprovalStatus,
65 pub submitted_at: Option<DateTime<Utc>>, pub approved_by: Option<Uuid>, pub approved_at: Option<DateTime<Utc>>, pub rejection_reason: Option<String>, pub payment_status: PaymentStatus,
72 pub supplier: Option<String>,
73 pub invoice_number: Option<String>,
74 pub account_code: Option<String>,
77 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 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 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), expense_date,
128 invoice_date: None,
129 due_date: None,
130 paid_date: None,
131 approval_status: ApprovalStatus::Draft, 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 #[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 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, 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, 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 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; 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 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 self.approval_status = ApprovalStatus::PendingApproval;
241 self.submitted_at = Some(Utc::now());
242 self.rejection_reason = None; 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 pub fn approve(&mut self, approved_by_user_id: Uuid) -> Result<(), String> {
256 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 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); 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 pub fn can_be_modified(&self) -> bool {
306 matches!(
307 self.approval_status,
308 ApprovalStatus::Draft | ApprovalStatus::Rejected
309 )
310 }
311
312 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 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()); 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 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()), );
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, );
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()), );
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); 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 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 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 #[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, 21.0, invoice_date,
634 Some(due_date),
635 Some("BatiPro SPRL".to_string()),
636 Some("INV-2025-042".to_string()),
637 None, );
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); 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, 6.0, Utc::now(),
663 None,
664 None,
665 None,
666 None, )
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, Utc::now(),
687 None,
688 None,
689 None,
690 None, );
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, Utc::now(),
710 None,
711 None,
712 None,
713 None, );
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, )
737 .unwrap();
738
739 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)); 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 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, )
765 .unwrap();
766
767 let result = expense.recalculate_vat();
768 assert!(result.is_err());
769 }
770
771 #[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, )
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, )
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, )
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 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()); }
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, )
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, )
913 .unwrap();
914
915 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, )
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, )
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, )
1004 .unwrap();
1005
1006 assert!(invoice.can_be_modified()); }
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, )
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()); }
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, )
1055 .unwrap();
1056
1057 invoice.submit_for_approval().unwrap();
1058 invoice.approve(syndic_id).unwrap();
1059
1060 assert!(!invoice.can_be_modified()); }
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, )
1080 .unwrap();
1081
1082 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 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, )
1112 .unwrap();
1113
1114 assert_eq!(invoice.approval_status, ApprovalStatus::Draft);
1116 assert!(invoice.can_be_modified());
1117
1118 invoice.submit_for_approval().unwrap();
1120 assert_eq!(invoice.approval_status, ApprovalStatus::PendingApproval);
1121 assert!(!invoice.can_be_modified());
1122
1123 invoice.approve(syndic_id).unwrap();
1125 assert_eq!(invoice.approval_status, ApprovalStatus::Approved);
1126 assert!(invoice.is_approved());
1127
1128 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 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 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 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 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 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 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 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 expense.set_contractor_report(contractor_report_id).unwrap();
1237
1238 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}