Skip to main content

koprogo_api/application/use_cases/
dashboard_use_cases.rs

1// Application Use Cases: Dashboard
2//
3// Business logic for dashboard statistics and recent transactions
4
5use crate::application::dto::{
6    AccountantDashboardStats, ExpenseFilters, PageRequest, RecentTransaction, TransactionType,
7};
8use crate::application::ports::{
9    ExpenseRepository, OwnerContributionRepository, PaymentReminderRepository,
10};
11use crate::domain::entities::{ApprovalStatus, ReminderStatus};
12use chrono::{Datelike, Timelike, Utc};
13use rust_decimal::Decimal;
14use rust_decimal_macros::dec;
15use std::collections::HashSet;
16use std::sync::Arc;
17use uuid::Uuid;
18
19pub struct DashboardUseCases {
20    expense_repo: Arc<dyn ExpenseRepository>,
21    owner_contribution_repo: Arc<dyn OwnerContributionRepository>,
22    payment_reminder_repo: Arc<dyn PaymentReminderRepository>,
23}
24
25impl DashboardUseCases {
26    pub fn new(
27        expense_repo: Arc<dyn ExpenseRepository>,
28        owner_contribution_repo: Arc<dyn OwnerContributionRepository>,
29        payment_reminder_repo: Arc<dyn PaymentReminderRepository>,
30    ) -> Self {
31        Self {
32            expense_repo,
33            owner_contribution_repo,
34            payment_reminder_repo,
35        }
36    }
37
38    /// Get accountant dashboard statistics
39    pub async fn get_accountant_stats(
40        &self,
41        organization_id: Uuid,
42    ) -> Result<AccountantDashboardStats, String> {
43        // Get all expenses for the organization
44        let filters = ExpenseFilters {
45            organization_id: Some(organization_id),
46            ..Default::default()
47        };
48
49        // Get all expenses (use large page size to get all)
50        let page_request = PageRequest {
51            page: 1,
52            per_page: 10000, // Large enough to get all expenses
53            sort_by: None,
54            order: Default::default(),
55        };
56
57        let (all_expenses, _total) = self
58            .expense_repo
59            .find_all_paginated(&page_request, &filters)
60            .await?;
61
62        // Get current month start date
63        let now = Utc::now();
64        let current_month_start = Utc::now()
65            .with_day(1)
66            .and_then(|d| d.with_hour(0))
67            .and_then(|d| d.with_minute(0))
68            .and_then(|d| d.with_second(0))
69            .unwrap_or(now);
70
71        // Filter expenses for current month
72        let current_month_expenses: Vec<_> = all_expenses
73            .iter()
74            .filter(|e| e.expense_date >= current_month_start)
75            .collect();
76
77        // Calculate total expenses for current month
78        let total_expenses_current_month: Decimal = current_month_expenses
79            .iter()
80            .map(|e| e.amount_incl_vat.unwrap_or(Decimal::ZERO))
81            .sum();
82
83        // Calculate paid expenses (status = Approved AND paid_date is set)
84        let paid_expenses: Vec<_> = all_expenses
85            .iter()
86            .filter(|e| e.approval_status == ApprovalStatus::Approved && e.paid_date.is_some())
87            .collect();
88
89        let total_paid: Decimal = paid_expenses
90            .iter()
91            .map(|e| e.amount_incl_vat.unwrap_or(Decimal::ZERO))
92            .sum();
93
94        // Calculate pending expenses (not paid)
95        let pending_expenses: Vec<_> = all_expenses
96            .iter()
97            .filter(|e| e.paid_date.is_none())
98            .collect();
99
100        let total_pending: Decimal = pending_expenses
101            .iter()
102            .map(|e| e.amount_incl_vat.unwrap_or(Decimal::ZERO))
103            .sum();
104
105        // Calculate percentages
106        let total_all = total_paid + total_pending;
107        let paid_percentage = if total_all > Decimal::ZERO {
108            (total_paid / total_all) * dec!(100)
109        } else {
110            Decimal::ZERO
111        };
112        let pending_percentage = if total_all > Decimal::ZERO {
113            (total_pending / total_all) * dec!(100)
114        } else {
115            Decimal::ZERO
116        };
117
118        // Calculate owners with overdue payments from payment_reminders
119        // Get all reminders for the organization
120        let all_reminders = self
121            .payment_reminder_repo
122            .find_by_organization(organization_id)
123            .await?;
124
125        // Filter for active reminders (not Paid or Cancelled)
126        let active_reminders: Vec<_> = all_reminders
127            .iter()
128            .filter(|r| r.status != ReminderStatus::Paid && r.status != ReminderStatus::Cancelled)
129            .collect();
130
131        // Count unique owners with active reminders
132        let unique_owners: HashSet<Uuid> = active_reminders.iter().map(|r| r.owner_id).collect();
133
134        let owners_with_overdue = unique_owners.len() as i64;
135
136        Ok(AccountantDashboardStats {
137            total_expenses_current_month,
138            total_paid,
139            paid_percentage,
140            total_pending,
141            pending_percentage,
142            owners_with_overdue,
143        })
144    }
145
146    /// Get recent transactions for dashboard
147    pub async fn get_recent_transactions(
148        &self,
149        organization_id: Uuid,
150        limit: usize,
151    ) -> Result<Vec<RecentTransaction>, String> {
152        // Get all expenses for the organization
153        let filters = ExpenseFilters {
154            organization_id: Some(organization_id),
155            ..Default::default()
156        };
157
158        let page_request = PageRequest {
159            page: 1,
160            per_page: 1000, // Get enough for sorting
161            sort_by: None,
162            order: Default::default(),
163        };
164
165        let (all_expenses, _total) = self
166            .expense_repo
167            .find_all_paginated(&page_request, &filters)
168            .await?;
169
170        // Get all owner contributions for the organization
171        let all_contributions = self
172            .owner_contribution_repo
173            .find_by_organization(organization_id)
174            .await?;
175
176        // Convert expenses to transactions (OUTGOING = negative)
177        let expense_transactions: Vec<RecentTransaction> = all_expenses
178            .iter()
179            .map(|expense| {
180                let transaction_type = TransactionType::PaymentMade;
181                let amount_value = expense.amount_incl_vat.unwrap_or(Decimal::ZERO);
182                let amount = -amount_value; // Negative for expenses
183
184                RecentTransaction {
185                    id: expense.id,
186                    transaction_type,
187                    description: expense.description.clone(),
188                    related_entity: expense.supplier.clone(),
189                    amount,
190                    date: expense.expense_date,
191                }
192            })
193            .collect();
194
195        // Convert owner contributions to transactions (INCOMING = positive)
196        let contribution_transactions: Vec<RecentTransaction> = all_contributions
197            .iter()
198            .map(|contribution| {
199                let transaction_type = TransactionType::PaymentReceived;
200                let amount = contribution.amount; // Positive for revenue
201
202                RecentTransaction {
203                    id: contribution.id,
204                    transaction_type,
205                    description: contribution.description.clone(),
206                    related_entity: Some("Copropriétaire".to_string()), // Could link to owner name if needed
207                    amount,
208                    date: contribution.contribution_date,
209                }
210            })
211            .collect();
212
213        // Merge both transaction types
214        let mut all_transactions = Vec::new();
215        all_transactions.extend(expense_transactions);
216        all_transactions.extend(contribution_transactions);
217
218        // Sort by date (most recent first)
219        all_transactions.sort_by_key(|t| std::cmp::Reverse(t.date));
220
221        // Take the most recent ones
222        let recent_transactions: Vec<RecentTransaction> =
223            all_transactions.into_iter().take(limit).collect();
224
225        Ok(recent_transactions)
226    }
227}
228
229#[cfg(test)]
230mod tests {
231
232    #[test]
233    fn test_percentage_calculation() {
234        let total_paid = 41270.0;
235        let total_pending = 4580.0;
236        let total = total_paid + total_pending;
237
238        let paid_percentage = (total_paid / total) * 100.0;
239        let pending_percentage = (total_pending / total) * 100.0;
240
241        assert!((paid_percentage - 90.0_f64).abs() < 0.1);
242        assert!((pending_percentage - 10.0_f64).abs() < 0.1);
243    }
244
245    #[test]
246    fn test_percentage_with_zero_total() {
247        let total_paid = 0.0;
248        let total_pending = 0.0;
249        let total = total_paid + total_pending;
250
251        let paid_percentage = if total > 0.0 {
252            (total_paid / total) * 100.0
253        } else {
254            0.0
255        };
256
257        assert_eq!(paid_percentage, 0.0);
258    }
259}