Skip to main content

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