1use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use rust_decimal_macros::dec;
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
13pub enum ExpenseCategory {
14 Maintenance, Repairs, Insurance, Utilities, Cleaning, Administration, Works, Other,
22}
23
24#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
36#[serde(rename_all = "snake_case")]
37pub enum ApprovalStatus {
38 Draft, PendingApproval, Approved, Rejected, }
43
44#[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 pub amount: Decimal, pub amount_excl_vat: Option<Decimal>, pub vat_rate: Option<Decimal>, pub vat_amount: Option<Decimal>, pub amount_incl_vat: Option<Decimal>, 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,
71 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,
78 pub supplier: Option<String>,
79 pub invoice_number: Option<String>,
80 pub account_code: Option<String>,
83 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 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 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), expense_date,
134 invoice_date: None,
135 due_date: None,
136 paid_date: None,
137 approval_status: ApprovalStatus::Draft, 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 #[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 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, 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, 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 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; 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 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 self.approval_status = ApprovalStatus::PendingApproval;
247 self.submitted_at = Some(Utc::now());
248 self.rejection_reason = None; 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 pub fn approve(&mut self, approved_by_user_id: Uuid) -> Result<(), String> {
262 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 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); 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 pub fn can_be_modified(&self) -> bool {
312 matches!(
313 self.approval_status,
314 ApprovalStatus::Draft | ApprovalStatus::Rejected
315 )
316 }
317
318 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 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()); 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 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()), );
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, );
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()), );
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); 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 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 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 #[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), dec!(21), invoice_date,
640 Some(due_date),
641 Some("BatiPro SPRL".to_string()),
642 Some("INV-2025-042".to_string()),
643 None, );
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)); 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), dec!(6), Utc::now(),
669 None,
670 None,
671 None,
672 None, )
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), Utc::now(),
693 None,
694 None,
695 None,
696 None, );
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), Utc::now(),
716 None,
717 None,
718 None,
719 None, );
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, )
743 .unwrap();
744
745 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))); 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 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, )
771 .unwrap();
772
773 let result = expense.recalculate_vat();
774 assert!(result.is_err());
775 }
776
777 #[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, )
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, )
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, )
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 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()); }
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, )
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, )
919 .unwrap();
920
921 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, )
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, )
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, )
1010 .unwrap();
1011
1012 assert!(invoice.can_be_modified()); }
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, )
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()); }
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, )
1061 .unwrap();
1062
1063 invoice.submit_for_approval().unwrap();
1064 invoice.approve(syndic_id).unwrap();
1065
1066 assert!(!invoice.can_be_modified()); }
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, )
1086 .unwrap();
1087
1088 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 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, )
1118 .unwrap();
1119
1120 assert_eq!(invoice.approval_status, ApprovalStatus::Draft);
1122 assert!(invoice.can_be_modified());
1123
1124 invoice.submit_for_approval().unwrap();
1126 assert_eq!(invoice.approval_status, ApprovalStatus::PendingApproval);
1127 assert!(!invoice.can_be_modified());
1128
1129 invoice.approve(syndic_id).unwrap();
1131 assert_eq!(invoice.approval_status, ApprovalStatus::Approved);
1132 assert!(invoice.is_approved());
1133
1134 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 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 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 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 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 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 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 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 expense.set_contractor_report(contractor_report_id).unwrap();
1243
1244 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}