Skip to main content

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