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 type BuildingOwnerships = HashMap<Uuid, Vec<(Uuid, Uuid, f64)>>;
308
309 struct MockUnitOwnerRepository {
310 building_ownerships: Mutex<BuildingOwnerships>,
312 }
313
314 impl MockUnitOwnerRepository {
315 fn new() -> Self {
316 Self {
317 building_ownerships: Mutex::new(HashMap::new()),
318 }
319 }
320
321 fn with_building_ownerships(building_id: Uuid, ownerships: Vec<(Uuid, Uuid, f64)>) -> Self {
322 let mut map = HashMap::new();
323 map.insert(building_id, ownerships);
324 Self {
325 building_ownerships: Mutex::new(map),
326 }
327 }
328 }
329
330 #[async_trait]
331 impl UnitOwnerRepository for MockUnitOwnerRepository {
332 async fn create(&self, _unit_owner: &UnitOwner) -> Result<UnitOwner, String> {
333 unimplemented!("not needed for charge distribution tests")
334 }
335
336 async fn find_by_id(&self, _id: Uuid) -> Result<Option<UnitOwner>, String> {
337 unimplemented!("not needed for charge distribution tests")
338 }
339
340 async fn find_current_owners_by_unit(
341 &self,
342 _unit_id: Uuid,
343 ) -> Result<Vec<UnitOwner>, String> {
344 unimplemented!("not needed for charge distribution tests")
345 }
346
347 async fn find_current_units_by_owner(
348 &self,
349 _owner_id: Uuid,
350 ) -> Result<Vec<UnitOwner>, String> {
351 unimplemented!("not needed for charge distribution tests")
352 }
353
354 async fn find_all_owners_by_unit(&self, _unit_id: Uuid) -> Result<Vec<UnitOwner>, String> {
355 unimplemented!("not needed for charge distribution tests")
356 }
357
358 async fn find_all_units_by_owner(&self, _owner_id: Uuid) -> Result<Vec<UnitOwner>, String> {
359 unimplemented!("not needed for charge distribution tests")
360 }
361
362 async fn update(&self, _unit_owner: &UnitOwner) -> Result<UnitOwner, String> {
363 unimplemented!("not needed for charge distribution tests")
364 }
365
366 async fn delete(&self, _id: Uuid) -> Result<(), String> {
367 unimplemented!("not needed for charge distribution tests")
368 }
369
370 async fn has_active_owners(&self, _unit_id: Uuid) -> Result<bool, String> {
371 unimplemented!("not needed for charge distribution tests")
372 }
373
374 async fn get_total_ownership_percentage(&self, _unit_id: Uuid) -> Result<f64, String> {
375 unimplemented!("not needed for charge distribution tests")
376 }
377
378 async fn find_active_by_unit_and_owner(
379 &self,
380 _unit_id: Uuid,
381 _owner_id: Uuid,
382 ) -> Result<Option<UnitOwner>, String> {
383 unimplemented!("not needed for charge distribution tests")
384 }
385
386 async fn find_active_by_building(
387 &self,
388 building_id: Uuid,
389 ) -> Result<Vec<(Uuid, Uuid, f64)>, String> {
390 let ownerships = self.building_ownerships.lock().unwrap();
391 Ok(ownerships.get(&building_id).cloned().unwrap_or_default())
392 }
393 }
394
395 fn make_approved_expense(building_id: Uuid, amount_incl_vat: f64) -> Expense {
398 let now = Utc::now();
399 Expense {
400 id: Uuid::new_v4(),
401 organization_id: Uuid::new_v4(),
402 building_id,
403 category: ExpenseCategory::Maintenance,
404 description: "Elevator maintenance".to_string(),
405 amount: amount_incl_vat,
406 amount_excl_vat: Some(amount_incl_vat / 1.21),
407 vat_rate: Some(21.0),
408 vat_amount: Some(amount_incl_vat - amount_incl_vat / 1.21),
409 amount_incl_vat: Some(amount_incl_vat),
410 expense_date: now,
411 invoice_date: Some(now),
412 due_date: None,
413 paid_date: None,
414 approval_status: ApprovalStatus::Approved,
415 submitted_at: Some(now),
416 approved_by: Some(Uuid::new_v4()),
417 approved_at: Some(now),
418 rejection_reason: None,
419 payment_status: PaymentStatus::Pending,
420 supplier: Some("Schindler SA".to_string()),
421 invoice_number: Some("INV-001".to_string()),
422 account_code: Some("611002".to_string()),
423 contractor_report_id: None,
424 created_at: now,
425 updated_at: now,
426 }
427 }
428
429 fn make_draft_expense(building_id: Uuid) -> Expense {
430 let now = Utc::now();
431 Expense {
432 id: Uuid::new_v4(),
433 organization_id: Uuid::new_v4(),
434 building_id,
435 category: ExpenseCategory::Maintenance,
436 description: "Draft expense".to_string(),
437 amount: 1000.0,
438 amount_excl_vat: None,
439 vat_rate: None,
440 vat_amount: None,
441 amount_incl_vat: None,
442 expense_date: now,
443 invoice_date: None,
444 due_date: None,
445 paid_date: None,
446 approval_status: ApprovalStatus::Draft,
447 submitted_at: None,
448 approved_by: None,
449 approved_at: None,
450 rejection_reason: None,
451 payment_status: PaymentStatus::Pending,
452 supplier: None,
453 invoice_number: None,
454 account_code: None,
455 contractor_report_id: None,
456 created_at: now,
457 updated_at: now,
458 }
459 }
460
461 fn make_use_cases(
462 dist_repo: MockChargeDistributionRepository,
463 expense_repo: MockExpenseRepository,
464 unit_owner_repo: MockUnitOwnerRepository,
465 ) -> ChargeDistributionUseCases {
466 ChargeDistributionUseCases::new(
467 Arc::new(dist_repo),
468 Arc::new(expense_repo),
469 Arc::new(unit_owner_repo),
470 )
471 }
472
473 #[tokio::test]
476 async fn test_calculate_and_save_distribution_success() {
477 let building_id = Uuid::new_v4();
478 let unit1_id = Uuid::new_v4();
479 let unit2_id = Uuid::new_v4();
480 let owner1_id = Uuid::new_v4();
481 let owner2_id = Uuid::new_v4();
482
483 let expense = make_approved_expense(building_id, 1000.0);
484 let expense_id = expense.id;
485
486 let ownerships = vec![
487 (unit1_id, owner1_id, 0.60), (unit2_id, owner2_id, 0.40), ];
490
491 let dist_repo = MockChargeDistributionRepository::new();
492 let expense_repo = MockExpenseRepository::with_expense(expense);
493 let unit_owner_repo =
494 MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
495
496 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
497
498 let result = uc.calculate_and_save_distribution(expense_id).await;
499 assert!(result.is_ok());
500
501 let distributions = result.unwrap();
502 assert_eq!(distributions.len(), 2);
503
504 let total_amount: f64 = distributions.iter().map(|d| d.amount_due).sum();
506 assert!((total_amount - 1000.0).abs() < 0.01);
507
508 assert!(distributions
510 .iter()
511 .all(|d| d.expense_id == expense_id.to_string()));
512 }
513
514 #[tokio::test]
515 async fn test_calculate_and_save_distribution_non_approved_expense() {
516 let building_id = Uuid::new_v4();
517 let expense = make_draft_expense(building_id);
518 let expense_id = expense.id;
519
520 let dist_repo = MockChargeDistributionRepository::new();
521 let expense_repo = MockExpenseRepository::with_expense(expense);
522 let unit_owner_repo = MockUnitOwnerRepository::new();
523
524 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
525
526 let result = uc.calculate_and_save_distribution(expense_id).await;
527 assert!(result.is_err());
528 let err = result.unwrap_err();
529 assert!(err.contains("non-approved invoice"));
530 }
531
532 #[tokio::test]
533 async fn test_calculate_and_save_distribution_expense_not_found() {
534 let dist_repo = MockChargeDistributionRepository::new();
535 let expense_repo = MockExpenseRepository::new(); let unit_owner_repo = MockUnitOwnerRepository::new();
537
538 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
539
540 let result = uc.calculate_and_save_distribution(Uuid::new_v4()).await;
541 assert!(result.is_err());
542 assert_eq!(result.unwrap_err(), "Expense/Invoice not found");
543 }
544
545 #[tokio::test]
546 async fn test_calculate_and_save_distribution_no_active_owners() {
547 let building_id = Uuid::new_v4();
548 let expense = make_approved_expense(building_id, 1000.0);
549 let expense_id = expense.id;
550
551 let dist_repo = MockChargeDistributionRepository::new();
552 let expense_repo = MockExpenseRepository::with_expense(expense);
553 let unit_owner_repo =
555 MockUnitOwnerRepository::with_building_ownerships(building_id, vec![]);
556
557 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
558
559 let result = uc.calculate_and_save_distribution(expense_id).await;
560 assert!(result.is_err());
561 assert_eq!(
562 result.unwrap_err(),
563 "No active unit-owner relationships found for this building"
564 );
565 }
566
567 #[tokio::test]
568 async fn test_get_distribution_by_expense() {
569 let building_id = Uuid::new_v4();
570 let unit1_id = Uuid::new_v4();
571 let owner1_id = Uuid::new_v4();
572
573 let expense = make_approved_expense(building_id, 500.0);
574 let expense_id = expense.id;
575
576 let ownerships = vec![(unit1_id, owner1_id, 1.0)]; let dist_repo = MockChargeDistributionRepository::new();
579 let expense_repo = MockExpenseRepository::with_expense(expense);
580 let unit_owner_repo =
581 MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
582
583 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
584
585 uc.calculate_and_save_distribution(expense_id)
587 .await
588 .unwrap();
589
590 let result = uc.get_distribution_by_expense(expense_id).await;
592 assert!(result.is_ok());
593 let distributions = result.unwrap();
594 assert_eq!(distributions.len(), 1);
595 assert_eq!(distributions[0].expense_id, expense_id.to_string());
596 assert_eq!(distributions[0].quota_percentage, 1.0);
597 assert!((distributions[0].amount_due - 500.0).abs() < 0.01);
598 }
599
600 #[tokio::test]
601 async fn test_get_distribution_by_expense_empty() {
602 let dist_repo = MockChargeDistributionRepository::new();
603 let expense_repo = MockExpenseRepository::new();
604 let unit_owner_repo = MockUnitOwnerRepository::new();
605
606 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
607
608 let result = uc.get_distribution_by_expense(Uuid::new_v4()).await;
609 assert!(result.is_ok());
610 assert!(result.unwrap().is_empty());
611 }
612
613 #[tokio::test]
614 async fn test_get_total_due_by_owner() {
615 let building_id = Uuid::new_v4();
616 let unit1_id = Uuid::new_v4();
617 let unit2_id = Uuid::new_v4();
618 let owner1_id = Uuid::new_v4();
619 let owner2_id = Uuid::new_v4();
620
621 let expense1 = make_approved_expense(building_id, 1000.0);
623 let expense1_id = expense1.id;
624 let expense2 = make_approved_expense(building_id, 2000.0);
625 let expense2_id = expense2.id;
626
627 let ownerships = vec![
628 (unit1_id, owner1_id, 0.60), (unit2_id, owner2_id, 0.40), ];
631
632 let dist_repo = MockChargeDistributionRepository::new();
633 let mut expense_map = HashMap::new();
634 expense_map.insert(expense1.id, expense1);
635 expense_map.insert(expense2.id, expense2);
636 let expense_repo = MockExpenseRepository {
637 expenses: Mutex::new(expense_map),
638 };
639 let unit_owner_repo =
640 MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
641
642 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
643
644 uc.calculate_and_save_distribution(expense1_id)
646 .await
647 .unwrap();
648 uc.calculate_and_save_distribution(expense2_id)
649 .await
650 .unwrap();
651
652 let total = uc.get_total_due_by_owner(owner1_id).await.unwrap();
654 assert!((total - 1800.0).abs() < 0.01);
655
656 let total = uc.get_total_due_by_owner(owner2_id).await.unwrap();
658 assert!((total - 1200.0).abs() < 0.01);
659 }
660
661 #[tokio::test]
662 async fn test_get_total_due_by_owner_no_distributions() {
663 let dist_repo = MockChargeDistributionRepository::new();
664 let expense_repo = MockExpenseRepository::new();
665 let unit_owner_repo = MockUnitOwnerRepository::new();
666
667 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
668
669 let total = uc.get_total_due_by_owner(Uuid::new_v4()).await.unwrap();
670 assert_eq!(total, 0.0);
671 }
672
673 #[tokio::test]
674 async fn test_calculate_distribution_uses_amount_incl_vat() {
675 let building_id = Uuid::new_v4();
676 let unit_id = Uuid::new_v4();
677 let owner_id = Uuid::new_v4();
678
679 let now = Utc::now();
681 let expense = Expense {
682 id: Uuid::new_v4(),
683 organization_id: Uuid::new_v4(),
684 building_id,
685 category: ExpenseCategory::Utilities,
686 description: "Electricity".to_string(),
687 amount: 1000.0,
688 amount_excl_vat: Some(1000.0),
689 vat_rate: Some(21.0),
690 vat_amount: Some(210.0),
691 amount_incl_vat: Some(1210.0),
692 expense_date: now,
693 invoice_date: Some(now),
694 due_date: None,
695 paid_date: None,
696 approval_status: ApprovalStatus::Approved,
697 submitted_at: Some(now),
698 approved_by: Some(Uuid::new_v4()),
699 approved_at: Some(now),
700 rejection_reason: None,
701 payment_status: PaymentStatus::Pending,
702 supplier: None,
703 invoice_number: None,
704 account_code: None,
705 contractor_report_id: None,
706 created_at: now,
707 updated_at: now,
708 };
709 let expense_id = expense.id;
710
711 let ownerships = vec![(unit_id, owner_id, 1.0)]; let dist_repo = MockChargeDistributionRepository::new();
714 let expense_repo = MockExpenseRepository::with_expense(expense);
715 let unit_owner_repo =
716 MockUnitOwnerRepository::with_building_ownerships(building_id, ownerships);
717
718 let uc = make_use_cases(dist_repo, expense_repo, unit_owner_repo);
719
720 let result = uc
721 .calculate_and_save_distribution(expense_id)
722 .await
723 .unwrap();
724
725 assert_eq!(result.len(), 1);
727 assert!((result[0].amount_due - 1210.0).abs() < 0.01);
728 }
729}