koprogo_api/domain/entities/
journal_entry.rs

1// Domain Entity: Journal Entry
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// Inspired by Noalyss `jrn` table structure
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16/// Journal Entry represents a complete accounting transaction
17/// with balanced debit and credit lines (double-entry bookkeeping).
18///
19/// Each entry contains multiple lines (JournalEntryLine) where:
20/// - Sum of debits = Sum of credits (enforced by database trigger)
21/// - Each line affects one account
22///
23/// Example: Recording a 1,210€ utility expense (1,000€ + 210€ VAT 21%):
24/// - Debit: 6100 (Utilities) 1,000€
25/// - Debit: 4110 (VAT Recoverable) 210€
26/// - Credit: 4400 (Suppliers) 1,210€
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct JournalEntry {
29    pub id: Uuid,
30    pub organization_id: Uuid,
31    /// Optional link to building for building-specific accounting
32    pub building_id: Option<Uuid>,
33    /// Date when the transaction occurred (not when recorded)
34    pub entry_date: DateTime<Utc>,
35    /// Human-readable description (e.g., "Facture eau janvier 2025")
36    pub description: Option<String>,
37    /// Reference to source document (invoice number, receipt, etc.)
38    pub document_ref: Option<String>,
39    /// Journal type: ACH (Purchases), VEN (Sales), FIN (Financial), ODS (Miscellaneous)
40    /// Inspired by Noalyss journal categories
41    pub journal_type: Option<String>,
42    /// Optional link to the expense that generated this entry
43    pub expense_id: Option<Uuid>,
44    /// Optional link to the owner contribution that generated this entry
45    pub contribution_id: Option<Uuid>,
46    /// Lines composing this entry (debits and credits)
47    pub lines: Vec<JournalEntryLine>,
48    pub created_at: DateTime<Utc>,
49    pub updated_at: DateTime<Utc>,
50    pub created_by: Option<Uuid>,
51}
52
53/// Individual debit or credit line within a journal entry
54///
55/// Implements double-entry bookkeeping rule: each line is EITHER debit OR credit
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct JournalEntryLine {
58    pub id: Uuid,
59    pub journal_entry_id: Uuid,
60    pub organization_id: Uuid,
61    /// PCMN account code (e.g., "6100", "4400", "5500")
62    pub account_code: String,
63    /// Debit amount (increases assets/expenses, decreases liabilities/revenue)
64    pub debit: f64,
65    /// Credit amount (decreases assets/expenses, increases liabilities/revenue)
66    pub credit: f64,
67    /// Optional description specific to this line
68    pub description: Option<String>,
69    pub created_at: DateTime<Utc>,
70}
71
72impl JournalEntry {
73    /// Create a new journal entry with validation
74    ///
75    /// # Arguments
76    /// - `organization_id`: Organization owning this entry
77    /// - `entry_date`: Transaction date
78    /// - `description`: Human-readable description
79    /// - `lines`: Debit and credit lines (must balance)
80    ///
81    /// # Returns
82    /// - `Ok(JournalEntry)` if lines balance (within 0.01€ tolerance)
83    /// - `Err(String)` if validation fails
84    #[allow(clippy::too_many_arguments)]
85    pub fn new(
86        organization_id: Uuid,
87        building_id: Option<Uuid>,
88        entry_date: DateTime<Utc>,
89        description: Option<String>,
90        document_ref: Option<String>,
91        journal_type: Option<String>,
92        expense_id: Option<Uuid>,
93        contribution_id: Option<Uuid>,
94        lines: Vec<JournalEntryLine>,
95        created_by: Option<Uuid>,
96    ) -> Result<Self, String> {
97        // Validate lines balance
98        Self::validate_lines_balance(&lines)?;
99
100        // Validate each line
101        for line in &lines {
102            Self::validate_line(line)?;
103        }
104
105        // Validate journal_type if provided (Noalyss-inspired)
106        if let Some(ref jtype) = journal_type {
107            if !["ACH", "VEN", "FIN", "ODS"].contains(&jtype.as_str()) {
108                return Err(format!(
109                    "Invalid journal type: {}. Must be one of: ACH (Purchases), VEN (Sales), FIN (Financial), ODS (Miscellaneous)",
110                    jtype
111                ));
112            }
113        }
114
115        let now = Utc::now();
116        Ok(Self {
117            id: Uuid::new_v4(),
118            organization_id,
119            building_id,
120            entry_date,
121            description,
122            document_ref,
123            journal_type,
124            expense_id,
125            contribution_id,
126            lines,
127            created_at: now,
128            updated_at: now,
129            created_by,
130        })
131    }
132
133    /// Validate that debits equal credits (with small rounding tolerance)
134    fn validate_lines_balance(lines: &[JournalEntryLine]) -> Result<(), String> {
135        if lines.is_empty() {
136            return Err("Journal entry must have at least one line".to_string());
137        }
138
139        let total_debits: f64 = lines.iter().map(|l| l.debit).sum();
140        let total_credits: f64 = lines.iter().map(|l| l.credit).sum();
141
142        let difference = (total_debits - total_credits).abs();
143        const TOLERANCE: f64 = 0.011; // Slightly higher to account for floating-point precision
144        if difference > TOLERANCE {
145            return Err(format!(
146                "Journal entry is unbalanced: debits={:.2}€, credits={:.2}€, difference={:.2}€ (tolerance: {:.2}€)",
147                total_debits, total_credits, difference, TOLERANCE
148            ));
149        }
150
151        Ok(())
152    }
153
154    /// Validate an individual line
155    fn validate_line(line: &JournalEntryLine) -> Result<(), String> {
156        // Must be EITHER debit OR credit (not both, not neither)
157        if line.debit > 0.0 && line.credit > 0.0 {
158            return Err("Line cannot have both debit and credit".to_string());
159        }
160
161        if line.debit == 0.0 && line.credit == 0.0 {
162            return Err("Line must have either debit or credit".to_string());
163        }
164
165        // Amounts must be non-negative
166        if line.debit < 0.0 || line.credit < 0.0 {
167            return Err("Debit and credit amounts must be non-negative".to_string());
168        }
169
170        // Account code required
171        if line.account_code.trim().is_empty() {
172            return Err("Account code is required".to_string());
173        }
174
175        Ok(())
176    }
177
178    /// Calculate total debits for this entry
179    pub fn total_debits(&self) -> f64 {
180        self.lines.iter().map(|l| l.debit).sum()
181    }
182
183    /// Calculate total credits for this entry
184    pub fn total_credits(&self) -> f64 {
185        self.lines.iter().map(|l| l.credit).sum()
186    }
187
188    /// Check if this entry is balanced (debits = credits)
189    pub fn is_balanced(&self) -> bool {
190        const TOLERANCE: f64 = 0.011; // Slightly higher to account for floating-point precision
191        (self.total_debits() - self.total_credits()).abs() <= TOLERANCE
192    }
193}
194
195impl JournalEntryLine {
196    /// Create a new debit line
197    pub fn new_debit(
198        journal_entry_id: Uuid,
199        organization_id: Uuid,
200        account_code: String,
201        amount: f64,
202        description: Option<String>,
203    ) -> Result<Self, String> {
204        if amount <= 0.0 {
205            return Err("Debit amount must be positive".to_string());
206        }
207
208        Ok(Self {
209            id: Uuid::new_v4(),
210            journal_entry_id,
211            organization_id,
212            account_code,
213            debit: amount,
214            credit: 0.0,
215            description,
216            created_at: Utc::now(),
217        })
218    }
219
220    /// Create a new credit line
221    pub fn new_credit(
222        journal_entry_id: Uuid,
223        organization_id: Uuid,
224        account_code: String,
225        amount: f64,
226        description: Option<String>,
227    ) -> Result<Self, String> {
228        if amount <= 0.0 {
229            return Err("Credit amount must be positive".to_string());
230        }
231
232        Ok(Self {
233            id: Uuid::new_v4(),
234            journal_entry_id,
235            organization_id,
236            account_code,
237            debit: 0.0,
238            credit: amount,
239            description,
240            created_at: Utc::now(),
241        })
242    }
243
244    /// Get the amount (whether debit or credit)
245    pub fn amount(&self) -> f64 {
246        if self.debit > 0.0 {
247            self.debit
248        } else {
249            self.credit
250        }
251    }
252
253    /// Check if this is a debit line
254    pub fn is_debit(&self) -> bool {
255        self.debit > 0.0
256    }
257
258    /// Check if this is a credit line
259    pub fn is_credit(&self) -> bool {
260        self.credit > 0.0
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_journal_entry_balanced() {
270        let org_id = Uuid::new_v4();
271        let entry_id = Uuid::new_v4();
272
273        // Utility expense: 1,000€ + 210€ VAT = 1,210€
274        let lines = vec![
275            JournalEntryLine::new_debit(
276                entry_id,
277                org_id,
278                "6100".to_string(),
279                1000.0,
280                Some("Utilities".to_string()),
281            )
282            .unwrap(),
283            JournalEntryLine::new_debit(
284                entry_id,
285                org_id,
286                "4110".to_string(),
287                210.0,
288                Some("VAT 21%".to_string()),
289            )
290            .unwrap(),
291            JournalEntryLine::new_credit(
292                entry_id,
293                org_id,
294                "4400".to_string(),
295                1210.0,
296                Some("Supplier".to_string()),
297            )
298            .unwrap(),
299        ];
300
301        let entry = JournalEntry::new(
302            org_id,
303            None, // building_id
304            Utc::now(),
305            Some("Facture eau".to_string()),
306            Some("INV-2025-001".to_string()),
307            Some("ACH".to_string()), // journal_type
308            None,                    // expense_id
309            None,                    // contribution_id
310            lines,
311            None, // created_by
312        );
313
314        assert!(entry.is_ok());
315        let entry = entry.unwrap();
316        assert!(entry.is_balanced());
317        assert_eq!(entry.total_debits(), 1210.0);
318        assert_eq!(entry.total_credits(), 1210.0);
319    }
320
321    #[test]
322    fn test_journal_entry_unbalanced() {
323        let org_id = Uuid::new_v4();
324        let entry_id = Uuid::new_v4();
325
326        // Unbalanced: 1,000€ debit vs 900€ credit
327        let lines = vec![
328            JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), 1000.0, None)
329                .unwrap(),
330            JournalEntryLine::new_credit(entry_id, org_id, "4400".to_string(), 900.0, None)
331                .unwrap(),
332        ];
333
334        let entry = JournalEntry::new(
335            org_id,
336            None, // building_id
337            Utc::now(),
338            Some("Test".to_string()),
339            None, // document_ref
340            None, // journal_type
341            None, // expense_id
342            None, // contribution_id
343            lines,
344            None, // created_by
345        );
346
347        assert!(entry.is_err());
348        assert!(entry.unwrap_err().contains("unbalanced"));
349    }
350
351    #[test]
352    fn test_journal_entry_line_cannot_have_both_debit_and_credit() {
353        let org_id = Uuid::new_v4();
354        let entry_id = Uuid::new_v4();
355
356        // Invalid line with both debit and credit
357        let invalid_line = JournalEntryLine {
358            id: Uuid::new_v4(),
359            journal_entry_id: entry_id,
360            organization_id: org_id,
361            account_code: "6100".to_string(),
362            debit: 100.0,
363            credit: 100.0, // Invalid!
364            description: None,
365            created_at: Utc::now(),
366        };
367
368        let entry = JournalEntry::new(
369            org_id,
370            None,
371            Utc::now(),
372            Some("Test".to_string()),
373            None,
374            None,
375            None,
376            None,
377            vec![invalid_line],
378            None,
379        );
380
381        assert!(entry.is_err());
382        assert!(entry.unwrap_err().contains("both debit and credit"));
383    }
384
385    #[test]
386    fn test_journal_entry_line_must_have_amount() {
387        let org_id = Uuid::new_v4();
388        let entry_id = Uuid::new_v4();
389
390        // Invalid line with neither debit nor credit
391        let invalid_line = JournalEntryLine {
392            id: Uuid::new_v4(),
393            journal_entry_id: entry_id,
394            organization_id: org_id,
395            account_code: "6100".to_string(),
396            debit: 0.0,
397            credit: 0.0, // Invalid!
398            description: None,
399            created_at: Utc::now(),
400        };
401
402        let entry = JournalEntry::new(
403            org_id,
404            None,
405            Utc::now(),
406            Some("Test".to_string()),
407            None,
408            None,
409            None,
410            None,
411            vec![invalid_line],
412            None,
413        );
414
415        assert!(entry.is_err());
416        assert!(entry.unwrap_err().contains("either debit or credit"));
417    }
418
419    #[test]
420    fn test_rounding_tolerance() {
421        let org_id = Uuid::new_v4();
422        let entry_id = Uuid::new_v4();
423
424        // Small rounding difference (0.01€) should be accepted
425        let lines = vec![
426            JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), 100.33, None)
427                .unwrap(),
428            JournalEntryLine::new_credit(
429                entry_id,
430                org_id,
431                "4400".to_string(),
432                100.34, // 0.01€ difference
433                None,
434            )
435            .unwrap(),
436        ];
437
438        let entry = JournalEntry::new(
439            org_id,
440            None,
441            Utc::now(),
442            Some("Test rounding".to_string()),
443            None,
444            None,
445            None,
446            None,
447            lines,
448            None,
449        );
450
451        if entry.is_err() {
452            eprintln!("Error: {:?}", entry.as_ref().err());
453        }
454        assert!(entry.is_ok());
455        assert!(entry.unwrap().is_balanced());
456    }
457}