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}