Skip to main content

koprogo_api/domain/comptabilite/
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
36    /// L'ACP dont ces comptes sont les comptes.
37    ///
38    /// Art. 3.89 § 5, 15° : le syndic tient « les comptes de l'association des
39    /// copropriétaires » suivant le plan comptable minimum normalisé. Il les
40    /// tient ; ils ne sont pas les siens. Une écriture sans ACP est une
41    /// écriture dans les livres de personne. Cf. ADR-0045.
42    ///
43    /// Obligatoire, contrairement à `building_id` : une ACP peut avoir
44    /// plusieurs immeubles et des écritures qui ne se rattachent à aucun
45    /// (Art. 3.84, groupe d'immeubles), mais aucune écriture n'existe hors
46    /// d'une comptabilité.
47    pub acp_id: Uuid,
48
49    /// Le syndic qui a passé l'écriture, conservé comme trace d'auteur.
50    pub organization_id: Uuid,
51    /// Optional link to building for building-specific accounting
52    pub building_id: Option<Uuid>,
53    /// Date when the transaction occurred (not when recorded)
54    pub entry_date: DateTime<Utc>,
55    /// Human-readable description (e.g., "Facture eau janvier 2025")
56    pub description: Option<String>,
57    /// Reference to source document (invoice number, receipt, etc.)
58    pub document_ref: Option<String>,
59    /// Journal type: ACH (Purchases), VEN (Sales), FIN (Financial), ODS (Miscellaneous)
60    /// Inspired by Noalyss journal categories
61    pub journal_type: Option<String>,
62    /// Optional link to the expense that generated this entry
63    pub expense_id: Option<Uuid>,
64    /// Optional link to the owner contribution that generated this entry
65    pub contribution_id: Option<Uuid>,
66    /// Lines composing this entry (debits and credits)
67    pub lines: Vec<JournalEntryLine>,
68    pub created_at: DateTime<Utc>,
69    pub updated_at: DateTime<Utc>,
70    pub created_by: Option<Uuid>,
71}
72
73/// Individual debit or credit line within a journal entry
74///
75/// Implements double-entry bookkeeping rule: each line is EITHER debit OR credit
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct JournalEntryLine {
78    pub id: Uuid,
79    pub journal_entry_id: Uuid,
80    pub organization_id: Uuid,
81    /// PCMN account code (e.g., "6100", "4400", "5500")
82    pub account_code: String,
83    /// Debit amount (increases assets/expenses, decreases liabilities/revenue)
84    pub debit: Decimal,
85    /// Credit amount (decreases assets/expenses, increases liabilities/revenue)
86    pub credit: Decimal,
87    /// Optional description specific to this line
88    pub description: Option<String>,
89    pub created_at: DateTime<Utc>,
90}
91
92/// Tolerance for double-entry balance check (1 centime + epsilon).
93const BALANCE_TOLERANCE: Decimal = dec!(0.011);
94
95/// Domain-typed validation error for journal entries (double-entry bookkeeping).
96///
97/// Pure domain type — no infrastructure/application dependency (hexagonal
98/// purity). Follows the codebase precedent `ProxyValidationError` (vote.rs):
99/// the entity returns its own typed error; the application layer maps it to
100/// `AppError` (see `impl From<JournalEntryError> for AppError`) so a malformed
101/// entry surfaces as a 400 validation error, not a 500 Internal (#433 / WP-A3).
102#[derive(Debug, Clone, PartialEq)]
103pub enum JournalEntryError {
104    /// Entry has no lines.
105    NoLines,
106    /// Sum of debits ≠ sum of credits beyond tolerance (double-entry rule).
107    Unbalanced {
108        debits: Decimal,
109        credits: Decimal,
110        difference: Decimal,
111        tolerance: Decimal,
112    },
113    /// A line carries both a debit and a credit amount.
114    LineHasBothDebitAndCredit,
115    /// A line carries neither a debit nor a credit amount.
116    LineHasNeitherDebitNorCredit,
117    /// A line has a negative debit or credit amount.
118    NegativeAmount,
119    /// A line is missing its PCMN account code.
120    MissingAccountCode,
121    /// Journal type is not one of ACH / VEN / FIN / ODS.
122    InvalidJournalType(String),
123    /// Debit line amount is not strictly positive.
124    NonPositiveDebit,
125    /// Credit line amount is not strictly positive.
126    NonPositiveCredit,
127    /// A line belongs to a different organization than the entry
128    /// (cross-org line injection — tenant isolation breach).
129    CrossOrgLine,
130}
131
132impl std::fmt::Display for JournalEntryError {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            Self::NoLines => write!(f, "Journal entry must have at least one line"),
136            Self::Unbalanced {
137                debits,
138                credits,
139                difference,
140                tolerance,
141            } => write!(
142                f,
143                "Journal entry is unbalanced: debits={}€, credits={}€, difference={}€ (tolerance: {}€)",
144                debits, credits, difference, tolerance
145            ),
146            Self::LineHasBothDebitAndCredit => {
147                write!(f, "Line cannot have both debit and credit")
148            }
149            Self::LineHasNeitherDebitNorCredit => {
150                write!(f, "Line must have either debit or credit")
151            }
152            Self::NegativeAmount => {
153                write!(f, "Debit and credit amounts must be non-negative")
154            }
155            Self::MissingAccountCode => write!(f, "Account code is required"),
156            Self::InvalidJournalType(jtype) => write!(
157                f,
158                "Invalid journal type: {}. Must be one of: ACH (Purchases), VEN (Sales), FIN (Financial), ODS (Miscellaneous)",
159                jtype
160            ),
161            Self::NonPositiveDebit => write!(f, "Debit amount must be positive"),
162            Self::NonPositiveCredit => write!(f, "Credit amount must be positive"),
163            Self::CrossOrgLine => write!(
164                f,
165                "Journal entry line belongs to a different organization than the entry (cross-org isolation)"
166            ),
167        }
168    }
169}
170
171impl std::error::Error for JournalEntryError {}
172
173/// Bridge so existing `Result<_, String>` use-cases keep compiling while the
174/// entity is typed (the use-case/port String→AppError cascade is a distinct,
175/// broader slice — out of WP-A3 scope). Pure, std-only.
176impl From<JournalEntryError> for String {
177    fn from(e: JournalEntryError) -> String {
178        e.to_string()
179    }
180}
181
182impl JournalEntry {
183    /// Create a new journal entry with validation
184    ///
185    /// # Arguments
186    /// - `organization_id`: Organization owning this entry
187    /// - `entry_date`: Transaction date
188    /// - `description`: Human-readable description
189    /// - `lines`: Debit and credit lines (must balance)
190    ///
191    /// # Returns
192    /// - `Ok(JournalEntry)` if lines balance (within 0.01€ tolerance)
193    /// - `Err(String)` if validation fails
194    #[allow(clippy::too_many_arguments)]
195    pub fn new(
196        acp_id: Uuid,
197        organization_id: Uuid,
198        building_id: Option<Uuid>,
199        entry_date: DateTime<Utc>,
200        description: Option<String>,
201        document_ref: Option<String>,
202        journal_type: Option<String>,
203        expense_id: Option<Uuid>,
204        contribution_id: Option<Uuid>,
205        lines: Vec<JournalEntryLine>,
206        created_by: Option<Uuid>,
207    ) -> Result<Self, JournalEntryError> {
208        // Validate lines balance
209        Self::validate_lines_balance(&lines)?;
210
211        // Validate each line + tenant isolation: a line must belong to the
212        // same organization as the entry (prevents cross-org line injection
213        // into another tenant's books — @security invariant, #433/WP-A3).
214        for line in &lines {
215            Self::validate_line(line)?;
216            if line.organization_id != organization_id {
217                return Err(JournalEntryError::CrossOrgLine);
218            }
219        }
220
221        // Validate journal_type if provided (Noalyss-inspired)
222        if let Some(ref jtype) = journal_type {
223            if !["ACH", "VEN", "FIN", "ODS"].contains(&jtype.as_str()) {
224                return Err(JournalEntryError::InvalidJournalType(jtype.clone()));
225            }
226        }
227
228        let now = Utc::now();
229        Ok(Self {
230            id: Uuid::new_v4(),
231            acp_id,
232            organization_id,
233            building_id,
234            entry_date,
235            description,
236            document_ref,
237            journal_type,
238            expense_id,
239            contribution_id,
240            lines,
241            created_at: now,
242            updated_at: now,
243            created_by,
244        })
245    }
246
247    /// Validate that debits equal credits (with small rounding tolerance)
248    fn validate_lines_balance(lines: &[JournalEntryLine]) -> Result<(), JournalEntryError> {
249        if lines.is_empty() {
250            return Err(JournalEntryError::NoLines);
251        }
252
253        let total_debits: Decimal = lines.iter().map(|l| l.debit).sum();
254        let total_credits: Decimal = lines.iter().map(|l| l.credit).sum();
255
256        let difference = (total_debits - total_credits).abs();
257        if difference > BALANCE_TOLERANCE {
258            return Err(JournalEntryError::Unbalanced {
259                debits: total_debits,
260                credits: total_credits,
261                difference,
262                tolerance: BALANCE_TOLERANCE,
263            });
264        }
265
266        Ok(())
267    }
268
269    /// Validate an individual line
270    fn validate_line(line: &JournalEntryLine) -> Result<(), JournalEntryError> {
271        // Must be EITHER debit OR credit (not both, not neither)
272        if line.debit > Decimal::ZERO && line.credit > Decimal::ZERO {
273            return Err(JournalEntryError::LineHasBothDebitAndCredit);
274        }
275
276        if line.debit == Decimal::ZERO && line.credit == Decimal::ZERO {
277            return Err(JournalEntryError::LineHasNeitherDebitNorCredit);
278        }
279
280        // Amounts must be non-negative
281        if line.debit < Decimal::ZERO || line.credit < Decimal::ZERO {
282            return Err(JournalEntryError::NegativeAmount);
283        }
284
285        // Account code required
286        if line.account_code.trim().is_empty() {
287            return Err(JournalEntryError::MissingAccountCode);
288        }
289
290        Ok(())
291    }
292
293    /// Calculate total debits for this entry
294    pub fn total_debits(&self) -> Decimal {
295        self.lines.iter().map(|l| l.debit).sum()
296    }
297
298    /// Calculate total credits for this entry
299    pub fn total_credits(&self) -> Decimal {
300        self.lines.iter().map(|l| l.credit).sum()
301    }
302
303    /// Check if this entry is balanced (debits = credits)
304    pub fn is_balanced(&self) -> bool {
305        (self.total_debits() - self.total_credits()).abs() <= BALANCE_TOLERANCE
306    }
307}
308
309impl JournalEntryLine {
310    /// Create a new debit line
311    pub fn new_debit(
312        journal_entry_id: Uuid,
313        organization_id: Uuid,
314        account_code: String,
315        amount: Decimal,
316        description: Option<String>,
317    ) -> Result<Self, JournalEntryError> {
318        if amount <= Decimal::ZERO {
319            return Err(JournalEntryError::NonPositiveDebit);
320        }
321
322        Ok(Self {
323            id: Uuid::new_v4(),
324            journal_entry_id,
325            organization_id,
326            account_code,
327            debit: amount,
328            credit: Decimal::ZERO,
329            description,
330            created_at: Utc::now(),
331        })
332    }
333
334    /// Create a new credit line
335    pub fn new_credit(
336        journal_entry_id: Uuid,
337        organization_id: Uuid,
338        account_code: String,
339        amount: Decimal,
340        description: Option<String>,
341    ) -> Result<Self, JournalEntryError> {
342        if amount <= Decimal::ZERO {
343            return Err(JournalEntryError::NonPositiveCredit);
344        }
345
346        Ok(Self {
347            id: Uuid::new_v4(),
348            journal_entry_id,
349            organization_id,
350            account_code,
351            debit: Decimal::ZERO,
352            credit: amount,
353            description,
354            created_at: Utc::now(),
355        })
356    }
357
358    /// Get the amount (whether debit or credit)
359    pub fn amount(&self) -> Decimal {
360        if self.debit > Decimal::ZERO {
361            self.debit
362        } else {
363            self.credit
364        }
365    }
366
367    /// Check if this is a debit line
368    pub fn is_debit(&self) -> bool {
369        self.debit > Decimal::ZERO
370    }
371
372    /// Check if this is a credit line
373    pub fn is_credit(&self) -> bool {
374        self.credit > Decimal::ZERO
375    }
376}
377
378impl crate::domain::services::PieceDeGestion for JournalEntry {
379    fn acp_id(&self) -> Uuid {
380        self.acp_id
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_journal_entry_balanced() {
390        let org_id = Uuid::new_v4();
391        let entry_id = Uuid::new_v4();
392
393        // Utility expense: 1,000€ + 210€ VAT = 1,210€
394        let lines = vec![
395            JournalEntryLine::new_debit(
396                entry_id,
397                org_id,
398                "6100".to_string(),
399                dec!(1000),
400                Some("Utilities".to_string()),
401            )
402            .unwrap(),
403            JournalEntryLine::new_debit(
404                entry_id,
405                org_id,
406                "4110".to_string(),
407                dec!(210),
408                Some("VAT 21%".to_string()),
409            )
410            .unwrap(),
411            JournalEntryLine::new_credit(
412                entry_id,
413                org_id,
414                "4400".to_string(),
415                dec!(1210),
416                Some("Supplier".to_string()),
417            )
418            .unwrap(),
419        ];
420
421        let entry = JournalEntry::new(
422            Uuid::new_v4(), // acp_id
423            org_id,
424            None, // building_id
425            Utc::now(),
426            Some("Facture eau".to_string()),
427            Some("INV-2025-001".to_string()),
428            Some("ACH".to_string()), // journal_type
429            None,                    // expense_id
430            None,                    // contribution_id
431            lines,
432            None, // created_by
433        );
434
435        assert!(entry.is_ok());
436        let entry = entry.unwrap();
437        assert!(entry.is_balanced());
438        assert_eq!(entry.total_debits(), dec!(1210));
439        assert_eq!(entry.total_credits(), dec!(1210));
440    }
441
442    #[test]
443    fn test_journal_entry_unbalanced() {
444        let org_id = Uuid::new_v4();
445        let entry_id = Uuid::new_v4();
446
447        // Unbalanced: 1,000€ debit vs 900€ credit
448        let lines = vec![
449            JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), dec!(1000), None)
450                .unwrap(),
451            JournalEntryLine::new_credit(entry_id, org_id, "4400".to_string(), dec!(900), None)
452                .unwrap(),
453        ];
454
455        let entry = JournalEntry::new(
456            Uuid::new_v4(), // acp_id
457            org_id,
458            None, // building_id
459            Utc::now(),
460            Some("Test".to_string()),
461            None, // document_ref
462            None, // journal_type
463            None, // expense_id
464            None, // contribution_id
465            lines,
466            None, // created_by
467        );
468
469        assert!(entry.is_err());
470        assert!(matches!(
471            entry.unwrap_err(),
472            JournalEntryError::Unbalanced { .. }
473        ));
474    }
475
476    #[test]
477    fn test_journal_entry_line_cannot_have_both_debit_and_credit() {
478        let org_id = Uuid::new_v4();
479        let entry_id = Uuid::new_v4();
480
481        // Invalid line with both debit and credit
482        let invalid_line = JournalEntryLine {
483            id: Uuid::new_v4(),
484            journal_entry_id: entry_id,
485            organization_id: org_id,
486            account_code: "6100".to_string(),
487            debit: dec!(100),
488            credit: dec!(100), // Invalid!
489            description: None,
490            created_at: Utc::now(),
491        };
492
493        let entry = JournalEntry::new(
494            Uuid::new_v4(), // acp_id
495            org_id,
496            None,
497            Utc::now(),
498            Some("Test".to_string()),
499            None,
500            None,
501            None,
502            None,
503            vec![invalid_line],
504            None,
505        );
506
507        assert!(entry.is_err());
508        assert!(matches!(
509            entry.unwrap_err(),
510            JournalEntryError::LineHasBothDebitAndCredit
511        ));
512    }
513
514    #[test]
515    fn test_journal_entry_line_must_have_amount() {
516        let org_id = Uuid::new_v4();
517        let entry_id = Uuid::new_v4();
518
519        // Invalid line with neither debit nor credit
520        let invalid_line = JournalEntryLine {
521            id: Uuid::new_v4(),
522            journal_entry_id: entry_id,
523            organization_id: org_id,
524            account_code: "6100".to_string(),
525            debit: Decimal::ZERO,
526            credit: Decimal::ZERO, // Invalid!
527            description: None,
528            created_at: Utc::now(),
529        };
530
531        let entry = JournalEntry::new(
532            Uuid::new_v4(), // acp_id
533            org_id,
534            None,
535            Utc::now(),
536            Some("Test".to_string()),
537            None,
538            None,
539            None,
540            None,
541            vec![invalid_line],
542            None,
543        );
544
545        assert!(entry.is_err());
546        assert!(matches!(
547            entry.unwrap_err(),
548            JournalEntryError::LineHasNeitherDebitNorCredit
549        ));
550    }
551
552    #[test]
553    fn test_rounding_tolerance() {
554        let org_id = Uuid::new_v4();
555        let entry_id = Uuid::new_v4();
556
557        // Small rounding difference (0.01€) should be accepted
558        let lines = vec![
559            JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), dec!(100.33), None)
560                .unwrap(),
561            JournalEntryLine::new_credit(
562                entry_id,
563                org_id,
564                "4400".to_string(),
565                dec!(100.34), // 0.01€ difference
566                None,
567            )
568            .unwrap(),
569        ];
570
571        let entry = JournalEntry::new(
572            Uuid::new_v4(), // acp_id
573            org_id,
574            None,
575            Utc::now(),
576            Some("Test rounding".to_string()),
577            None,
578            None,
579            None,
580            None,
581            lines,
582            None,
583        );
584
585        if entry.is_err() {
586            eprintln!("Error: {:?}", entry.as_ref().err());
587        }
588        assert!(entry.is_ok());
589        assert!(entry.unwrap().is_balanced());
590    }
591
592    /// @edge — Decimal exactness preserved on cumulative sums (ADR-0007).
593    /// IEEE 754 fails this: 0.1 + 0.2 != 0.3 in f64.
594    #[test]
595    fn edge_decimal_exactness_preserved_on_cumul() {
596        let org_id = Uuid::new_v4();
597        let entry_id = Uuid::new_v4();
598
599        let lines = vec![
600            JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), dec!(0.1), None)
601                .unwrap(),
602            JournalEntryLine::new_debit(entry_id, org_id, "6101".to_string(), dec!(0.2), None)
603                .unwrap(),
604            JournalEntryLine::new_credit(entry_id, org_id, "4400".to_string(), dec!(0.3), None)
605                .unwrap(),
606        ];
607
608        let entry = JournalEntry::new(
609            Uuid::new_v4(), // acp_id
610            org_id,
611            None,
612            Utc::now(),
613            None,
614            None,
615            None,
616            None,
617            None,
618            lines,
619            None,
620        )
621        .expect("0.1 + 0.2 = 0.3 must balance exactly with Decimal");
622
623        assert_eq!(entry.total_debits(), dec!(0.3));
624        assert_eq!(entry.total_credits(), dec!(0.3));
625        assert!(entry.is_balanced());
626    }
627
628    /// @negative — Negative debit must be rejected.
629    #[test]
630    fn negative_debit_amount_rejected() {
631        let result = JournalEntryLine::new_debit(
632            Uuid::new_v4(),
633            Uuid::new_v4(),
634            "6100".to_string(),
635            dec!(-1),
636            None,
637        );
638        assert!(result.is_err());
639        assert!(matches!(
640            result.unwrap_err(),
641            JournalEntryError::NonPositiveDebit
642        ));
643    }
644
645    /// @security — A line belonging to another organization must be rejected
646    /// (cross-org line injection into another tenant's books, #433/WP-A3).
647    #[test]
648    fn security_cross_org_line_rejected() {
649        let org_id = Uuid::new_v4();
650        let other_org_id = Uuid::new_v4();
651        let entry_id = Uuid::new_v4();
652
653        // Balanced pair (100 = 100) — the ONLY defect is the credit line
654        // belonging to a different organization than the entry.
655        let lines = vec![
656            JournalEntryLine::new_debit(entry_id, org_id, "6100".to_string(), dec!(100), None)
657                .unwrap(),
658            JournalEntryLine::new_credit(
659                entry_id,
660                other_org_id,
661                "4400".to_string(),
662                dec!(100),
663                None,
664            )
665            .unwrap(),
666        ];
667
668        let result = JournalEntry::new(
669            Uuid::new_v4(), // acp_id
670            org_id,
671            None,
672            Utc::now(),
673            None,
674            None,
675            None,
676            None,
677            None,
678            lines,
679            None,
680        );
681
682        assert!(result.is_err());
683        assert!(matches!(
684            result.unwrap_err(),
685            JournalEntryError::CrossOrgLine
686        ));
687    }
688}