1use crate::application::dto::ChargeDistributionResponseDto;
2use crate::application::ports::{
3 ChargeDistributionRepository, ExpenseRepository, UnitOwnerRepository,
4};
5use crate::domain::entities::{ApprovalStatus, ChargeDistribution};
6use std::sync::Arc;
7use uuid::Uuid;
8
9pub struct ChargeDistributionUseCases {
10 distribution_repository: Arc<dyn ChargeDistributionRepository>,
11 expense_repository: Arc<dyn ExpenseRepository>,
12 unit_owner_repository: Arc<dyn UnitOwnerRepository>,
13}
14
15impl ChargeDistributionUseCases {
16 pub fn new(
17 distribution_repository: Arc<dyn ChargeDistributionRepository>,
18 expense_repository: Arc<dyn ExpenseRepository>,
19 unit_owner_repository: Arc<dyn UnitOwnerRepository>,
20 ) -> Self {
21 Self {
22 distribution_repository,
23 expense_repository,
24 unit_owner_repository,
25 }
26 }
27
28 pub async fn calculate_and_save_distribution(
30 &self,
31 expense_id: Uuid,
32 ) -> Result<Vec<ChargeDistributionResponseDto>, String> {
33 let expense = self
35 .expense_repository
36 .find_by_id(expense_id)
37 .await?
38 .ok_or_else(|| "Expense/Invoice not found".to_string())?;
39
40 if expense.approval_status != ApprovalStatus::Approved {
42 return Err(format!(
43 "Cannot calculate distribution for non-approved invoice (status: {:?})",
44 expense.approval_status
45 ));
46 }
47
48 let total_amount = expense.amount_incl_vat.unwrap_or(expense.amount);
50
51 let unit_ownerships = self
53 .unit_owner_repository
54 .find_active_by_building(expense.building_id)
55 .await?;
56
57 if unit_ownerships.is_empty() {
58 return Err("No active unit-owner relationships found for this building".to_string());
59 }
60
61 let distributions =
63 ChargeDistribution::calculate_distributions(expense_id, total_amount, unit_ownerships)?;
64
65 let saved_distributions = self
67 .distribution_repository
68 .create_bulk(&distributions)
69 .await?;
70
71 Ok(saved_distributions
73 .iter()
74 .map(|d| self.to_response_dto(d))
75 .collect())
76 }
77
78 pub async fn get_distribution_by_expense(
80 &self,
81 expense_id: Uuid,
82 ) -> Result<Vec<ChargeDistributionResponseDto>, String> {
83 let distributions = self
84 .distribution_repository
85 .find_by_expense(expense_id)
86 .await?;
87 Ok(distributions
88 .iter()
89 .map(|d| self.to_response_dto(d))
90 .collect())
91 }
92
93 pub async fn get_distributions_by_owner(
95 &self,
96 owner_id: Uuid,
97 ) -> Result<Vec<ChargeDistributionResponseDto>, String> {
98 let distributions = self.distribution_repository.find_by_owner(owner_id).await?;
99 Ok(distributions
100 .iter()
101 .map(|d| self.to_response_dto(d))
102 .collect())
103 }
104
105 pub async fn get_total_due_by_owner(&self, owner_id: Uuid) -> Result<f64, String> {
107 self.distribution_repository
108 .get_total_due_by_owner(owner_id)
109 .await
110 }
111
112 pub async fn delete_distribution_by_expense(&self, expense_id: Uuid) -> Result<(), String> {
114 self.distribution_repository
115 .delete_by_expense(expense_id)
116 .await
117 }
118
119 fn to_response_dto(&self, distribution: &ChargeDistribution) -> ChargeDistributionResponseDto {
120 ChargeDistributionResponseDto {
121 id: distribution.id.to_string(),
122 expense_id: distribution.expense_id.to_string(),
123 unit_id: distribution.unit_id.to_string(),
124 owner_id: distribution.owner_id.to_string(),
125 quota_percentage: distribution.quota_percentage,
126 amount_due: distribution.amount_due,
127 created_at: distribution.created_at.to_rfc3339(),
128 }
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::application::dto::{ExpenseFilters, PageRequest};
136 use crate::application::ports::{
137 ChargeDistributionRepository, ExpenseRepository, UnitOwnerRepository,
138 };
139 use crate::domain::entities::{
140 ApprovalStatus, ChargeDistribution, Expense, ExpenseCategory, PaymentStatus, UnitOwner,
141 };
142 use async_trait::async_trait;
143 use chrono::Utc;
144 use std::collections::HashMap;
145 use std::sync::Mutex;
146
147 struct MockChargeDistributionRepository {
150 distributions: Mutex<HashMap<Uuid, ChargeDistribution>>,
151 }
152
153 impl MockChargeDistributionRepository {
154 fn new() -> Self {
155 Self {
156 distributions: Mutex::new(HashMap::new()),
157 }
158 }
159 }
160
161 #[async_trait]
162 impl ChargeDistributionRepository for MockChargeDistributionRepository {
163 async fn create(
164 &self,
165 distribution: &ChargeDistribution,
166 ) -> Result<ChargeDistribution, String> {
167 let mut distributions = self.distributions.lock().unwrap();
168 distributions.insert(distribution.id, distribution.clone());
169 Ok(distribution.clone())
170 }
171
172 async fn create_bulk(
173 &self,
174 distributions: &[ChargeDistribution],
175 ) -> Result<Vec<ChargeDistribution>, String> {
176 let mut store = self.distributions.lock().unwrap();
177 let mut result = Vec::new();
178 for d in distributions {
179 store.insert(d.id, d.clone());
180 result.push(d.clone());
181 }
182 Ok(result)
183 }
184
185 async fn find_by_id(&self, id: Uuid) -> Result<Option<ChargeDistribution>, String> {
186 let distributions = self.distributions.lock().unwrap();
187 Ok(distributions.get(&id).cloned())
188 }
189
190 async fn find_by_expense(
191 &self,
192 expense_id: Uuid,
193 ) -> Result<Vec<ChargeDistribution>, String> {
194 let distributions = self.distributions.lock().unwrap();
195 Ok(distributions
196 .values()
197 .filter(|d| d.expense_id == expense_id)
198 .cloned()
199 .collect())
200 }
201
202 async fn find_by_unit(&self, unit_id: Uuid) -> Result<Vec<ChargeDistribution>, String> {
203 let distributions = self.distributions.lock().unwrap();
204 Ok(distributions
205 .values()
206 .filter(|d| d.unit_id == unit_id)
207 .cloned()
208 .collect())
209 }
210
211 async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<ChargeDistribution>, String> {
212 let distributions = self.distributions.lock().unwrap();
213 Ok(distributions
214 .values()
215 .filter(|d| d.owner_id == owner_id)
216 .cloned()
217 .collect())
218 }
219
220 async fn delete_by_expense(&self, expense_id: Uuid) -> Result<(), String> {
221 let mut distributions = self.distributions.lock().unwrap();
222 distributions.retain(|_, d| d.expense_id != expense_id);
223 Ok(())
224 }
225
226 async fn get_total_due_by_owner(&self, owner_id: Uuid) -> Result<f64, String> {
227 let distributions = self.distributions.lock().unwrap();
228 let total = distributions
229 .values()
230 .filter(|d| d.owner_id == owner_id)
231 .map(|d| d.amount_due)
232 .sum();
233 Ok(total)
234 }
235 }
236
237 struct MockExpenseRepository {
240 expenses: Mutex<HashMap<Uuid, Expense>>,
241 }
242
243 impl MockExpenseRepository {
244 fn new() -> Self {
245 Self {
246 expenses: Mutex::new(HashMap::new()),
247 }
248 }
249
250 fn with_expense(expense: Expense) -> Self {
251 let mut map = HashMap::new();
252 map.insert(expense.id, expense);
253 Self {
254 expenses: Mutex::new(map),
255 }
256 }
257 }
258
259 #[async_trait]
260 impl ExpenseRepository for MockExpenseRepository {
261 async fn create(&self, expense: &Expense) -> Result<Expense, String> {
262 let mut expenses = self.expenses.lock().unwrap();
263 expenses.insert(expense.id, expense.clone());
264 Ok(expense.clone())
265 }
266
267 async fn find_by_id(&self, id: Uuid) -> Result<Option<Expense>, String> {
268 let expenses = self.expenses.lock().unwrap();
269 Ok(expenses.get(&id).cloned())
270 }
271
272 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Expense>, String> {
273 let expenses = self.expenses.lock().unwrap();
274 Ok(expenses
275 .values()
276 .filter(|e| e.building_id == building_id)
277 .cloned()
278 .collect())
279 }
280
281 async fn find_all_paginated(
282 &self,
283 _page_request: &PageRequest,
284 _filters: &ExpenseFilters,
285 ) -> Result<(Vec<Expense>, i64), String> {
286 let expenses = self.expenses.lock().unwrap();
287 let all: Vec<Expense> = expenses.values().cloned().collect();
288 let count = all.len() as i64;
289 Ok((all, count))
290 }
291
292 async fn update(&self, expense: &Expense) -> Result<Expense, String> {
293 let mut expenses = self.expenses.lock().unwrap();
294 expenses.insert(expense.id, expense.clone());
295 Ok(expense.clone())
296 }
297
298 async fn delete(&self, id: Uuid) -> Result<bool, String> {
299 let mut expenses = self.expenses.lock().unwrap();
300 Ok(expenses.remove(&id).is_some())
301 }
302 }
303
304 struct MockUnitOwnerRepository {
307 building_ownerships: Mutex<HashMap<Uuid, Vec<(Uuid, Uuid, f64)>>>,
309 }
310
311 impl MockUnitOwnerRepository {
312 fn new() -> Self {
313 Self {
314 building_ownerships: Mutex::new(HashMap::new()),
315 }
316 }
317
318 fn with_building_ownerships(building_id: Uuid, ownerships: Vec<(Uuid, Uuid, f64)>) -> Self {
319 let mut map = HashMap::new();
320 map.insert(building_id, ownerships);
321 Self {
322 building_ownerships: Mutex::new(map),
323 }
324 }
325 }
326
327 #[async_trait]
328 impl UnitOwnerRepository for MockUnitOwnerRepository {
329 async fn create(&self, _unit_owner: &UnitOwner) -> Result<UnitOwner, String> {
330 unimplemented!("not needed for charge distribution tests")
331 }
332
333 async fn find_by_id(&self, _id: Uuid) -> Result<Option<UnitOwner>, String> {
334 unimplemented!("not needed for charge distribution tests")
335 }
336
337 async fn find_current_owners_by_unit(
338 &self,
339 _unit_id: Uuid,
340 ) -> Result<Vec<UnitOwner>, String> {
341 unimplemented!("not needed for charge distribution tests")
342 }
343
344 async fn find_current_units_by_owner(
345 &self,
346 _owner_id: Uuid,
347 ) -> Result<Vec<UnitOwner>, String> {
348 unimplemented!("not needed for charge distribution tests")
349 }
350
351 async fn find_all_owners_by_unit(&self, _unit_id: Uuid) -> Result<Vec<UnitOwner>, String> {
352 unimplemented!("not needed for charge distribution tests")
353 }
354
355 async fn find_all_units_by_owner(&self, _owner_id: Uuid) -> Result<Vec<UnitOwner>, String> {
356 unimplemented!("not needed for charge distribution tests")
357 }
358
359 async fn update(&self, _unit_owner: &UnitOwner) -> Result<UnitOwner, String> {
360 unimplemented!("not needed for charge distribution tests")
361 }
362
363 async fn delete(&self, _id: Uuid) -> Result<(), String> {
364 unimplemented!("not needed for charge distribution tests")
365 }
366
367 async fn has_active_owners(&self, _unit_id: Uuid) -> Result<bool, String> {
368 unimplemented!("not needed for charge distribution tests")
369 }
370
371 async fn get_total_ownership_percentage(&self, _unit_id: Uuid) -> Result<f64, String> {
372 unimplemented!("not needed for charge distribution tests")
373 }
374
375 async fn find_active_by_unit_and_owner(
376 &self,
377 _unit_id: Uuid,
378 _owner_id: Uuid,
379 ) -> Result<Option<UnitOwner>, String> {
380 unimplemented!("not needed for charge distribution tests")
381 }
382
383 async fn find_active_by_building(
384 &self,
385 building_id: Uuid,
386 ) -> Result<Vec<(Uuid, Uuid, f64)>, String> {
387 let ownerships = self.building_ownerships.lock().unwrap();
388 Ok(ownerships.get(&building_id).cloned().unwrap_or_default())
389 }
390 }
391
392 fn make_approved_expense(building_id: Uuid, amount_incl_vat: f64) -> Expense {
395 let now = Utc::now();
396 Expense {
397 id: Uuid::new_v4(),
398 organization_id: Uuid::new_v4(),
399 building_id,
400 category: ExpenseCategory::Maintenance,
401 description: "Elevator maintenance".to_string(),
402 amount: amount_incl_vat,
403 amount_excl_vat: Some(amount_incl_vat / 1.21),
404 vat_rate: Some(21.0),
405 vat_amount: Some(amount_incl_vat - amount_incl_vat / 1.21),
406 amount_incl_vat: Some(amount_incl_vat),
407 expense_date: now,
408 invoice_date: Some(now),
409 due_date: None,
410 paid_date: None,
411 approval_status: ApprovalStatus::Approved,
412 submitted_at: Some(now),
413 approved_by: Some(Uuid::new_v4()),
414 approved_at: Some(now),
415 rejection_reason: None,
416 payment_status: PaymentStatus::Pending,
417 supplier: Some("Schindler SA".to_string()),
418 invoice_number: Some("INV-001".to_string()),
419 account_code: Some("611002".to_string()),
420 contractor_report_id: None,
421 created_at: now,
422 updated_at: now,
423 }
424 }
425
426 fn make_draft_expense(building_id: Uuid) -> Expense {
427 let now = Utc::now();
428 Expense {
429 id: Uuid::new_v4(),
430 organization_id: Uuid::new_v4(),
431 building_id,
432 category: ExpenseCategory::Maintenance,
433 description: "Draft expense".to_string(),
434 amount: 1000.0,
435 amount_excl_vat: None,
436 vat_rate: None,
437 vat_amount: None,
438 amount_incl_vat: None,
439 expense_date: now,
440 invoice_date: None,
441 due_date: None,
442 paid_date: None,
443 approval_status: ApprovalStatus::Draft,
444 submitted_at: None,
445 approved_by: None,
446 approved_at: None,
447 rejection_reason: None,
448 payment_status: PaymentStatus::Pending,
449 supplier: None,
450 invoice_number: None,
451 account_code: None,
452 contractor_report_id: None,
453 created_at: now,
454 updated_at: now,
455 }
456 }
457
458 fn make_use_cases(
459 dist_repo: MockChargeDistributionRepository,
460 expense_repo: MockExpenseRepository,
461 unit_owner_repo: MockUnitOwnerRepository,
462 ) -> ChargeDistributionUseCases {
463 ChargeDistributionUseCases::new(
464 Arc::new(dist_repo),
465 Arc::new(expense_repo),
466 Arc::new(unit_owner_repo),
467 )
468 }
469
470 #[tokio::test]
473 async fn test_calculate_and_save_distribution_success() {
474 let building_id = Uuid::new_v4();
475 let unit1_id = Uuid::new_v4();
476 let unit2_id = Uuid::new_v4();
477 let owner1_id = Uuid::new_v4();
478 let owner2_id = Uuid::new_v4();
479
480 let expense = make_approved_expense(building_id, 1000.0);
481 let expense_id = expense.id;
482
483 let ownerships = vec![
484 (unit1_id, owner1_id, 0.60), (unit2_id, owner2_id, 0.40), ];
487
488 let dist_repo = MockChargeDistributionRepository::new();
489 let expense_repo = MockExpenseRepository::with_expense(expense);
490 let unit_owner_repo =
491 MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
492
493 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
494
495 let result = uc.calculate_and_save_distribution(expense_id).await;
496 assert!(result.is_ok());
497
498 let distributions = result.unwrap();
499 assert_eq!(distributions.len(), 2);
500
501 let total_amount: f64 = distributions.iter().map(|d| d.amount_due).sum();
503 assert!((total_amount - 1000.0).abs() < 0.01);
504
505 assert!(distributions
507 .iter()
508 .all(|d| d.expense_id == expense_id.to_string()));
509 }
510
511 #[tokio::test]
512 async fn test_calculate_and_save_distribution_non_approved_expense() {
513 let building_id = Uuid::new_v4();
514 let expense = make_draft_expense(building_id);
515 let expense_id = expense.id;
516
517 let dist_repo = MockChargeDistributionRepository::new();
518 let expense_repo = MockExpenseRepository::with_expense(expense);
519 let unit_owner_repo = MockUnitOwnerRepository::new();
520
521 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
522
523 let result = uc.calculate_and_save_distribution(expense_id).await;
524 assert!(result.is_err());
525 let err = result.unwrap_err();
526 assert!(err.contains("non-approved invoice"));
527 }
528
529 #[tokio::test]
530 async fn test_calculate_and_save_distribution_expense_not_found() {
531 let dist_repo = MockChargeDistributionRepository::new();
532 let expense_repo = MockExpenseRepository::new(); let unit_owner_repo = MockUnitOwnerRepository::new();
534
535 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
536
537 let result = uc.calculate_and_save_distribution(Uuid::new_v4()).await;
538 assert!(result.is_err());
539 assert_eq!(result.unwrap_err(), "Expense/Invoice not found");
540 }
541
542 #[tokio::test]
543 async fn test_calculate_and_save_distribution_no_active_owners() {
544 let building_id = Uuid::new_v4();
545 let expense = make_approved_expense(building_id, 1000.0);
546 let expense_id = expense.id;
547
548 let dist_repo = MockChargeDistributionRepository::new();
549 let expense_repo = MockExpenseRepository::with_expense(expense);
550 let unit_owner_repo =
552 MockUnitOwnerRepository::with_building_ownerships(building_id, vec![]);
553
554 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
555
556 let result = uc.calculate_and_save_distribution(expense_id).await;
557 assert!(result.is_err());
558 assert_eq!(
559 result.unwrap_err(),
560 "No active unit-owner relationships found for this building"
561 );
562 }
563
564 #[tokio::test]
565 async fn test_get_distribution_by_expense() {
566 let building_id = Uuid::new_v4();
567 let unit1_id = Uuid::new_v4();
568 let owner1_id = Uuid::new_v4();
569
570 let expense = make_approved_expense(building_id, 500.0);
571 let expense_id = expense.id;
572
573 let ownerships = vec![(unit1_id, owner1_id, 1.0)]; let dist_repo = MockChargeDistributionRepository::new();
576 let expense_repo = MockExpenseRepository::with_expense(expense);
577 let unit_owner_repo =
578 MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
579
580 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
581
582 uc.calculate_and_save_distribution(expense_id)
584 .await
585 .unwrap();
586
587 let result = uc.get_distribution_by_expense(expense_id).await;
589 assert!(result.is_ok());
590 let distributions = result.unwrap();
591 assert_eq!(distributions.len(), 1);
592 assert_eq!(distributions[0].expense_id, expense_id.to_string());
593 assert_eq!(distributions[0].quota_percentage, 1.0);
594 assert!((distributions[0].amount_due - 500.0).abs() < 0.01);
595 }
596
597 #[tokio::test]
598 async fn test_get_distribution_by_expense_empty() {
599 let dist_repo = MockChargeDistributionRepository::new();
600 let expense_repo = MockExpenseRepository::new();
601 let unit_owner_repo = MockUnitOwnerRepository::new();
602
603 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
604
605 let result = uc.get_distribution_by_expense(Uuid::new_v4()).await;
606 assert!(result.is_ok());
607 assert!(result.unwrap().is_empty());
608 }
609
610 #[tokio::test]
611 async fn test_get_total_due_by_owner() {
612 let building_id = Uuid::new_v4();
613 let unit1_id = Uuid::new_v4();
614 let unit2_id = Uuid::new_v4();
615 let owner1_id = Uuid::new_v4();
616 let owner2_id = Uuid::new_v4();
617
618 let expense1 = make_approved_expense(building_id, 1000.0);
620 let expense1_id = expense1.id;
621 let expense2 = make_approved_expense(building_id, 2000.0);
622 let expense2_id = expense2.id;
623
624 let ownerships = vec![
625 (unit1_id, owner1_id, 0.60), (unit2_id, owner2_id, 0.40), ];
628
629 let dist_repo = MockChargeDistributionRepository::new();
630 let mut expense_map = HashMap::new();
631 expense_map.insert(expense1.id, expense1);
632 expense_map.insert(expense2.id, expense2);
633 let expense_repo = MockExpenseRepository {
634 expenses: Mutex::new(expense_map),
635 };
636 let unit_owner_repo =
637 MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
638
639 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
640
641 uc.calculate_and_save_distribution(expense1_id)
643 .await
644 .unwrap();
645 uc.calculate_and_save_distribution(expense2_id)
646 .await
647 .unwrap();
648
649 let total = uc.get_total_due_by_owner(owner1_id).await.unwrap();
651 assert!((total - 1800.0).abs() < 0.01);
652
653 let total = uc.get_total_due_by_owner(owner2_id).await.unwrap();
655 assert!((total - 1200.0).abs() < 0.01);
656 }
657
658 #[tokio::test]
659 async fn test_get_total_due_by_owner_no_distributions() {
660 let dist_repo = MockChargeDistributionRepository::new();
661 let expense_repo = MockExpenseRepository::new();
662 let unit_owner_repo = MockUnitOwnerRepository::new();
663
664 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
665
666 let total = uc.get_total_due_by_owner(Uuid::new_v4()).await.unwrap();
667 assert_eq!(total, 0.0);
668 }
669
670 #[tokio::test]
671 async fn test_calculate_distribution_uses_amount_incl_vat() {
672 let building_id = Uuid::new_v4();
673 let unit_id = Uuid::new_v4();
674 let owner_id = Uuid::new_v4();
675
676 let now = Utc::now();
678 let expense = Expense {
679 id: Uuid::new_v4(),
680 organization_id: Uuid::new_v4(),
681 building_id,
682 category: ExpenseCategory::Utilities,
683 description: "Electricity".to_string(),
684 amount: 1000.0,
685 amount_excl_vat: Some(1000.0),
686 vat_rate: Some(21.0),
687 vat_amount: Some(210.0),
688 amount_incl_vat: Some(1210.0),
689 expense_date: now,
690 invoice_date: Some(now),
691 due_date: None,
692 paid_date: None,
693 approval_status: ApprovalStatus::Approved,
694 submitted_at: Some(now),
695 approved_by: Some(Uuid::new_v4()),
696 approved_at: Some(now),
697 rejection_reason: None,
698 payment_status: PaymentStatus::Pending,
699 supplier: None,
700 invoice_number: None,
701 account_code: None,
702 contractor_report_id: None,
703 created_at: now,
704 updated_at: now,
705 };
706 let expense_id = expense.id;
707
708 let ownerships = vec![(unit_id, owner_id, 1.0)]; let dist_repo = MockChargeDistributionRepository::new();
711 let expense_repo = MockExpenseRepository::with_expense(expense);
712 let unit_owner_repo =
713 MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
714
715 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
716
717 let result = uc
718 .calculate_and_save_distribution(expense_id)
719 .await
720 .unwrap();
721
722 assert_eq!(result.len(), 1);
724 assert!((result[0].amount_due - 1210.0).abs() < 0.01);
725 }
726}