koprogo_api/domain/entities/
account.rs

1// Domain Entity: Account (Belgian Normalized Accounting Plan)
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// We extend our sincere thanks to the Noalyss team for their excellent work on
11// implementing the Belgian PCMN (Plan Comptable Minimum Normalisé). Their approach
12// to hierarchical account structures and automatic type detection served as inspiration.
13//
14// References:
15// - Noalyss: https://gitlab.com/noalyss/noalyss
16// - Belgian Royal Decree: AR 12/07/2012
17// - Noalyss class: include/database/tmp_pcmn_sql.class.php
18// - Noalyss class: include/database/acc_plan_sql.class.php
19
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22use uuid::Uuid;
23
24/// Account classification based on Belgian PCMN (Plan Comptable Minimum Normalisé)
25///
26/// This enum maps to Noalyss `pcm_type` field with the following equivalences:
27/// - `Asset` = ACT (Actif) - Classes 2, 3, 4, 5
28/// - `Liability` = PAS (Passif) - Class 1
29/// - `Expense` = CHA (Charges) - Class 6
30/// - `Revenue` = PRO (Produits) - Class 7
31/// - `OffBalance` = CON (Contrôle) - Class 9
32///
33/// Reference: Noalyss tmp_pcmn.pcm_type field
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
35#[sqlx(type_name = "account_type", rename_all = "SCREAMING_SNAKE_CASE")]
36pub enum AccountType {
37    /// Assets (Actif) - Classes 2, 3, 4, 5 in Belgian PCMN
38    /// Examples: Buildings, Inventory, Receivables, Bank accounts
39    Asset,
40
41    /// Liabilities (Passif) - Class 1 in Belgian PCMN
42    /// Examples: Capital, Reserves, Provisions, Debts
43    Liability,
44
45    /// Expenses (Charges) - Class 6 in Belgian PCMN
46    /// Examples: Electricity, Maintenance, Insurance, Salaries
47    Expense,
48
49    /// Revenue (Produits) - Class 7 in Belgian PCMN
50    /// Examples: Regular fees, Extraordinary fees, Interest income
51    Revenue,
52
53    /// Off-balance/Control accounts (Contrôle) - Class 9 in Belgian PCMN
54    /// Examples: Memorandum accounts, Statistical accounts
55    OffBalance,
56}
57
58impl AccountType {
59    /// Automatically detect account type from Belgian PCMN code
60    ///
61    /// Logic inspired by Noalyss `find_pcm_type()` function.
62    /// See: include/sql/mod1/schema.sql in Noalyss repository
63    ///
64    /// # Arguments
65    /// * `code` - Account code (e.g., "700", "604001")
66    ///
67    /// # Returns
68    /// Detected `AccountType` based on first digit (Belgian PCMN class)
69    ///
70    /// # Examples
71    /// ```
72    /// use koprogo_api::domain::entities::account::AccountType;
73    ///
74    /// assert_eq!(AccountType::from_code("700"), AccountType::Revenue);
75    /// assert_eq!(AccountType::from_code("604001"), AccountType::Expense);
76    /// assert_eq!(AccountType::from_code("100"), AccountType::Liability);
77    /// assert_eq!(AccountType::from_code("5500"), AccountType::Asset);
78    /// ```
79    pub fn from_code(code: &str) -> Self {
80        if code.is_empty() {
81            return AccountType::OffBalance;
82        }
83
84        // Extract first character (Belgian PCMN class)
85        match &code[0..1] {
86            "1" => AccountType::Liability, // Class 1: Capital, reserves
87            "2" | "3" | "4" | "5" => AccountType::Asset, // Classes 2-5: Assets
88            "6" => AccountType::Expense,   // Class 6: Expenses
89            "7" => AccountType::Revenue,   // Class 7: Revenue
90            "8" => AccountType::Expense,   // Class 8: Special (rarely used)
91            "9" => AccountType::OffBalance, // Class 9: Off-balance
92            _ => AccountType::OffBalance,  // Unknown: default to off-balance
93        }
94    }
95
96    /// Check if this account type appears on the balance sheet
97    ///
98    /// Balance sheet accounts: Assets & Liabilities (Classes 1-5)
99    /// Income statement accounts: Expenses & Revenue (Classes 6-7)
100    pub fn is_balance_sheet(&self) -> bool {
101        matches!(self, AccountType::Asset | AccountType::Liability)
102    }
103
104    /// Check if this account type appears on the income statement
105    ///
106    /// Income statement (Compte de résultat): Expenses & Revenue
107    pub fn is_income_statement(&self) -> bool {
108        matches!(self, AccountType::Expense | AccountType::Revenue)
109    }
110}
111
112/// Account in the Belgian Normalized Accounting Plan (PCMN)
113///
114/// Represents a single account in the hierarchical chart of accounts.
115/// Structure inspired by Noalyss `tmp_pcmn` table.
116///
117/// # Hierarchical Structure
118///
119/// Accounts can have parent-child relationships for organization:
120/// ```text
121/// 6           (Charges/Expenses - parent: None)
122///   60        (Approvisionnements - parent: "6")
123///     604     (Fournitures - parent: "60")
124///       604001 (Électricité - parent: "604")
125/// ```
126///
127/// # Belgian PCMN Classes
128///
129/// - Class 1: Liabilities (Capital, Reserves, Provisions)
130/// - Classes 2-5: Assets (Fixed assets, Inventory, Receivables, Cash)
131/// - Class 6: Expenses (Purchases, Services, Salaries)
132/// - Class 7: Revenue (Sales, Services, Financial income)
133/// - Class 9: Off-balance (Control accounts)
134///
135/// Reference: Belgian Royal Decree AR 12/07/2012
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct Account {
138    /// Unique identifier
139    pub id: Uuid,
140
141    /// Account code (e.g., "700", "604001", "100")
142    ///
143    /// Can be hierarchical. Parent codes are typically shorter.
144    /// Example: "604001" is child of "604" which is child of "60"
145    pub code: String,
146
147    /// Account label/description
148    ///
149    /// Examples:
150    /// - "Électricité" (Electricity)
151    /// - "Appels de fonds ordinaires" (Regular fees)
152    /// - "Assurance immeuble" (Building insurance)
153    pub label: String,
154
155    /// Parent account code for hierarchical organization
156    ///
157    /// None if this is a top-level account (e.g., "6", "7")
158    /// Some("604") if this is a child account (e.g., "604001")
159    pub parent_code: Option<String>,
160
161    /// Account classification (Asset, Liability, Expense, Revenue, OffBalance)
162    pub account_type: AccountType,
163
164    /// Whether this account can be used directly in journal entries
165    ///
166    /// - true: Can post transactions to this account (e.g., "604001" - Electricity)
167    /// - false: Summary account only (e.g., "60" - Approvisionnements)
168    ///
169    /// Corresponds to Noalyss `pcm_direct_use` field (Y/N)
170    pub direct_use: bool,
171
172    /// Organization this account belongs to (multi-tenancy)
173    pub organization_id: Uuid,
174
175    /// Creation timestamp
176    pub created_at: DateTime<Utc>,
177
178    /// Last update timestamp
179    pub updated_at: DateTime<Utc>,
180}
181
182impl Account {
183    /// Create a new account with validation
184    ///
185    /// # Arguments
186    /// * `code` - Account code (must be non-empty)
187    /// * `label` - Account description (must be non-empty)
188    /// * `parent_code` - Optional parent account code
189    /// * `account_type` - Account classification
190    /// * `direct_use` - Whether account can be used in transactions
191    /// * `organization_id` - Organization ID
192    ///
193    /// # Returns
194    /// `Ok(Account)` if validation passes, `Err(String)` otherwise
195    ///
196    /// # Validation Rules
197    /// 1. Code must not be empty
198    /// 2. Code must be alphanumeric (can contain letters for auxiliary accounts)
199    /// 3. Label must not be empty
200    /// 4. Label must be <= 255 characters
201    /// 5. Parent code cannot equal code (prevent self-reference)
202    pub fn new(
203        code: String,
204        label: String,
205        parent_code: Option<String>,
206        account_type: AccountType,
207        direct_use: bool,
208        organization_id: Uuid,
209    ) -> Result<Self, String> {
210        // Validation: code must not be empty
211        if code.trim().is_empty() {
212            return Err("Account code cannot be empty".to_string());
213        }
214
215        // Validation: code must be reasonable length (max 40 chars per SQL)
216        if code.len() > 40 {
217            return Err("Account code cannot exceed 40 characters".to_string());
218        }
219
220        // Validation: label must not be empty
221        if label.trim().is_empty() {
222            return Err("Account label cannot be empty".to_string());
223        }
224
225        // Validation: label max length (reasonable limit)
226        if label.len() > 255 {
227            return Err("Account label cannot exceed 255 characters".to_string());
228        }
229
230        // Validation: parent_code cannot equal code (prevent self-reference)
231        if let Some(ref parent) = parent_code {
232            if parent == &code {
233                return Err("Account cannot be its own parent".to_string());
234            }
235        }
236
237        let now = Utc::now();
238
239        Ok(Account {
240            id: Uuid::new_v4(),
241            code,
242            label,
243            parent_code,
244            account_type,
245            direct_use,
246            organization_id,
247            created_at: now,
248            updated_at: now,
249        })
250    }
251
252    /// Get the account class (first digit for Belgian PCMN)
253    ///
254    /// # Examples
255    /// ```
256    /// # use koprogo_api::domain::entities::account::Account;
257    /// # use uuid::Uuid;
258    /// # let org_id = Uuid::new_v4();
259    /// let account = Account::new(
260    ///     "604001".to_string(),
261    ///     "Electricity".to_string(),
262    ///     Some("604".to_string()),
263    ///     account::AccountType::Expense,
264    ///     true,
265    ///     org_id
266    /// ).unwrap();
267    ///
268    /// assert_eq!(account.get_class(), "6");
269    /// ```
270    pub fn get_class(&self) -> &str {
271        if self.code.is_empty() {
272            return "";
273        }
274        &self.code[0..1]
275    }
276
277    /// Check if this is a top-level account (no parent)
278    pub fn is_root(&self) -> bool {
279        self.parent_code.is_none()
280    }
281
282    /// Update account details
283    ///
284    /// # Arguments
285    /// * `label` - New label (optional, keeps current if None)
286    /// * `parent_code` - New parent code (optional, keeps current if None)
287    /// * `account_type` - New type (optional, keeps current if None)
288    /// * `direct_use` - New direct use flag (optional, keeps current if None)
289    ///
290    /// # Returns
291    /// `Ok(())` if validation passes, `Err(String)` otherwise
292    pub fn update(
293        &mut self,
294        label: Option<String>,
295        parent_code: Option<Option<String>>,
296        account_type: Option<AccountType>,
297        direct_use: Option<bool>,
298    ) -> Result<(), String> {
299        // Update label if provided
300        if let Some(new_label) = label {
301            if new_label.trim().is_empty() {
302                return Err("Account label cannot be empty".to_string());
303            }
304            if new_label.len() > 255 {
305                return Err("Account label cannot exceed 255 characters".to_string());
306            }
307            self.label = new_label;
308        }
309
310        // Update parent_code if provided
311        if let Some(new_parent) = parent_code {
312            if let Some(ref parent) = new_parent {
313                if parent == &self.code {
314                    return Err("Account cannot be its own parent".to_string());
315                }
316            }
317            self.parent_code = new_parent;
318        }
319
320        // Update account_type if provided
321        if let Some(new_type) = account_type {
322            self.account_type = new_type;
323        }
324
325        // Update direct_use if provided
326        if let Some(new_direct_use) = direct_use {
327            self.direct_use = new_direct_use;
328        }
329
330        self.updated_at = Utc::now();
331        Ok(())
332    }
333}
334
335// ============================================================================
336// UNIT TESTS
337// ============================================================================
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn test_account_type_from_code() {
345        // Test Belgian PCMN classes
346        assert_eq!(AccountType::from_code("100"), AccountType::Liability); // Class 1
347        assert_eq!(AccountType::from_code("280"), AccountType::Asset); // Class 2
348        assert_eq!(AccountType::from_code("3400"), AccountType::Asset); // Class 3
349        assert_eq!(AccountType::from_code("400"), AccountType::Asset); // Class 4
350        assert_eq!(AccountType::from_code("5500"), AccountType::Asset); // Class 5
351        assert_eq!(AccountType::from_code("604001"), AccountType::Expense); // Class 6
352        assert_eq!(AccountType::from_code("700"), AccountType::Revenue); // Class 7
353        assert_eq!(AccountType::from_code("900"), AccountType::OffBalance); // Class 9
354
355        // Test edge cases
356        assert_eq!(AccountType::from_code(""), AccountType::OffBalance);
357        assert_eq!(AccountType::from_code("X123"), AccountType::OffBalance);
358    }
359
360    #[test]
361    fn test_account_type_is_balance_sheet() {
362        assert!(AccountType::Asset.is_balance_sheet());
363        assert!(AccountType::Liability.is_balance_sheet());
364        assert!(!AccountType::Expense.is_balance_sheet());
365        assert!(!AccountType::Revenue.is_balance_sheet());
366        assert!(!AccountType::OffBalance.is_balance_sheet());
367    }
368
369    #[test]
370    fn test_account_type_is_income_statement() {
371        assert!(AccountType::Expense.is_income_statement());
372        assert!(AccountType::Revenue.is_income_statement());
373        assert!(!AccountType::Asset.is_income_statement());
374        assert!(!AccountType::Liability.is_income_statement());
375        assert!(!AccountType::OffBalance.is_income_statement());
376    }
377
378    #[test]
379    fn test_create_account_success() {
380        let org_id = Uuid::new_v4();
381        let account = Account::new(
382            "604001".to_string(),
383            "Électricité".to_string(),
384            Some("604".to_string()),
385            AccountType::Expense,
386            true,
387            org_id,
388        );
389
390        assert!(account.is_ok());
391        let account = account.unwrap();
392        assert_eq!(account.code, "604001");
393        assert_eq!(account.label, "Électricité");
394        assert_eq!(account.parent_code, Some("604".to_string()));
395        assert_eq!(account.account_type, AccountType::Expense);
396        assert!(account.direct_use);
397        assert_eq!(account.organization_id, org_id);
398    }
399
400    #[test]
401    fn test_create_account_empty_code() {
402        let org_id = Uuid::new_v4();
403        let result = Account::new(
404            "".to_string(),
405            "Test".to_string(),
406            None,
407            AccountType::Expense,
408            true,
409            org_id,
410        );
411
412        assert!(result.is_err());
413        assert_eq!(result.unwrap_err(), "Account code cannot be empty");
414    }
415
416    #[test]
417    fn test_create_account_empty_label() {
418        let org_id = Uuid::new_v4();
419        let result = Account::new(
420            "700".to_string(),
421            "".to_string(),
422            None,
423            AccountType::Revenue,
424            true,
425            org_id,
426        );
427
428        assert!(result.is_err());
429        assert_eq!(result.unwrap_err(), "Account label cannot be empty");
430    }
431
432    #[test]
433    fn test_create_account_self_parent() {
434        let org_id = Uuid::new_v4();
435        let result = Account::new(
436            "700".to_string(),
437            "Test".to_string(),
438            Some("700".to_string()), // Same as code!
439            AccountType::Revenue,
440            true,
441            org_id,
442        );
443
444        assert!(result.is_err());
445        assert_eq!(result.unwrap_err(), "Account cannot be its own parent");
446    }
447
448    #[test]
449    fn test_account_get_class() {
450        let org_id = Uuid::new_v4();
451        let account = Account::new(
452            "604001".to_string(),
453            "Test".to_string(),
454            None,
455            AccountType::Expense,
456            true,
457            org_id,
458        )
459        .unwrap();
460
461        assert_eq!(account.get_class(), "6");
462    }
463
464    #[test]
465    fn test_account_is_root() {
466        let org_id = Uuid::new_v4();
467
468        let root = Account::new(
469            "6".to_string(),
470            "Charges".to_string(),
471            None,
472            AccountType::Expense,
473            false,
474            org_id,
475        )
476        .unwrap();
477
478        let child = Account::new(
479            "604".to_string(),
480            "Fournitures".to_string(),
481            Some("6".to_string()),
482            AccountType::Expense,
483            false,
484            org_id,
485        )
486        .unwrap();
487
488        assert!(root.is_root());
489        assert!(!child.is_root());
490    }
491
492    #[test]
493    fn test_account_update_success() {
494        let org_id = Uuid::new_v4();
495        let mut account = Account::new(
496            "700".to_string(),
497            "Old Label".to_string(),
498            None,
499            AccountType::Revenue,
500            true,
501            org_id,
502        )
503        .unwrap();
504
505        let result = account.update(
506            Some("New Label".to_string()),
507            Some(Some("70".to_string())),
508            Some(AccountType::Revenue),
509            Some(false),
510        );
511
512        assert!(result.is_ok());
513        assert_eq!(account.label, "New Label");
514        assert_eq!(account.parent_code, Some("70".to_string()));
515        assert!(!account.direct_use);
516    }
517
518    #[test]
519    fn test_account_update_self_parent() {
520        let org_id = Uuid::new_v4();
521        let mut account = Account::new(
522            "700".to_string(),
523            "Test".to_string(),
524            None,
525            AccountType::Revenue,
526            true,
527            org_id,
528        )
529        .unwrap();
530
531        let result = account.update(None, Some(Some("700".to_string())), None, None);
532
533        assert!(result.is_err());
534        assert_eq!(result.unwrap_err(), "Account cannot be its own parent");
535    }
536}