koprogo_api/domain/services/
pcn_mapper.rs

1use crate::domain::entities::{Expense, ExpenseCategory};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// Belgian Chart of Accounts (Plan Comptable Normalisé - PCN) Account
6///
7/// The PCN is the standard accounting framework used in Belgium.
8/// - Class 6: Expenses (Charges) - 60x, 61x, 62x
9/// - Class 7: Income (Produits) - 70x, 71x, 72x
10///
11/// For co-ownership buildings, we primarily use Class 6 accounts.
12/// Supports 4 languages: NL (Dutch - 60%), FR (French - 40%), DE (German - <1%), EN (English - international)
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct PcnAccount {
15    /// PCN account code (e.g., "611" for maintenance, "615" for utilities)
16    pub code: String,
17    /// Account label in Dutch (Nederlands - 60% of Belgium)
18    pub label_nl: String,
19    /// Account label in French (Français - 40% of Belgium)
20    pub label_fr: String,
21    /// Account label in German (Deutsch - <1% of Belgium, legally required)
22    pub label_de: String,
23    /// Account label in English (international competitiveness)
24    pub label_en: String,
25}
26
27impl PcnAccount {
28    pub fn new(
29        code: impl Into<String>,
30        label_nl: impl Into<String>,
31        label_fr: impl Into<String>,
32        label_de: impl Into<String>,
33        label_en: impl Into<String>,
34    ) -> Self {
35        Self {
36            code: code.into(),
37            label_nl: label_nl.into(),
38            label_fr: label_fr.into(),
39            label_de: label_de.into(),
40            label_en: label_en.into(),
41        }
42    }
43}
44
45/// Belgian PCN Mapping Service
46pub struct PcnMapper;
47
48/// PCN Report Line - aggregated expenses for one PCN account
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct PcnReportLine {
51    /// PCN account
52    pub account: PcnAccount,
53    /// Total amount for this account
54    pub total_amount: f64,
55    /// Number of expense entries
56    pub entry_count: usize,
57}
58
59impl PcnMapper {
60    /// Map ExpenseCategory to Belgian PCN account
61    /// Based on Belgian Chart of Accounts (PCN) Class 6: Expenses
62    pub fn map_expense_to_pcn(category: &ExpenseCategory) -> PcnAccount {
63        match category {
64            ExpenseCategory::Works => PcnAccount::new(
65                "610",
66                "Werken en grote herstellingen",
67                "Travaux et grosses réparations",
68                "Arbeiten und große Reparaturen",
69                "Works and major repairs",
70            ),
71            ExpenseCategory::Maintenance => PcnAccount::new(
72                "611",
73                "Onderhoud en kleine herstellingen",
74                "Entretien et petites réparations",
75                "Wartung und kleine Reparaturen",
76                "Maintenance and minor repairs",
77            ),
78            ExpenseCategory::Repairs => PcnAccount::new(
79                "612",
80                "Gewone herstellingen",
81                "Réparations ordinaires",
82                "Gewöhnliche Reparaturen",
83                "Ordinary repairs",
84            ),
85            ExpenseCategory::Insurance => PcnAccount::new(
86                "613",
87                "Verzekeringen",
88                "Assurances",
89                "Versicherungen",
90                "Insurance",
91            ),
92            ExpenseCategory::Cleaning => PcnAccount::new(
93                "614",
94                "Schoonmaak en lopend onderhoud",
95                "Nettoyage et entretien courant",
96                "Reinigung und laufende Wartung",
97                "Cleaning and routine maintenance",
98            ),
99            ExpenseCategory::Utilities => PcnAccount::new(
100                "615",
101                "Water, energie en verwarming",
102                "Eau, énergie et chauffage",
103                "Wasser, Energie und Heizung",
104                "Water, energy and heating",
105            ),
106            ExpenseCategory::Administration => PcnAccount::new(
107                "620",
108                "Beheer- en administratiekosten",
109                "Frais de gestion et d'administration",
110                "Verwaltungs- und Verwaltungskosten",
111                "Management and administration costs",
112            ),
113            ExpenseCategory::Other => PcnAccount::new(
114                "619",
115                "Overige diverse kosten",
116                "Autres charges diverses",
117                "Sonstige verschiedene Kosten",
118                "Other miscellaneous expenses",
119            ),
120        }
121    }
122
123    /// Generate PCN report from a list of expenses
124    /// Aggregates expenses by PCN account code
125    /// Returns report lines sorted by PCN account code
126    pub fn generate_report(expenses: &[Expense]) -> Vec<PcnReportLine> {
127        let mut aggregated: HashMap<String, (PcnAccount, f64, usize)> = HashMap::new();
128
129        // Aggregate expenses by PCN account code
130        for expense in expenses {
131            let account = Self::map_expense_to_pcn(&expense.category);
132            let entry = aggregated
133                .entry(account.code.clone())
134                .or_insert((account, 0.0, 0));
135            entry.1 += expense.amount;
136            entry.2 += 1;
137        }
138
139        // Convert to Vec<PcnReportLine> and sort by account code
140        let mut report: Vec<PcnReportLine> = aggregated
141            .into_iter()
142            .map(|(_, (account, total_amount, entry_count))| PcnReportLine {
143                account,
144                total_amount,
145                entry_count,
146            })
147            .collect();
148
149        report.sort_by(|a, b| a.account.code.cmp(&b.account.code));
150        report
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_map_maintenance_to_pcn_611() {
160        let account = PcnMapper::map_expense_to_pcn(&ExpenseCategory::Maintenance);
161
162        assert_eq!(account.code, "611");
163        assert!(account.label_fr.contains("Entretien"));
164        assert!(!account.label_nl.is_empty());
165    }
166
167    #[test]
168    fn test_map_utilities_to_pcn_615() {
169        let account = PcnMapper::map_expense_to_pcn(&ExpenseCategory::Utilities);
170
171        assert_eq!(account.code, "615");
172        assert!(account.label_fr.contains("Eau") || account.label_fr.contains("Énergie"));
173    }
174
175    #[test]
176    fn test_map_insurance_to_pcn_613() {
177        let account = PcnMapper::map_expense_to_pcn(&ExpenseCategory::Insurance);
178
179        assert_eq!(account.code, "613");
180        assert!(account.label_fr.contains("Assurance"));
181    }
182
183    #[test]
184    fn test_map_repairs_to_pcn_612() {
185        let account = PcnMapper::map_expense_to_pcn(&ExpenseCategory::Repairs);
186
187        assert_eq!(account.code, "612");
188        assert!(account.label_fr.contains("Réparation"));
189    }
190
191    #[test]
192    fn test_map_cleaning_to_pcn_614() {
193        let account = PcnMapper::map_expense_to_pcn(&ExpenseCategory::Cleaning);
194
195        assert_eq!(account.code, "614");
196        assert!(account.label_fr.contains("Nettoyage"));
197    }
198
199    #[test]
200    fn test_map_administration_to_pcn_620() {
201        let account = PcnMapper::map_expense_to_pcn(&ExpenseCategory::Administration);
202
203        assert_eq!(account.code, "620");
204        assert!(
205            account.label_fr.to_lowercase().contains("administration")
206                || account.label_fr.to_lowercase().contains("gestion")
207        );
208    }
209
210    #[test]
211    fn test_map_works_to_pcn_610() {
212        let account = PcnMapper::map_expense_to_pcn(&ExpenseCategory::Works);
213
214        assert_eq!(account.code, "610");
215        assert!(account.label_fr.contains("Travaux"));
216    }
217
218    #[test]
219    fn test_map_other_to_pcn_619() {
220        let account = PcnMapper::map_expense_to_pcn(&ExpenseCategory::Other);
221
222        assert_eq!(account.code, "619");
223        assert!(account.label_fr.contains("Divers") || account.label_fr.contains("Autre"));
224    }
225
226    // Helper function to create test expenses
227    fn create_test_expense(category: ExpenseCategory, amount: f64) -> Expense {
228        use chrono::Utc;
229        use uuid::Uuid;
230
231        let description = format!("Test expense for {:?}", category);
232        Expense::new(
233            Uuid::new_v4(), // organization_id
234            Uuid::new_v4(), // building_id
235            category,
236            description,
237            amount,
238            Utc::now(),
239            Some("Test Supplier".to_string()),
240            Some("INV-001".to_string()),
241        )
242        .unwrap()
243    }
244
245    #[test]
246    fn test_generate_report_empty_expenses() {
247        let expenses: Vec<Expense> = vec![];
248        let report = PcnMapper::generate_report(&expenses);
249
250        assert!(report.is_empty());
251    }
252
253    #[test]
254    fn test_generate_report_single_category() {
255        let expenses = vec![
256            create_test_expense(ExpenseCategory::Maintenance, 100.0),
257            create_test_expense(ExpenseCategory::Maintenance, 150.0),
258        ];
259
260        let report = PcnMapper::generate_report(&expenses);
261
262        assert_eq!(report.len(), 1);
263        assert_eq!(report[0].account.code, "611");
264        assert_eq!(report[0].total_amount, 250.0);
265        assert_eq!(report[0].entry_count, 2);
266    }
267
268    #[test]
269    fn test_generate_report_multiple_categories() {
270        let expenses = vec![
271            create_test_expense(ExpenseCategory::Maintenance, 100.0),
272            create_test_expense(ExpenseCategory::Utilities, 50.0),
273            create_test_expense(ExpenseCategory::Maintenance, 150.0),
274            create_test_expense(ExpenseCategory::Insurance, 200.0),
275        ];
276
277        let report = PcnMapper::generate_report(&expenses);
278
279        assert_eq!(report.len(), 3);
280
281        // Find specific accounts in report
282        let maintenance_line = report.iter().find(|l| l.account.code == "611").unwrap();
283        assert_eq!(maintenance_line.total_amount, 250.0);
284        assert_eq!(maintenance_line.entry_count, 2);
285
286        let utilities_line = report.iter().find(|l| l.account.code == "615").unwrap();
287        assert_eq!(utilities_line.total_amount, 50.0);
288        assert_eq!(utilities_line.entry_count, 1);
289
290        let insurance_line = report.iter().find(|l| l.account.code == "613").unwrap();
291        assert_eq!(insurance_line.total_amount, 200.0);
292        assert_eq!(insurance_line.entry_count, 1);
293    }
294
295    #[test]
296    fn test_generate_report_sorted_by_account_code() {
297        let expenses = vec![
298            create_test_expense(ExpenseCategory::Administration, 100.0), // 620
299            create_test_expense(ExpenseCategory::Works, 200.0),          // 610
300            create_test_expense(ExpenseCategory::Utilities, 50.0),       // 615
301        ];
302
303        let report = PcnMapper::generate_report(&expenses);
304
305        assert_eq!(report.len(), 3);
306        // Should be sorted by account code: 610, 615, 620
307        assert_eq!(report[0].account.code, "610");
308        assert_eq!(report[1].account.code, "615");
309        assert_eq!(report[2].account.code, "620");
310    }
311}