Skip to main content

koprogo_api/domain/services/
pcn_mapper.rs

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