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