koprogo_api/application/use_cases/
financial_report_use_cases.rs

1// Application Use Cases: Financial Reports
2//
3// CREDITS & ATTRIBUTION:
4// This implementation is inspired by the Noalyss project (https://gitlab.com/noalyss/noalyss)
5// Noalyss is a free accounting software for Belgian and French accounting
6// License: GPL-2.0-or-later (GNU General Public License version 2 or later)
7// Copyright: (C) 1989, 1991 Free Software Foundation, Inc.
8// Copyright: Dany De Bontridder <dany@alchimerys.eu>
9//
10// Financial reports generation based on Belgian PCMN (Plan Comptable Minimum Normalisé)
11// Inspired by Noalyss' balance sheet and income statement reports
12
13use crate::application::ports::{AccountRepository, ExpenseRepository, JournalEntryRepository};
14use crate::domain::entities::AccountType;
15use serde::Serialize;
16use std::collections::HashMap;
17use std::sync::Arc;
18use uuid::Uuid;
19
20pub struct FinancialReportUseCases {
21    account_repo: Arc<dyn AccountRepository>,
22    #[allow(dead_code)]
23    expense_repo: Arc<dyn ExpenseRepository>,
24    journal_entry_repo: Arc<dyn JournalEntryRepository>,
25}
26
27#[derive(Debug, Serialize)]
28pub struct BalanceSheetReport {
29    /// Organization ID
30    pub organization_id: String,
31    /// Report generation date (ISO 8601)
32    pub report_date: String,
33    /// Assets section (Classes 2-5 in Belgian PCMN)
34    pub assets: AccountSection,
35    /// Liabilities section (Class 1 in Belgian PCMN)
36    pub liabilities: AccountSection,
37    /// Equity section (Capital + Net Result)
38    pub equity: AccountSection,
39    /// Total assets value
40    pub total_assets: f64,
41    /// Total liabilities value
42    pub total_liabilities: f64,
43    /// Total equity value
44    pub total_equity: f64,
45    /// Balance (should be 0 in a balanced sheet: Assets = Liabilities + Equity)
46    pub balance: f64,
47}
48
49#[derive(Debug, Serialize)]
50pub struct IncomeStatementReport {
51    /// Organization ID
52    pub organization_id: String,
53    /// Report generation date (ISO 8601)
54    pub report_date: String,
55    /// Date range start (ISO 8601)
56    pub period_start: String,
57    /// Date range end (ISO 8601)
58    pub period_end: String,
59    /// Expenses section (Class 6 in Belgian PCMN)
60    pub expenses: AccountSection,
61    /// Revenue section (Class 7 in Belgian PCMN)
62    pub revenue: AccountSection,
63    /// Total expenses
64    pub total_expenses: f64,
65    /// Total revenue
66    pub total_revenue: f64,
67    /// Net result (revenue - expenses)
68    pub net_result: f64,
69}
70
71#[derive(Debug, Serialize)]
72pub struct AccountSection {
73    /// Account type (ASSET, LIABILITY, EXPENSE, REVENUE)
74    pub account_type: String,
75    /// List of account lines with balances
76    pub accounts: Vec<AccountLine>,
77    /// Section total
78    pub total: f64,
79}
80
81#[derive(Debug, Serialize)]
82pub struct AccountLine {
83    /// Account code (e.g., "604001")
84    pub code: String,
85    /// Account label (e.g., "Électricité")
86    pub label: String,
87    /// Account balance/amount
88    pub amount: f64,
89}
90
91impl FinancialReportUseCases {
92    pub fn new(
93        account_repo: Arc<dyn AccountRepository>,
94        expense_repo: Arc<dyn ExpenseRepository>,
95        journal_entry_repo: Arc<dyn JournalEntryRepository>,
96    ) -> Self {
97        Self {
98            account_repo,
99            expense_repo,
100            journal_entry_repo,
101        }
102    }
103
104    /// Generate a balance sheet report for an organization
105    ///
106    /// Balance sheet shows (following Belgian PCMN and accounting equation):
107    /// - Assets (Classes 2-5): Buildings, receivables, bank, cash
108    /// - Liabilities (Class 1): Capital, reserves, provisions, payables
109    /// - Equity: Net result from revenues - expenses
110    ///
111    /// Accounting equation: Assets = Liabilities + Equity
112    ///
113    /// Inspired by Noalyss' balance sheet generation
114    pub async fn generate_balance_sheet(
115        &self,
116        organization_id: Uuid,
117    ) -> Result<BalanceSheetReport, String> {
118        // Fetch all accounts for the organization
119        let all_accounts = self
120            .account_repo
121            .find_by_organization(organization_id)
122            .await?;
123
124        // Calculate account balances from journal entries
125        let account_balances = self.calculate_account_balances(organization_id).await?;
126
127        // Separate assets, liabilities, expenses, and revenues
128        let mut assets_accounts = Vec::new();
129        let mut liabilities_accounts = Vec::new();
130        let mut expense_accounts = Vec::new();
131        let mut revenue_accounts = Vec::new();
132
133        for account in all_accounts {
134            let amount = account_balances.get(&account.code).cloned().unwrap_or(0.0);
135
136            let line = AccountLine {
137                code: account.code.clone(),
138                label: account.label.clone(),
139                amount,
140            };
141
142            match account.account_type {
143                AccountType::Asset => assets_accounts.push(line),
144                AccountType::Liability => liabilities_accounts.push(line),
145                AccountType::Expense => expense_accounts.push(line),
146                AccountType::Revenue => revenue_accounts.push(line),
147                AccountType::OffBalance => {} // Off-balance accounts not shown in balance sheet
148            }
149        }
150
151        // Calculate totals
152        let total_assets: f64 = assets_accounts.iter().map(|a| a.amount).sum();
153        let total_liabilities: f64 = liabilities_accounts.iter().map(|a| a.amount).sum();
154        let total_expenses: f64 = expense_accounts.iter().map(|a| a.amount).sum();
155        let total_revenue: f64 = revenue_accounts.iter().map(|a| a.amount).sum();
156
157        // Calculate net result (profit/loss) - this is part of equity
158        let net_result = total_revenue - total_expenses;
159
160        // Create equity section with net result
161        let equity_accounts = vec![AccountLine {
162            code: "RESULT".to_string(),
163            label: if net_result >= 0.0 {
164                "Résultat de l'exercice (Bénéfice)".to_string()
165            } else {
166                "Résultat de l'exercice (Perte)".to_string()
167            },
168            amount: net_result,
169        }];
170
171        let total_equity = net_result;
172
173        // Balance check: Assets = Liabilities + Equity
174        let balance = total_assets - (total_liabilities + total_equity);
175
176        Ok(BalanceSheetReport {
177            organization_id: organization_id.to_string(),
178            report_date: chrono::Utc::now().to_rfc3339(),
179            assets: AccountSection {
180                account_type: "ASSET".to_string(),
181                accounts: assets_accounts,
182                total: total_assets,
183            },
184            liabilities: AccountSection {
185                account_type: "LIABILITY".to_string(),
186                accounts: liabilities_accounts,
187                total: total_liabilities,
188            },
189            equity: AccountSection {
190                account_type: "EQUITY".to_string(),
191                accounts: equity_accounts,
192                total: total_equity,
193            },
194            total_assets,
195            total_liabilities,
196            total_equity,
197            balance,
198        })
199    }
200
201    /// Generate an income statement (profit & loss) report
202    ///
203    /// Income statement shows:
204    /// - Expenses (Class 6): Operating costs, maintenance, utilities
205    /// - Revenue (Class 7): Regular fees, extraordinary fees, interest income
206    ///
207    /// Inspired by Noalyss' income statement generation
208    pub async fn generate_income_statement(
209        &self,
210        organization_id: Uuid,
211        period_start: chrono::DateTime<chrono::Utc>,
212        period_end: chrono::DateTime<chrono::Utc>,
213    ) -> Result<IncomeStatementReport, String> {
214        // Fetch all accounts for the organization
215        let all_accounts = self
216            .account_repo
217            .find_by_organization(organization_id)
218            .await?;
219
220        // Calculate account balances for the period
221        let expense_amounts = self
222            .calculate_account_balances_for_period(organization_id, period_start, period_end)
223            .await?;
224
225        // Separate expenses and revenue
226        let mut expense_accounts = Vec::new();
227        let mut revenue_accounts = Vec::new();
228
229        for account in all_accounts {
230            let amount = expense_amounts.get(&account.code).cloned().unwrap_or(0.0);
231
232            // Only include accounts with non-zero amounts
233            if amount == 0.0 {
234                continue;
235            }
236
237            let line = AccountLine {
238                code: account.code.clone(),
239                label: account.label.clone(),
240                amount,
241            };
242
243            match account.account_type {
244                AccountType::Expense => expense_accounts.push(line),
245                AccountType::Revenue => revenue_accounts.push(line),
246                _ => {} // Skip asset/liability accounts in income statement
247            }
248        }
249
250        // Calculate totals
251        let total_expenses: f64 = expense_accounts.iter().map(|a| a.amount).sum();
252        let total_revenue: f64 = revenue_accounts.iter().map(|a| a.amount).sum();
253        let net_result = total_revenue - total_expenses;
254
255        Ok(IncomeStatementReport {
256            organization_id: organization_id.to_string(),
257            report_date: chrono::Utc::now().to_rfc3339(),
258            period_start: period_start.to_rfc3339(),
259            period_end: period_end.to_rfc3339(),
260            expenses: AccountSection {
261                account_type: "EXPENSE".to_string(),
262                accounts: expense_accounts,
263                total: total_expenses,
264            },
265            revenue: AccountSection {
266                account_type: "REVENUE".to_string(),
267                accounts: revenue_accounts,
268                total: total_revenue,
269            },
270            total_expenses,
271            total_revenue,
272            net_result,
273        })
274    }
275
276    /// Calculate account balances from journal entries (double-entry bookkeeping)
277    ///
278    /// NEW: Uses journal entries instead of directly summing expenses.
279    /// This properly implements double-entry accounting where:
280    /// - Assets/Expenses: balance = debits - credits
281    /// - Liabilities/Revenue: balance = credits - debits
282    async fn calculate_account_balances(
283        &self,
284        organization_id: Uuid,
285    ) -> Result<HashMap<String, f64>, String> {
286        // Use the journal entry repository to calculate balances
287        // This leverages the account_balances view created in the migration
288        self.journal_entry_repo
289            .calculate_account_balances(organization_id)
290            .await
291    }
292
293    /// Calculate account balances for a specific time period from journal entries
294    ///
295    /// NEW: Uses journal entries filtered by date range.
296    async fn calculate_account_balances_for_period(
297        &self,
298        organization_id: Uuid,
299        period_start: chrono::DateTime<chrono::Utc>,
300        period_end: chrono::DateTime<chrono::Utc>,
301    ) -> Result<HashMap<String, f64>, String> {
302        // Use the journal entry repository to calculate balances for the period
303        self.journal_entry_repo
304            .calculate_account_balances_for_period(organization_id, period_start, period_end)
305            .await
306    }
307
308    /// Generate a balance sheet report for a specific building
309    pub async fn generate_balance_sheet_for_building(
310        &self,
311        organization_id: Uuid,
312        building_id: Uuid,
313    ) -> Result<BalanceSheetReport, String> {
314        // Fetch all accounts for the organization
315        let all_accounts = self
316            .account_repo
317            .find_by_organization(organization_id)
318            .await?;
319
320        // Calculate account balances for the building
321        let account_balances = self
322            .journal_entry_repo
323            .calculate_account_balances_for_building(organization_id, building_id)
324            .await?;
325
326        // Same logic as organization-level balance sheet
327        let mut asset_accounts = Vec::new();
328        let mut liability_accounts = Vec::new();
329
330        for account in all_accounts {
331            let balance = account_balances.get(&account.code).cloned().unwrap_or(0.0);
332            if balance == 0.0 {
333                continue;
334            }
335
336            let line = AccountLine {
337                code: account.code.clone(),
338                label: account.label.clone(),
339                amount: balance.abs(),
340            };
341
342            match account.account_type {
343                AccountType::Asset => asset_accounts.push(line),
344                AccountType::Liability => liability_accounts.push(line),
345                _ => {}
346            }
347        }
348
349        let total_assets: f64 = asset_accounts.iter().map(|a| a.amount).sum();
350        let total_liabilities: f64 = liability_accounts.iter().map(|a| a.amount).sum();
351
352        // Calculate net result from revenue - expenses
353        let total_revenue: f64 = account_balances
354            .iter()
355            .filter(|(code, _)| code.starts_with('7'))
356            .map(|(_, balance)| *balance)
357            .sum();
358        let total_expenses: f64 = account_balances
359            .iter()
360            .filter(|(code, _)| code.starts_with('6'))
361            .map(|(_, balance)| *balance)
362            .sum();
363        let net_result = total_revenue - total_expenses;
364
365        let equity_line = AccountLine {
366            code: "RESULT".to_string(),
367            label: if net_result >= 0.0 {
368                "Résultat de l'exercice (Bénéfice)".to_string()
369            } else {
370                "Résultat de l'exercice (Perte)".to_string()
371            },
372            amount: net_result.abs(),
373        };
374
375        let total_equity = net_result;
376        let balance = total_assets - (total_liabilities + total_equity);
377
378        Ok(BalanceSheetReport {
379            organization_id: organization_id.to_string(),
380            report_date: chrono::Utc::now().to_rfc3339(),
381            assets: AccountSection {
382                account_type: "ASSET".to_string(),
383                accounts: asset_accounts,
384                total: total_assets,
385            },
386            liabilities: AccountSection {
387                account_type: "LIABILITY".to_string(),
388                accounts: liability_accounts,
389                total: total_liabilities,
390            },
391            equity: AccountSection {
392                account_type: "EQUITY".to_string(),
393                accounts: vec![equity_line],
394                total: total_equity,
395            },
396            total_assets,
397            total_liabilities,
398            total_equity,
399            balance,
400        })
401    }
402
403    /// Generate an income statement report for a specific building and period
404    pub async fn generate_income_statement_for_building(
405        &self,
406        organization_id: Uuid,
407        building_id: Uuid,
408        period_start: chrono::DateTime<chrono::Utc>,
409        period_end: chrono::DateTime<chrono::Utc>,
410    ) -> Result<IncomeStatementReport, String> {
411        // Fetch all accounts for the organization
412        let all_accounts = self
413            .account_repo
414            .find_by_organization(organization_id)
415            .await?;
416
417        // Calculate account balances for the building and period
418        let account_balances = self
419            .journal_entry_repo
420            .calculate_account_balances_for_building_and_period(
421                organization_id,
422                building_id,
423                period_start,
424                period_end,
425            )
426            .await?;
427
428        // Separate expenses and revenue
429        let mut expense_accounts = Vec::new();
430        let mut revenue_accounts = Vec::new();
431
432        for account in all_accounts {
433            let amount = account_balances.get(&account.code).cloned().unwrap_or(0.0);
434            if amount == 0.0 {
435                continue;
436            }
437
438            let line = AccountLine {
439                code: account.code.clone(),
440                label: account.label.clone(),
441                amount,
442            };
443
444            match account.account_type {
445                AccountType::Expense => expense_accounts.push(line),
446                AccountType::Revenue => revenue_accounts.push(line),
447                _ => {}
448            }
449        }
450
451        let total_expenses: f64 = expense_accounts.iter().map(|a| a.amount).sum();
452        let total_revenue: f64 = revenue_accounts.iter().map(|a| a.amount).sum();
453        let net_result = total_revenue - total_expenses;
454
455        Ok(IncomeStatementReport {
456            organization_id: organization_id.to_string(),
457            report_date: chrono::Utc::now().to_rfc3339(),
458            period_start: period_start.to_rfc3339(),
459            period_end: period_end.to_rfc3339(),
460            expenses: AccountSection {
461                account_type: "EXPENSE".to_string(),
462                accounts: expense_accounts,
463                total: total_expenses,
464            },
465            revenue: AccountSection {
466                account_type: "REVENUE".to_string(),
467                accounts: revenue_accounts,
468                total: total_revenue,
469            },
470            total_expenses,
471            total_revenue,
472            net_result,
473        })
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    // Note: These are unit tests for business logic.
482    // Integration tests with real database are in tests/integration/
483
484    #[test]
485    fn test_balance_sheet_report_structure() {
486        // Test that BalanceSheetReport serializes correctly
487        let report = BalanceSheetReport {
488            organization_id: "test-org".to_string(),
489            report_date: "2024-01-01T00:00:00Z".to_string(),
490            assets: AccountSection {
491                account_type: "ASSET".to_string(),
492                accounts: vec![AccountLine {
493                    code: "550".to_string(),
494                    label: "Banque".to_string(),
495                    amount: 10000.0,
496                }],
497                total: 10000.0,
498            },
499            liabilities: AccountSection {
500                account_type: "LIABILITY".to_string(),
501                accounts: vec![AccountLine {
502                    code: "4400".to_string(),
503                    label: "Fournisseurs".to_string(),
504                    amount: 8000.0,
505                }],
506                total: 8000.0,
507            },
508            equity: AccountSection {
509                account_type: "EQUITY".to_string(),
510                accounts: vec![AccountLine {
511                    code: "RESULT".to_string(),
512                    label: "Résultat de l'exercice (Bénéfice)".to_string(),
513                    amount: 2000.0,
514                }],
515                total: 2000.0,
516            },
517            total_assets: 10000.0,
518            total_liabilities: 8000.0,
519            total_equity: 2000.0,
520            balance: 0.0,
521        };
522
523        assert_eq!(report.total_assets, 10000.0);
524        assert_eq!(report.total_liabilities, 8000.0);
525        assert_eq!(report.total_equity, 2000.0);
526        // Assets = Liabilities + Equity
527        assert_eq!(
528            report.total_assets,
529            report.total_liabilities + report.total_equity
530        );
531        assert_eq!(report.balance, 0.0);
532    }
533
534    #[test]
535    fn test_income_statement_report_structure() {
536        // Test that IncomeStatementReport calculates net result correctly
537        let report = IncomeStatementReport {
538            organization_id: "test-org".to_string(),
539            report_date: "2024-01-01T00:00:00Z".to_string(),
540            period_start: "2024-01-01T00:00:00Z".to_string(),
541            period_end: "2024-12-31T23:59:59Z".to_string(),
542            expenses: AccountSection {
543                account_type: "EXPENSE".to_string(),
544                accounts: vec![AccountLine {
545                    code: "604001".to_string(),
546                    label: "Électricité".to_string(),
547                    amount: 5000.0,
548                }],
549                total: 5000.0,
550            },
551            revenue: AccountSection {
552                account_type: "REVENUE".to_string(),
553                accounts: vec![AccountLine {
554                    code: "700001".to_string(),
555                    label: "Appels de fonds".to_string(),
556                    amount: 8000.0,
557                }],
558                total: 8000.0,
559            },
560            total_expenses: 5000.0,
561            total_revenue: 8000.0,
562            net_result: 3000.0,
563        };
564
565        assert_eq!(report.total_expenses, 5000.0);
566        assert_eq!(report.total_revenue, 8000.0);
567        assert_eq!(report.net_result, 3000.0); // Profit
568    }
569
570    #[test]
571    fn test_income_statement_loss() {
572        // Test negative net result (loss)
573        let total_expenses = 10000.0;
574        let total_revenue = 7000.0;
575        let net_result = total_revenue - total_expenses;
576
577        assert_eq!(net_result, -3000.0); // Loss
578    }
579}